@bongos/core 1.20.26 → 1.20.28
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 +117 -47
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -1
- package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +2 -2
- package/docs/adr/0355-a-soft-limit-is-a-rule-with-a-reason-and-nobody-enforces-it.md +30 -0
- package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +46 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +105 -5
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +37 -36
- package/docs/copy-registry.json +52 -43
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +70 -68
- package/migrations/core_266_access_requests_invited_login_idx.sql +31 -0
- package/modules/government/board.js +64 -7
- package/modules/government/charter.js +232 -0
- package/modules/government/routes/government.js +62 -0
- package/modules/hall-ui/public/government.js +11 -0
- package/modules/hall-ui/public/watch.js +9 -3
- package/modules/onboarding/routes/access-requests.js +43 -20
- package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql +65 -0
- package/modules/platform-identity/platform-identity.js +12 -8
- package/modules/platform-identity/tests/platform-identity.mjs +4 -2
- package/modules/provisioning/planet-physics.js +154 -0
- package/modules/provisioning/provisioning.js +29 -9
- package/modules/provisioning/routes/body-validators.js +25 -7
- package/modules/provisioning/routes/provisioning.js +8 -3
- package/modules/provisioning/screening.js +62 -0
- package/modules/provisioning/soft-limits.js +146 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/gds/fitness.js +4 -0
- package/scripts/gds/login.js +6 -0
- package/scripts/gds/run-unit-tests.js +8 -0
- package/src/bongos/db-kernel.js +6 -3
- package/src/bongos/invite-expiry.js +47 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/module-api.js +10 -1
- package/tests/access_requests_invite.mjs +2 -1
- package/tests/application_lifecycle.mjs +164 -5
- package/tests/bongos_login.mjs +2 -0
- package/tests/government_charter_forms.mjs +283 -0
- package/tests/hub_invite_notices.mjs +5 -2
- package/tests/invite_expiry.mjs +37 -0
- package/tests/invite_expiry_db.mjs +196 -0
- package/tests/module_api.mjs +1 -0
- package/tests/pending_invite_purge_db.mjs +129 -0
- package/tests/project_admin_console_model.mjs +12 -5
- package/tests/provisioning_planet_physics.mjs +377 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// tests/pending_invite_purge_db.mjs — task 1004408 (owner decision on ADR 0353's
|
|
2
|
+
// open consequence). Real DB.
|
|
3
|
+
//
|
|
4
|
+
// Proves migration platform_identity_028 against real SQL: it deletes the `pending`
|
|
5
|
+
// rows the OLD hub invite wrote (they can lead to a locked door) and keeps every
|
|
6
|
+
// notice the project wrote since the hub first ran ADR 0353. The cutoff is read from
|
|
7
|
+
// the core_upgrades ledger — the earliest upgrade to 1.20.22 or later — so only a
|
|
8
|
+
// real engine can show the version comparison (numeric, not lexical), the malformed
|
|
9
|
+
// ledger rows it must skip without failing the deploy, and the one-shot guard.
|
|
10
|
+
//
|
|
11
|
+
// Each scenario builds its own tables in a throwaway schema, so the migration's
|
|
12
|
+
// table-wide DELETE can never touch a shared database's rows.
|
|
13
|
+
//
|
|
14
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
15
|
+
// INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
|
|
16
|
+
// never runs it.
|
|
17
|
+
//
|
|
18
|
+
// Run: DATABASE_URL=... node tests/pending_invite_purge_db.mjs
|
|
19
|
+
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const { Pool } = require('pg');
|
|
27
|
+
|
|
28
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const PI_MIG = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
30
|
+
const CORE_MIG = join(here, '..', 'migrations');
|
|
31
|
+
const piSql = (f) => readFileSync(join(PI_MIG, `platform_identity_${f}.sql`), 'utf8');
|
|
32
|
+
const PURGE = piSql('028_purge_pre_notice_invites');
|
|
33
|
+
|
|
34
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
35
|
+
let client;
|
|
36
|
+
try {
|
|
37
|
+
client = await pool.connect();
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.log(`pending_invite_purge_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
40
|
+
await pool.end().catch(() => {});
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const q = (sql, params) => client.query(sql, params);
|
|
45
|
+
const at = (hh, mm = 0) => new Date(Date.UTC(2026, 8, 30, hh, mm)).toISOString();
|
|
46
|
+
const schemas = [];
|
|
47
|
+
let pass = 0;
|
|
48
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
49
|
+
|
|
50
|
+
// A fresh hub in its own schema: the memberships table as it stands today (003, then
|
|
51
|
+
// 010 + 021's rename), schema_migrations, and — unless `ledger` is false — the
|
|
52
|
+
// core_upgrades ledger (core_189).
|
|
53
|
+
async function freshHub(tag, { ledger = true } = {}) {
|
|
54
|
+
const schema = `t1004408_${tag}_${process.pid}`;
|
|
55
|
+
schemas.push(schema);
|
|
56
|
+
await q(`CREATE SCHEMA ${schema}`);
|
|
57
|
+
await q(`SET search_path TO ${schema}`);
|
|
58
|
+
await q('CREATE TABLE schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())');
|
|
59
|
+
for (const f of ['003_project_memberships', '010_membership_kind', '021_drop_legacy_role']) await q(piSql(f));
|
|
60
|
+
if (ledger) await q(readFileSync(join(CORE_MIG, 'core_189_core_upgrades_ledger.sql'), 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
const member = (client_id, github_id, kind, joinedAt) => q(
|
|
63
|
+
`INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind, joined_at, last_active)
|
|
64
|
+
VALUES ($1, $2, $3, $4, $4)`, [client_id, github_id, kind, joinedAt]);
|
|
65
|
+
const upgraded = (toVersion, appliedAt) => q(
|
|
66
|
+
'INSERT INTO core_upgrades (to_version, applied_at) VALUES ($1, $2)', [toVersion, appliedAt]);
|
|
67
|
+
const rows = async () => (await q(
|
|
68
|
+
'SELECT client_id, github_id::int AS id, membership_kind AS kind FROM platform_identity_project_memberships ORDER BY github_id',
|
|
69
|
+
)).rows.map((r) => `${r.id}:${r.kind}`).join(' ');
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// ── The expected case: the hub has never run ADR 0353 (it served 1.19.1081). ──
|
|
73
|
+
await freshHub('never');
|
|
74
|
+
await upgraded('1.19.1081', at(8));
|
|
75
|
+
await member('spike-harbor', 1, 'pending', at(9)); // the live example's shape
|
|
76
|
+
await member('spike-harbor', 2, 'owner', at(9));
|
|
77
|
+
await member('spike-harbor', 3, 'member', at(9));
|
|
78
|
+
await member('mercury', 4, 'visitor', at(9));
|
|
79
|
+
await member('mercury', 5, 'pending', at(10));
|
|
80
|
+
await q(PURGE);
|
|
81
|
+
ok(await rows() === '2:owner 3:member 4:visitor',
|
|
82
|
+
'no ledger row at 1.20.22+: every pending row is an old one and goes; owners, members and visitors stay');
|
|
83
|
+
ok((await q(`SELECT 1 FROM schema_migrations WHERE version = 'platform_identity_028_purge_pre_notice_invites'`)).rowCount === 1,
|
|
84
|
+
'the migration records itself');
|
|
85
|
+
|
|
86
|
+
// One-shot: a notice written after the deploy survives a replay.
|
|
87
|
+
await member('mercury', 6, 'pending', at(11));
|
|
88
|
+
await q(PURGE);
|
|
89
|
+
ok(await rows() === '2:owner 3:member 4:visitor 6:pending',
|
|
90
|
+
'replaying the recorded migration deletes nothing, so a hand-run can never sweep real notices');
|
|
91
|
+
|
|
92
|
+
// ── The hub already ran ADR 0353 before this deploy: only rows older than that go. ──
|
|
93
|
+
await freshHub('since');
|
|
94
|
+
await upgraded('garbage', at(9));
|
|
95
|
+
await upgraded('1.20.99999999999', at(9, 30)); // malformed: must be skipped, not overflow the cast
|
|
96
|
+
await upgraded('1.19.1081', at(10));
|
|
97
|
+
await upgraded('1.20.21', at(11));
|
|
98
|
+
await upgraded('1.20.22', at(12)); // the first release carrying ADR 0353
|
|
99
|
+
await upgraded('1.20.25', at(13));
|
|
100
|
+
await member('spike-harbor', 11, 'pending', at(11, 30)); // old hub invite: a locked door
|
|
101
|
+
await member('spike-harbor', 12, 'pending', at(12, 30)); // a project's own notice
|
|
102
|
+
await member('spike-harbor', 13, 'member', at(10));
|
|
103
|
+
await q(PURGE);
|
|
104
|
+
ok(await rows() === '12:pending 13:member',
|
|
105
|
+
'the earliest 1.20.22+ upgrade is the cutoff: the older pending row goes, the notice written after it stays');
|
|
106
|
+
|
|
107
|
+
// ── Versions compare as numbers, and a prefix or suffix does not hide one. ──
|
|
108
|
+
await freshHub('numeric');
|
|
109
|
+
await upgraded('1.9.0', at(9)); // '1.9.0' > '1.20.22' as TEXT — must not count
|
|
110
|
+
await upgraded('v1.20.30-rc.1', at(12));
|
|
111
|
+
await member('demo', 21, 'pending', at(11));
|
|
112
|
+
await member('demo', 22, 'pending', at(13));
|
|
113
|
+
await q(PURGE);
|
|
114
|
+
ok(await rows() === '22:pending',
|
|
115
|
+
'1.9.0 is below 1.20.22 (numeric compare), and v1.20.30-rc.1 counts as 1.20.30');
|
|
116
|
+
|
|
117
|
+
// ── An instance with no ledger table at all (the module without core_189). ──
|
|
118
|
+
await freshHub('noledger', { ledger: false });
|
|
119
|
+
await member('demo', 31, 'pending', at(9));
|
|
120
|
+
await member('demo', 32, 'owner', at(9));
|
|
121
|
+
await q(PURGE);
|
|
122
|
+
ok(await rows() === '32:owner', 'no core_upgrades table: treated as never having run ADR 0353');
|
|
123
|
+
|
|
124
|
+
console.log(`\npending_invite_purge_db.mjs: ${pass} passed`);
|
|
125
|
+
} finally {
|
|
126
|
+
for (const s of schemas) await q(`DROP SCHEMA IF EXISTS ${s} CASCADE`).catch(() => {});
|
|
127
|
+
client.release();
|
|
128
|
+
await pool.end().catch(() => {});
|
|
129
|
+
}
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// drift quietly is worse than none, so the load-bearing ones are pinned here:
|
|
7
7
|
//
|
|
8
8
|
// D1 the hub relays and notifies; it never writes a project's admission row
|
|
9
|
-
// D2 an invite is a row the existing gate already reads — `
|
|
10
|
-
// `
|
|
9
|
+
// D2 an invite is a row the existing gate already reads — `created_by` never
|
|
10
|
+
// enters the admission read, and `kind` enters it only through the invite
|
|
11
|
+
// expiry, which can close a door and never open one (ADR 0356 amends D2.5)
|
|
11
12
|
// D3 the admission verdict stays `access_request.review` at the archon floor,
|
|
12
13
|
// and `project.recruit`, once minted, takes exactly the shape the ADR fixes
|
|
13
14
|
//
|
|
@@ -66,16 +67,22 @@ test('the ADR exists, is indexed, and ADR 0141 names it as superseding its §5 i
|
|
|
66
67
|
|
|
67
68
|
// Both admission reads: the gate's own (hasInvitedAccessRequest) and the state the
|
|
68
69
|
// "confirming access" waiting page polls (accessStateByLogin), which must agree with it.
|
|
69
|
-
|
|
70
|
+
// Since ADR 0356 (task 1002969) `kind` reaches them through ONE door: the ANDed
|
|
71
|
+
// invite-expiry unit, which only ever narrows `status = 'invited'`.
|
|
72
|
+
test('D2: both admission reads look at status, narrowed only by the invite expiry — never widened by kind', () => {
|
|
70
73
|
const src = read('src', 'bongos', 'db-kernel.js');
|
|
71
74
|
for (const fn of ['hasInvitedAccessRequest', 'accessStateByLogin']) {
|
|
72
75
|
const m = new RegExp(`async function ${fn}\\([^)]*\\)\\s*\\{([\\s\\S]*?)\\n\\}`).exec(src);
|
|
73
76
|
assert.ok(m, `${fn} is still an admission read in db-kernel.js`);
|
|
74
77
|
const body = m[1];
|
|
75
|
-
assert.match(body, /status\s*=\s*'invited'/, `${fn}: admission is status = invited`);
|
|
76
|
-
assert.doesNotMatch(body, /\bkind\b/, `${fn}: kind is
|
|
78
|
+
assert.match(body, /status\s*=\s*'invited'\s+AND\s+\$\{inviteLiveSql\(\)\}/, `${fn}: admission is status = invited AND the invite has not expired`);
|
|
79
|
+
assert.doesNotMatch(body, /\bkind\b/, `${fn}: kind is never read directly; only through inviteLiveSql`);
|
|
77
80
|
assert.doesNotMatch(body, /\bcreated_by\b/, `${fn}: created_by is audit data; it must never decide admission`);
|
|
78
81
|
}
|
|
82
|
+
// And that unit can only close a door: NOT (an invite AND past its window).
|
|
83
|
+
const { inviteLiveSql } = require('../src/bongos/invite-expiry.js');
|
|
84
|
+
assert.match(inviteLiveSql(), /^NOT \(kind = 'invite' AND created_at <= now\(\) - interval '\d+ days'\)$/,
|
|
85
|
+
'the expiry is a pure narrowing: a row that is not an invite is untouched by it');
|
|
79
86
|
});
|
|
80
87
|
|
|
81
88
|
test('D1: no hub (platform-identity) source writes access_requests — the hub relays, it never admits', () => {
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// tests/provisioning_planet_physics.mjs
|
|
2
|
+
//
|
|
3
|
+
// The seven planet-physics answers, the screening hook, and the soft-limit rule
|
|
4
|
+
// table (BV2.PS02, task 1004416; goal 1000121, spec
|
|
5
|
+
// docs/specs/bongos-v2-project-startup.md decision D6).
|
|
6
|
+
//
|
|
7
|
+
// Done when: a create request can carry the answers, they read back on the project,
|
|
8
|
+
// and a test pins the vocabulary and the stop path. Four layers:
|
|
9
|
+
// • the vocabulary — every kind of question, stored canonically, refused loudly
|
|
10
|
+
// • soft limits — a reason on every rule, every rule names real answers, and
|
|
11
|
+
// nothing claims a limit is enforced
|
|
12
|
+
// • screening — the stop path, and the honest projects it must let through
|
|
13
|
+
// • the routes — POST /instances carries and reads back the answers; a
|
|
14
|
+
// screened description is stopped BEFORE anything is created;
|
|
15
|
+
// PATCH …/detail un-answers with null
|
|
16
|
+
//
|
|
17
|
+
// DB-free: the data-access functions the routes need are stubbed on the cached
|
|
18
|
+
// module object, over a loopback listener (the provisioning_module_selection.mjs rig).
|
|
19
|
+
//
|
|
20
|
+
// Run: node tests/provisioning_planet_physics.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import http from 'node:http';
|
|
25
|
+
// ESM named imports off the CJS modules on purpose — knip cannot trace a
|
|
26
|
+
// createRequire() import (the tests/provisioning_module_selection.mjs note).
|
|
27
|
+
import { EU_COUNTRIES, COUNTRY_CODES, REGION_CODES, crossAnswerFault }
|
|
28
|
+
from '../modules/provisioning/planet-physics.js';
|
|
29
|
+
import { SOFT_LIMIT_RULES, EFFECTS, softLimitsFor } from '../modules/provisioning/soft-limits.js';
|
|
30
|
+
import { SCREENING_RULES, STOP_MESSAGE, screenProject } from '../modules/provisioning/screening.js';
|
|
31
|
+
import { badProjectDetail, screenedOut } from '../modules/provisioning/routes/body-validators.js';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
35
|
+
|
|
36
|
+
let passed = 0, failed = 0;
|
|
37
|
+
async function t(name, fn) {
|
|
38
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
39
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The Kestrel sample from the canvas, answered in full.
|
|
43
|
+
const KESTREL = Object.freeze({
|
|
44
|
+
project_state: 'existing', existing_work: ['digital-files', 'code'], main_work: 'code',
|
|
45
|
+
home_country: 'US', home_region: 'US-NY', builder_geography: 'global',
|
|
46
|
+
minors_on_board: true, entity: 'for-profit',
|
|
47
|
+
builders_at_start: 3, builders_expected: 8,
|
|
48
|
+
reward_intents: ['future-payouts', 'non-monetary'],
|
|
49
|
+
framing: 'hybrid', services: ['hosting', 'starter-kit', 'guild-access'],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
console.log('\nThe vocabulary:');
|
|
53
|
+
|
|
54
|
+
await t('the seven answers are all questions the detail column knows, beside team_shape', () => {
|
|
55
|
+
const keys = Object.keys(provisioning.PROJECT_DETAIL_QUESTIONS);
|
|
56
|
+
for (const key of ['team_shape', ...Object.keys(KESTREL)]) assert.ok(keys.includes(key), `${key} is a question`);
|
|
57
|
+
assert.deepEqual(provisioning.PROJECT_DETAIL_VOCAB.team_shape, ['solo', 'small-team', 'community'], 'the R15 answer is untouched');
|
|
58
|
+
// Framing is A–D, in that order.
|
|
59
|
+
assert.deepEqual(provisioning.PROJECT_DETAIL_VOCAB.framing, ['managed', 'hybrid', 'local', 'open-source']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await t('every enumerated question has a closed answer list, and every count a range — no defaults anywhere', () => {
|
|
63
|
+
for (const [key, q] of Object.entries(provisioning.PROJECT_DETAIL_QUESTIONS)) {
|
|
64
|
+
if (q.kind === 'count') {
|
|
65
|
+
assert.ok(Number.isInteger(q.min) && q.max > q.min, `${key} has a range`);
|
|
66
|
+
assert.ok(!(key in provisioning.PROJECT_DETAIL_VOCAB), `${key} has no answer list to offer`);
|
|
67
|
+
} else {
|
|
68
|
+
assert.ok(['one', 'many'].includes(q.kind), `${key} is a known kind`);
|
|
69
|
+
assert.ok(Array.isArray(q.values) && q.values.length, `${key} has a closed list`);
|
|
70
|
+
assert.equal(provisioning.PROJECT_DETAIL_VOCAB[key], q.values, `${key}'s list is what options serves`);
|
|
71
|
+
}
|
|
72
|
+
assert.equal(q.default, undefined, `${key} carries no default`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await t('a full set of answers stores canonically and reads back on the project', () => {
|
|
77
|
+
const stored = provisioning.normalizeProjectDetail(KESTREL);
|
|
78
|
+
// `many` answers come back in the vocabulary's order, so two spellings store one value.
|
|
79
|
+
assert.deepEqual(stored.existing_work, ['code', 'digital-files']);
|
|
80
|
+
assert.deepEqual(stored.reward_intents, ['non-monetary', 'future-payouts']);
|
|
81
|
+
assert.equal(stored.minors_on_board, true);
|
|
82
|
+
assert.equal(stored.builders_at_start, 3);
|
|
83
|
+
const read = provisioning.publicInstance({ id: 1, slug: 'kestrel', detail: stored }).detail;
|
|
84
|
+
assert.deepEqual(read, stored, 'the read is the stored answers, nothing added or lost');
|
|
85
|
+
assert.equal(Object.keys(read).length, Object.keys(KESTREL).length);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await t('false and an empty list are real answers, distinct from never answering', () => {
|
|
89
|
+
const stored = provisioning.normalizeProjectDetail({ minors_on_board: false, reward_intents: [] });
|
|
90
|
+
assert.deepEqual(stored, { minors_on_board: false, reward_intents: [] });
|
|
91
|
+
assert.deepEqual(provisioning.normalizeProjectDetail({}), {});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await t('badProjectDetail refuses every kind of wrong answer LOUDLY, naming the question', () => {
|
|
95
|
+
const cases = [
|
|
96
|
+
[{ plannet_state: 'new' }, /Unknown detail answer "plannet_state"/],
|
|
97
|
+
[{ project_state: 'brownfield' }, /"project_state" must be one of: new, existing/],
|
|
98
|
+
[{ existing_work: 'code' }, /"existing_work" must be a list/],
|
|
99
|
+
[{ existing_work: ['code', 'sculpture'] }, /"existing_work" must be a list, each item one of/],
|
|
100
|
+
[{ home_country: 'ZZ' }, /ISO 3166-1 alpha-2/],
|
|
101
|
+
[{ home_country: 'us' }, /ISO 3166-1 alpha-2/],
|
|
102
|
+
[{ home_region: 'US-XX' }, /US state or Canadian province/],
|
|
103
|
+
[{ minors_on_board: 'yes' }, /"minors_on_board" must be one of: false, true/],
|
|
104
|
+
[{ entity: 'llc' }, /"entity" must be one of/],
|
|
105
|
+
[{ builders_at_start: 0 }, /whole number from 1/],
|
|
106
|
+
[{ builders_expected: 2.5 }, /whole number/],
|
|
107
|
+
[{ builders_expected: '8' }, /whole number/],
|
|
108
|
+
[{ framing: 'b' }, /"framing" must be one of: managed, hybrid, local, open-source/],
|
|
109
|
+
[{ services: ['hosting', 'yacht'] }, /"services" must be a list/],
|
|
110
|
+
];
|
|
111
|
+
for (const [detail, re] of cases) {
|
|
112
|
+
const fault = badProjectDetail(detail);
|
|
113
|
+
assert.ok(fault, `${JSON.stringify(detail)} is refused`);
|
|
114
|
+
assert.equal(fault.code, 'bad_detail');
|
|
115
|
+
assert.match(fault.message, re, JSON.stringify(detail));
|
|
116
|
+
}
|
|
117
|
+
assert.equal(badProjectDetail(KESTREL), null, 'the canvas sample is clean');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await t('null un-answers every kind of question — and only null does', () => {
|
|
121
|
+
const nulls = Object.fromEntries(Object.keys(KESTREL).map((k) => [k, null]));
|
|
122
|
+
assert.equal(badProjectDetail(nulls), null, 'null is accepted for every question');
|
|
123
|
+
const { set, clear } = provisioning.projectDetailPatch(nulls);
|
|
124
|
+
assert.deepEqual(set, {});
|
|
125
|
+
assert.deepEqual(clear.sort(), Object.keys(KESTREL).sort());
|
|
126
|
+
assert.ok(badProjectDetail({ existing_work: '' }), 'an empty string is still a typo, not an erase');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await t('answers sent together must agree: main work is one of the existing kinds, the region is in the country', () => {
|
|
130
|
+
assert.match(badProjectDetail({ existing_work: ['code'], main_work: 'physical-work' }).message, /"main_work" must be one of the kinds/);
|
|
131
|
+
assert.match(badProjectDetail({ home_country: 'DE', home_region: 'US-CA' }).message, /not in "home_country" DE/);
|
|
132
|
+
assert.equal(badProjectDetail({ home_country: 'CA', home_region: 'CA-ON' }), null);
|
|
133
|
+
// Across two writes it is not refused: a merge patch may change one half first.
|
|
134
|
+
assert.equal(crossAnswerFault({ main_work: 'physical-work' }), null);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await t('the reference lists are what they claim to be', () => {
|
|
138
|
+
assert.equal(new Set(COUNTRY_CODES).size, COUNTRY_CODES.length, 'no duplicate country');
|
|
139
|
+
assert.equal(COUNTRY_CODES.length, 249, 'the ISO 3166-1 officially assigned set');
|
|
140
|
+
assert.equal(EU_COUNTRIES.length, 27);
|
|
141
|
+
for (const c of EU_COUNTRIES) assert.ok(COUNTRY_CODES.includes(c), `${c} is a country`);
|
|
142
|
+
for (const r of REGION_CODES) assert.ok(COUNTRY_CODES.includes(r.slice(0, 2)), `${r} is inside a country`);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
console.log('\nThe soft-limit table:');
|
|
146
|
+
|
|
147
|
+
// Is `option` a real question, or a real member of one?
|
|
148
|
+
function optionExists(option) {
|
|
149
|
+
const [key, member] = option.split(':');
|
|
150
|
+
const q = provisioning.PROJECT_DETAIL_QUESTIONS[key];
|
|
151
|
+
if (!q) return false;
|
|
152
|
+
return member === undefined || (q.values || []).map(String).includes(member);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await t('every rule carries a reason, a known effect, and names only real questions and answers', () => {
|
|
156
|
+
const ids = new Set();
|
|
157
|
+
for (const rule of SOFT_LIMIT_RULES) {
|
|
158
|
+
assert.ok(!ids.has(rule.id), `${rule.id} is unique`); ids.add(rule.id);
|
|
159
|
+
assert.ok(typeof rule.reason === 'string' && rule.reason.length > 10, `${rule.id} has a reason`);
|
|
160
|
+
assert.ok(EFFECTS.includes(rule.effect), `${rule.id}'s effect is one of ${EFFECTS}`);
|
|
161
|
+
assert.ok(rule.options.length, `${rule.id} names an option`);
|
|
162
|
+
for (const option of rule.options) assert.ok(optionExists(option), `${rule.id}: ${option} exists`);
|
|
163
|
+
for (const [key, test] of Object.entries(rule.when)) {
|
|
164
|
+
const q = provisioning.PROJECT_DETAIL_QUESTIONS[key];
|
|
165
|
+
assert.ok(q, `${rule.id}: ${key} is a question`);
|
|
166
|
+
const named = test.in || [test.has ?? test.lacks];
|
|
167
|
+
for (const v of named) assert.ok(q.values.includes(v), `${rule.id}: ${key}=${v} is a real answer`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await t('nothing claims a limit is enforced — not the resolution, not any reason', () => {
|
|
173
|
+
assert.equal(softLimitsFor(KESTREL).enforced, false);
|
|
174
|
+
assert.equal(softLimitsFor({}).enforced, false);
|
|
175
|
+
for (const rule of SOFT_LIMIT_RULES) {
|
|
176
|
+
assert.doesNotMatch(rule.reason, /enforc|automatic|blocked|prohibit|not permitted|forbidden/i, `${rule.id} words advice as a rule`);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await t("the spec's three examples fire, each with its why line", () => {
|
|
181
|
+
const find = (answers, option) => softLimitsFor(answers).limits.filter((l) => l.option === option);
|
|
182
|
+
const equity = find({ entity: 'intent-solo' }, 'reward_intents:equity');
|
|
183
|
+
assert.equal(equity.length, 1); assert.equal(equity[0].effect, 'grey');
|
|
184
|
+
assert.match(equity[0].reason, /until you form a company/);
|
|
185
|
+
assert.match(find({ builder_geography: 'us-eu' }, 'reward_intents:cash')[0].reason, /EU aren't available yet/);
|
|
186
|
+
assert.match(find({ minors_on_board: true }, 'reward_intents:cash')[0].reason, /13 to 15 join non-monetary tracks only/);
|
|
187
|
+
// A formed company is not greyed out of equity.
|
|
188
|
+
assert.equal(find({ entity: 'for-profit' }, 'reward_intents:equity').length, 0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await t('a rule never fires on an answer nobody gave — only the platform-wide facts apply to a skipped interview', () => {
|
|
192
|
+
const skipped = softLimitsFor({});
|
|
193
|
+
for (const limit of skipped.limits) {
|
|
194
|
+
const rule = SOFT_LIMIT_RULES.find((r) => r.id === limit.rule);
|
|
195
|
+
assert.deepEqual(rule.when, {}, `${rule.id} fired with no answers`);
|
|
196
|
+
}
|
|
197
|
+
// `lacks` is "said, and not this" — never "never said".
|
|
198
|
+
assert.ok(!softLimitsFor({}).limits.some((l) => l.rule === 'labor-payment-follows-cash'));
|
|
199
|
+
assert.ok(softLimitsFor({ reward_intents: ['equity'] }).limits.some((l) => l.rule === 'labor-payment-follows-cash'));
|
|
200
|
+
assert.ok(!softLimitsFor({ reward_intents: ['cash'] }).limits.some((l) => l.rule === 'labor-payment-follows-cash'));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await t('the table hides whole questions too: a new planet has no existing work, managed has no services to pick', () => {
|
|
204
|
+
const opts = (answers) => softLimitsFor(answers).limits.filter((l) => l.effect === 'hide').map((l) => l.option);
|
|
205
|
+
assert.deepEqual(opts({ project_state: 'new' }), ['existing_work', 'main_work']);
|
|
206
|
+
assert.ok(opts({ framing: 'managed' }).includes('services'));
|
|
207
|
+
assert.ok(!opts({ framing: 'hybrid' }).includes('services'));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await t('the soft limits read back on every owner read of the project, from the stored answers', () => {
|
|
211
|
+
const view = provisioning.publicInstance({ id: 1, slug: 'kestrel', detail: provisioning.normalizeProjectDetail({ entity: 'nonprofit' }) });
|
|
212
|
+
assert.equal(view.soft_limits.enforced, false);
|
|
213
|
+
assert.ok(view.soft_limits.limits.some((l) => l.rule === 'equity-not-in-a-nonprofit'));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
console.log('\nScreening:');
|
|
217
|
+
|
|
218
|
+
await t('the stop path: each rule stops a description that asks for the thing itself', () => {
|
|
219
|
+
const stops = [
|
|
220
|
+
['A marketplace to sell ransomware to anyone who pays.', 'malware'],
|
|
221
|
+
['We want to build a phishing kit that copies bank logins.', 'phishing'],
|
|
222
|
+
['A site sharing explicit images of minors.', 'child-sexual-abuse'],
|
|
223
|
+
];
|
|
224
|
+
for (const [description, rule] of stops) assert.deepEqual(screenProject({ description }), { stop: true, rule }, description);
|
|
225
|
+
assert.deepEqual(SCREENING_RULES.map((r) => r.id).sort(), stops.map(([, r]) => r).sort(), 'every rule has a stop case here');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await t('honest projects pass — including the ones that talk about the same things', () => {
|
|
229
|
+
for (const description of [
|
|
230
|
+
'A field guide app where birdwatchers log sightings together and build a shared map.',
|
|
231
|
+
'We build a ransomware detector for small clinics.',
|
|
232
|
+
'A phishing awareness course that runs simulated campaigns for our staff.',
|
|
233
|
+
'A child safety app that helps parents block harmful content.',
|
|
234
|
+
'', ' ',
|
|
235
|
+
]) assert.deepEqual(screenProject({ description }), { stop: false }, description);
|
|
236
|
+
assert.deepEqual(screenProject({}), { stop: false }, 'no text is never a stop');
|
|
237
|
+
assert.deepEqual(screenProject({ description: 42 }), { stop: false });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await t('the hook is a seam: a caller can hand it different rules and nothing else changes', () => {
|
|
241
|
+
const rules = [{ id: 'test-only', match: /\bkestrel\b/i, unless: null }];
|
|
242
|
+
assert.deepEqual(screenProject({ description: 'Kestrel' }, rules), { stop: true, rule: 'test-only' });
|
|
243
|
+
assert.deepEqual(screenProject({ description: 'Kestrel' }), { stop: false });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await t('screenedOut answers with the canvas copy and names no rule anywhere in the reply', () => {
|
|
247
|
+
assert.equal(screenedOut('A birdwatching map.'), null);
|
|
248
|
+
assert.equal(screenedOut(undefined), null);
|
|
249
|
+
const out = screenedOut('We will sell ransomware.');
|
|
250
|
+
assert.equal(out.status, 422);
|
|
251
|
+
assert.equal(out.message, STOP_MESSAGE);
|
|
252
|
+
assert.match(out.message, /Nothing has been set up/);
|
|
253
|
+
assert.deepEqual(out.details, { screened: true }, 'naming the rule would teach a caller how to reword around it');
|
|
254
|
+
assert.doesNotMatch(JSON.stringify(out), /malware/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
console.log('\nThe routes:');
|
|
258
|
+
|
|
259
|
+
let lastCreateArgs = null;
|
|
260
|
+
let detailRow = null;
|
|
261
|
+
provisioning.getInstanceBySlug = async () => null;
|
|
262
|
+
provisioning.getInstanceByDomain = async () => null;
|
|
263
|
+
provisioning.getInstanceById = async () => detailRow;
|
|
264
|
+
provisioning.createInstance = async (_db, args) => {
|
|
265
|
+
lastCreateArgs = args;
|
|
266
|
+
return { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'byo-host',
|
|
267
|
+
description: args.description || null, detail: provisioning.normalizeProjectDetail(args.detail) }, created: true };
|
|
268
|
+
};
|
|
269
|
+
provisioning.enqueueIntent = async (_db, id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
|
|
270
|
+
provisioning.getOpenIntent = async () => null;
|
|
271
|
+
provisioning.recordEvent = async () => {};
|
|
272
|
+
// Mirrors the real statement: merge the set answers, drop the un-answered keys.
|
|
273
|
+
provisioning.updateInstanceDetail = async (_db, _id, patch) => {
|
|
274
|
+
const row = { ...detailRow };
|
|
275
|
+
if (patch.description !== undefined) row.description = patch.description;
|
|
276
|
+
if (patch.detail !== undefined) {
|
|
277
|
+
const { set, clear } = provisioning.projectDetailPatch(patch.detail);
|
|
278
|
+
row.detail = { ...(detailRow.detail || {}), ...set };
|
|
279
|
+
for (const key of clear) delete row.detail[key];
|
|
280
|
+
}
|
|
281
|
+
detailRow = row;
|
|
282
|
+
return row;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const api = require('../src/module-api.js');
|
|
286
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
287
|
+
|
|
288
|
+
const express = require('express');
|
|
289
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
290
|
+
const app = express();
|
|
291
|
+
app.use(express.json());
|
|
292
|
+
app.use((_req, res, next) => {
|
|
293
|
+
res.fail = (code, opts, details) => {
|
|
294
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
295
|
+
res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
|
|
296
|
+
};
|
|
297
|
+
next();
|
|
298
|
+
});
|
|
299
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
300
|
+
const server = app.listen(0);
|
|
301
|
+
await new Promise((r) => server.once('listening', r));
|
|
302
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
303
|
+
|
|
304
|
+
function send(method, path, body) {
|
|
305
|
+
return new Promise((resolve, reject) => {
|
|
306
|
+
const payload = JSON.stringify(body);
|
|
307
|
+
const req = http.request(`${base}${path}`, {
|
|
308
|
+
method, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
309
|
+
}, (res) => {
|
|
310
|
+
let d = '';
|
|
311
|
+
res.on('data', (c) => { d += c; });
|
|
312
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
313
|
+
});
|
|
314
|
+
req.on('error', reject);
|
|
315
|
+
req.end(payload);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
await t('POST /instances carries all seven answers to storage and reads them back on the project', async () => {
|
|
320
|
+
lastCreateArgs = null;
|
|
321
|
+
const res = await send('POST', '/provisioning/instances', {
|
|
322
|
+
slug: 'kestrel', hosting_shape: 'byo-host', description: 'An inventory tool our team started last year.', detail: KESTREL,
|
|
323
|
+
});
|
|
324
|
+
assert.equal(res.status, 201, res.raw);
|
|
325
|
+
assert.deepEqual(lastCreateArgs.detail, KESTREL, 'the answers reached createInstance');
|
|
326
|
+
assert.deepEqual(res.body.instance.detail, provisioning.normalizeProjectDetail(KESTREL), 'and read back on the project');
|
|
327
|
+
assert.equal(res.body.instance.soft_limits.enforced, false, 'with the soft limits beside them');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await t('POST /instances refuses an unknown answer with a 400 and creates nothing', async () => {
|
|
331
|
+
lastCreateArgs = null;
|
|
332
|
+
const res = await send('POST', '/provisioning/instances', { slug: 'kestrel', hosting_shape: 'byo-host', detail: { framing: 'e' } });
|
|
333
|
+
assert.equal(res.status, 400, res.raw);
|
|
334
|
+
assert.equal(res.body.error.code, 'bad_detail');
|
|
335
|
+
assert.equal(lastCreateArgs, null);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await t('POST /instances: a screened description STOPS creation — 422, nothing created, no intent queued', async () => {
|
|
339
|
+
lastCreateArgs = null;
|
|
340
|
+
let queued = false;
|
|
341
|
+
const enqueue = provisioning.enqueueIntent;
|
|
342
|
+
provisioning.enqueueIntent = async (...a) => { queued = true; return enqueue(...a); };
|
|
343
|
+
const res = await send('POST', '/provisioning/instances', {
|
|
344
|
+
slug: 'kestrel', hosting_shape: 'byo-host', description: 'Build ransomware and sell it on forums.', detail: KESTREL,
|
|
345
|
+
});
|
|
346
|
+
provisioning.enqueueIntent = enqueue;
|
|
347
|
+
assert.equal(res.status, 422, res.raw);
|
|
348
|
+
assert.equal(res.body.error.code, 'screened_out');
|
|
349
|
+
assert.equal(res.body.error.message, STOP_MESSAGE);
|
|
350
|
+
assert.deepEqual(res.body.error.details, { screened: true }, 'the reply names no rule');
|
|
351
|
+
assert.equal(lastCreateArgs, null, 'createInstance never ran');
|
|
352
|
+
assert.equal(queued, false, 'and no provision intent exists');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await t('PATCH …/detail screens a new description too, so the stop cannot be walked around after creation', async () => {
|
|
356
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'kestrel', status: 'active', description: 'A map.', detail: {} };
|
|
357
|
+
const res = await send('PATCH', '/provisioning/instances/41/detail', { description: 'We will build a phishing kit.' });
|
|
358
|
+
assert.equal(res.status, 422, res.raw);
|
|
359
|
+
assert.equal(res.body.error.code, 'screened_out');
|
|
360
|
+
assert.equal(detailRow.description, 'A map.', 'nothing was written');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
await t('PATCH …/detail answers, re-reads, and un-answers with null — soft limits following the stored answers', async () => {
|
|
364
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'kestrel', status: 'active', detail: {} };
|
|
365
|
+
const set = await send('PATCH', '/provisioning/instances/41/detail', { detail: { entity: 'intent-solo', reward_intents: ['equity'] } });
|
|
366
|
+
assert.equal(set.status, 200, set.raw);
|
|
367
|
+
assert.deepEqual(set.body.detail, { entity: 'intent-solo', reward_intents: ['equity'] });
|
|
368
|
+
assert.ok(set.body.soft_limits.limits.some((l) => l.rule === 'equity-needs-a-company'));
|
|
369
|
+
const cleared = await send('PATCH', '/provisioning/instances/41/detail', { detail: { entity: null } });
|
|
370
|
+
assert.equal(cleared.status, 200, cleared.raw);
|
|
371
|
+
assert.deepEqual(cleared.body.detail, { reward_intents: ['equity'] }, 'the entity is un-answered, the rest kept');
|
|
372
|
+
assert.ok(!cleared.body.soft_limits.limits.some((l) => l.rule === 'equity-needs-a-company'), 'and its limit left with it');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
server.close();
|
|
376
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
377
|
+
if (failed) process.exit(1);
|