@bongos/core 1.20.25 → 1.20.27
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 +92 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +1 -1
- package/docs/adr/0355-a-soft-limit-is-a-rule-with-a-reason-and-nobody-enforces-it.md +30 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +137 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +34 -34
- package/docs/copy-registry.json +38 -38
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +75 -73
- package/modules/discord/board-broadcast.js +1 -0
- package/modules/government/board.js +261 -2
- package/modules/government/charter.js +232 -0
- package/modules/government/db.js +42 -1
- package/modules/government/migrations/government_020_genesis_stage_subject.sql +55 -0
- package/modules/government/routes/government.js +82 -0
- package/modules/hall-ui/public/board-room.js +5 -0
- package/modules/hall-ui/public/government.js +11 -0
- package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql +65 -0
- 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 +20 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/route-rank-check.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/government_board_genesis_stage.mjs +305 -0
- package/tests/government_charter_forms.mjs +283 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/pending_invite_purge_db.mjs +129 -0
- package/tests/provisioning_planet_physics.mjs +377 -0
|
@@ -108,6 +108,18 @@ const governmentPort = {
|
|
|
108
108
|
// is no counterpart that records an outcome, and there must never be one: an
|
|
109
109
|
// outcome is written by a close, never by the module reading it.
|
|
110
110
|
boardPassForIdea: board.boardPassForIdea,
|
|
111
|
+
// task 1004429 (BV2.PS04): a finished genesis stage is put to the board — the
|
|
112
|
+
// genesis home (task 1004423) calls this when a stage's tasks are done. The
|
|
113
|
+
// finished-tasks gate lives inside board.js; the item is an ordinary one under
|
|
114
|
+
// the constitution in force. And the founding band's read: per stage, what it
|
|
115
|
+
// is waiting on. A pass is announced as `board.genesis_stage.passed`.
|
|
116
|
+
openGenesisStageClose: board.openGenesisStageClose,
|
|
117
|
+
// task 1004427 (BV2.PS13): the genesis charter + legislation choice, filed as
|
|
118
|
+
// an ordinary amendment under the constitution in force — for the genesis
|
|
119
|
+
// home, which renders the stages from GET /government/constitution's
|
|
120
|
+
// `charter` block. Opening a sitting decides nothing; the vote does.
|
|
121
|
+
proposeCharter: board.proposeCharter,
|
|
122
|
+
genesisStageStatus: board.genesisStageStatus,
|
|
111
123
|
};
|
|
112
124
|
|
|
113
125
|
function registerSeams() {
|
|
@@ -460,6 +472,19 @@ module.exports = function buildGovernmentRouter() {
|
|
|
460
472
|
}
|
|
461
473
|
});
|
|
462
474
|
|
|
475
|
+
// rank: perm board.vote.cast — the founding band's read (task 1004429): each
|
|
476
|
+
// genesis stage's latest sitting, and who it is still waiting on under the
|
|
477
|
+
// item's own snapshot. Same atom as the room — seeing who is still to vote is
|
|
478
|
+
// part of the open ballot (§9). A read; nothing here opens or decides.
|
|
479
|
+
router.get('/government/board/genesis-stages', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
|
|
480
|
+
try {
|
|
481
|
+
res.json(await board.genesisStageStatus());
|
|
482
|
+
} catch (err) {
|
|
483
|
+
log.error('[gds] GET /government/board/genesis-stages', err);
|
|
484
|
+
res.fail('genesis_stage_read_failed', { status: 500, message: 'internal error' });
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
463
488
|
// rank: perm board.item.open — put an AMENDMENT to the board (R14 of goal
|
|
464
489
|
// 1000069, ADR 0175 §7). The board's other subject never comes through here:
|
|
465
490
|
// a Full Idea's window opens AUTOMATICALLY on grade pass (R07), so requesting
|
|
@@ -516,6 +541,63 @@ module.exports = function buildGovernmentRouter() {
|
|
|
516
541
|
}
|
|
517
542
|
});
|
|
518
543
|
|
|
544
|
+
// rank: perm board.item.open — the genesis charter + legislation choice (task
|
|
545
|
+
// 1004427, spec D10): a form of government and/or its two live settings
|
|
546
|
+
// (franchise, pass rule), built into a whole constitution on top of the live
|
|
547
|
+
// one and filed through the SAME amendment path as the route above — the same
|
|
548
|
+
// atom, the same strict validation, the same self-removal guard. Nothing here
|
|
549
|
+
// changes the constitution; the sitting it opens is what decides.
|
|
550
|
+
router.post('/government/board/charter', api.requireBuilder, api.requirePermission('board.item.open'), async (req, res) => {
|
|
551
|
+
if (validateOrRespond(req, res, {
|
|
552
|
+
form: { type: 'string', maxLength: 64 },
|
|
553
|
+
franchise: { type: 'string', maxLength: 32 },
|
|
554
|
+
council_rank: { type: 'string', maxLength: RANK_KEY_MAX },
|
|
555
|
+
pass_rule: { type: 'string', maxLength: 32 },
|
|
556
|
+
rationale_md: { type: 'string', maxLength: 5000 },
|
|
557
|
+
acknowledge_self_removal: { type: 'boolean' },
|
|
558
|
+
})) return;
|
|
559
|
+
const body = req.body || {};
|
|
560
|
+
if (body.window_minutes !== undefined && body.window_minutes !== null && !Number.isInteger(body.window_minutes)) {
|
|
561
|
+
return res.fail('invalid_proposal', 400, { reason: 'bad_window_minutes', expected: 'null (no clock) or a whole number of minutes' });
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
const out = await board.proposeCharter({
|
|
565
|
+
form: body.form ?? undefined,
|
|
566
|
+
franchise: body.franchise ?? undefined,
|
|
567
|
+
councilRank: body.council_rank ?? undefined,
|
|
568
|
+
passRule: body.pass_rule ?? undefined,
|
|
569
|
+
windowMinutes: body.window_minutes,
|
|
570
|
+
rationaleMd: body.rationale_md ?? null,
|
|
571
|
+
proposedBy: req.builder.id,
|
|
572
|
+
acknowledgeSelfRemoval: body.acknowledge_self_removal === true,
|
|
573
|
+
});
|
|
574
|
+
if (!out.ok) {
|
|
575
|
+
if (out.reason === 'proposer_would_lose_their_seat') {
|
|
576
|
+
return res.fail('proposer_would_lose_their_seat', 409, {
|
|
577
|
+
membership: out.membership,
|
|
578
|
+
describes: out.describes,
|
|
579
|
+
what_this_means: out.what_this_means,
|
|
580
|
+
hint: out.hint,
|
|
581
|
+
retry_with: { acknowledge_self_removal: true },
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
if (out.reason === 'no_change') {
|
|
585
|
+
return res.fail('no_change', 409, { reason: 'the constitution in force already has these settings' });
|
|
586
|
+
}
|
|
587
|
+
return res.fail('invalid_charter', 400, {
|
|
588
|
+
reason: out.reason,
|
|
589
|
+
...(out.field ? { field: out.field } : {}),
|
|
590
|
+
...(out.expected ? { expected: out.expected } : {}),
|
|
591
|
+
...(out.rank_key ? { rank_key: out.rank_key } : {}),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, reads_as: out.reads_as });
|
|
595
|
+
} catch (err) {
|
|
596
|
+
log.error('[gds] POST /government/board/charter', err);
|
|
597
|
+
res.fail('charter_failed', { status: 500, message: 'internal error' });
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
519
601
|
// rank: perm board.vote.cast — cast a vote on an open board item (R08 of goal
|
|
520
602
|
// 1000069, ADR 0175). The atom FLOORS AT XENOS deliberately (the R04 carry-
|
|
521
603
|
// note): the permission is only the coarse gate, and the real wall is the
|
|
@@ -132,6 +132,11 @@
|
|
|
132
132
|
const title = item.subject ? item.subject.title : `idea ${item.subject_id}`;
|
|
133
133
|
return `<a href="/#/idea/${escapeHtml(String(item.subject_id))}">${escapeHtml(title)}</a> ${pill('quiet', 'Full Idea')}`;
|
|
134
134
|
}
|
|
135
|
+
// task 1004429: the third subject — closing a founding stage.
|
|
136
|
+
if (item.subject_type === 'genesis_stage') {
|
|
137
|
+
const label = item.subject ? item.subject.label : `stage ${item.subject_id}`;
|
|
138
|
+
return `Close the ${escapeHtml(label)} stage ${pill('quiet', 'genesis stage')}`;
|
|
139
|
+
}
|
|
135
140
|
return `Constitutional amendment ${pill('quiet', 'amendment')}`;
|
|
136
141
|
}
|
|
137
142
|
|
|
@@ -678,6 +678,16 @@
|
|
|
678
678
|
// hand" note; PASS_RULE_NAME sits beside its sentence-length twin
|
|
679
679
|
// PASS_RULE_PLAIN there, so the two renderings of one pass rule cannot drift.
|
|
680
680
|
|
|
681
|
+
// The form of government the constitution in force READS AS (task 1004427):
|
|
682
|
+
// the real name and its plain explanation, or Custom. The server does the
|
|
683
|
+
// reading (charter.js readCharter) — the name follows the settings, so this
|
|
684
|
+
// page never guesses it. An older server without the block is said plainly.
|
|
685
|
+
function formOfGovernmentSentence(charter) {
|
|
686
|
+
const r = charter && charter.reads_as;
|
|
687
|
+
if (!r || !r.name) return 'Not available from this server yet.';
|
|
688
|
+
return `<strong>${escapeHtml(r.name)}</strong>. ${escapeHtml(r.explanation || '')}`;
|
|
689
|
+
}
|
|
690
|
+
|
|
681
691
|
function dialRow(name, sentenceHtml) {
|
|
682
692
|
return `<li class="ov-row"><div class="ov-row__main"><span class="ov-row__title">${escapeHtml(name)}</span><p class="ov-row__sub">${sentenceHtml}</p></div></li>`;
|
|
683
693
|
}
|
|
@@ -829,6 +839,7 @@
|
|
|
829
839
|
live.innerHTML = `
|
|
830
840
|
${divergenceHtml(data.divergence)}
|
|
831
841
|
<ul class="ov-rows">
|
|
842
|
+
${dialRow('Form of government', formOfGovernmentSentence(data.charter))}
|
|
832
843
|
${dialRow('Who sits on the board', membershipSentence(b))}
|
|
833
844
|
${dialRow('What counts as passing', passRuleSentence(b))}
|
|
834
845
|
${dialRow('How long a sitting runs', windowSentence(b, data.clock))}
|
package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- platform_identity_028_purge_pre_notice_invites.sql — delete the `pending` rows the
|
|
2
|
+
-- OLD hub invite wrote (task 1004408, the owner's call on ADR 0353's open consequence).
|
|
3
|
+
--
|
|
4
|
+
-- WHY. Before ADR 0353 the hub's POST /projects/invite wrote a `pending` membership
|
|
5
|
+
-- row and nothing on the project. My Projects renders that row as "Open project →",
|
|
6
|
+
-- and on any door but `open` (the default is `apply`) it leads to the project's own
|
|
7
|
+
-- gate, which never heard of the invite: a locked door. Since ADR 0353 a `pending`
|
|
8
|
+
-- row is a NOTICE that only the project writes (POST /sso/invites/notify), after it
|
|
9
|
+
-- wrote its own `invited` row. So a pending row is a dead end exactly when it
|
|
10
|
+
-- predates the hub's first run of ADR 0353 code.
|
|
11
|
+
--
|
|
12
|
+
-- THE CUTOFF IS THAT FIRST RUN, read from the core_upgrades ledger: the earliest
|
|
13
|
+
-- upgrade to core 1.20.22 or later (the first release carrying ADR 0353, PR 1367).
|
|
14
|
+
-- No such row means the hub has never served a notice route, so every pending row
|
|
15
|
+
-- is an old one and all of them go. That is the expected case: the hub served
|
|
16
|
+
-- 1.19.1081 when this was written, and upgrade.js writes the ledger row AFTER
|
|
17
|
+
-- migrate, so the upgrade applying this file is not yet in the ledger. The ledger
|
|
18
|
+
-- row lands just after the restart, so a notice written in those few seconds would
|
|
19
|
+
-- also go; an old-style row cannot survive either way.
|
|
20
|
+
--
|
|
21
|
+
-- WHAT IS LOST. The owner accepted this (option (a)): an old row on an `open` door
|
|
22
|
+
-- did lead somewhere, and it is deleted too. That invitee can still sign in to the
|
|
23
|
+
-- project, and the inviter can re-invite through the project's own hall.
|
|
24
|
+
--
|
|
25
|
+
-- Touches `pending` rows only, so no real membership (owner, member, visitor) can
|
|
26
|
+
-- be removed. One-shot: a replay after this file is recorded does nothing, so a
|
|
27
|
+
-- hand-run later can never sweep up real notices. Deletes data but adds nothing
|
|
28
|
+
-- the previous release cannot read, so it is forward-safe (ADR 0083 §5).
|
|
29
|
+
|
|
30
|
+
BEGIN;
|
|
31
|
+
|
|
32
|
+
DO $$
|
|
33
|
+
DECLARE
|
|
34
|
+
notices_since timestamptz;
|
|
35
|
+
removed integer;
|
|
36
|
+
BEGIN
|
|
37
|
+
IF EXISTS (SELECT 1 FROM schema_migrations
|
|
38
|
+
WHERE version = 'platform_identity_028_purge_pre_notice_invites') THEN
|
|
39
|
+
RETURN;
|
|
40
|
+
END IF;
|
|
41
|
+
|
|
42
|
+
-- Only a well-formed x.y.z counts (any suffix ignored); the digit caps keep the
|
|
43
|
+
-- int cast from overflowing, so a malformed ledger row can never fail the deploy.
|
|
44
|
+
IF to_regclass('core_upgrades') IS NOT NULL THEN
|
|
45
|
+
SELECT min(applied_at) INTO notices_since
|
|
46
|
+
FROM (SELECT applied_at,
|
|
47
|
+
substring(to_version FROM '^v?([0-9]{1,6}\.[0-9]{1,6}\.[0-9]{1,6})(?:[^0-9]|$)') AS xyz
|
|
48
|
+
FROM core_upgrades) u
|
|
49
|
+
WHERE xyz IS NOT NULL
|
|
50
|
+
AND string_to_array(xyz, '.')::int[] >= ARRAY[1, 20, 22];
|
|
51
|
+
END IF;
|
|
52
|
+
|
|
53
|
+
DELETE FROM platform_identity_project_memberships
|
|
54
|
+
WHERE membership_kind = 'pending'
|
|
55
|
+
AND (notices_since IS NULL OR joined_at < notices_since);
|
|
56
|
+
GET DIAGNOSTICS removed = ROW_COUNT;
|
|
57
|
+
|
|
58
|
+
RAISE NOTICE 'platform_identity_028: removed % pre-ADR-0353 pending invite row(s) (cutoff: %)',
|
|
59
|
+
removed, COALESCE(notices_since::text, 'none, the hub never ran ADR 0353');
|
|
60
|
+
END $$;
|
|
61
|
+
|
|
62
|
+
INSERT INTO schema_migrations (version) VALUES ('platform_identity_028_purge_pre_notice_invites')
|
|
63
|
+
ON CONFLICT DO NOTHING;
|
|
64
|
+
|
|
65
|
+
COMMIT;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// modules/provisioning/planet-physics.js — the seven "planet physics" answers a new
|
|
2
|
+
// project carries (BV2.PS02, task 1004416; goal 1000121, spec
|
|
3
|
+
// docs/specs/bongos-v2-project-startup.md).
|
|
4
|
+
//
|
|
5
|
+
// WHAT. The questions the startup flow asks before any technical setup, as a closed
|
|
6
|
+
// vocabulary the provisioning `detail` jsonb stores (the R15 dumb-store contract in
|
|
7
|
+
// provisioning.js: a later question costs no migration). In the canvas's order:
|
|
8
|
+
//
|
|
9
|
+
// 1 state project_state, existing_work (all that apply), main_work
|
|
10
|
+
// 2 geography home_country, home_region, builder_geography
|
|
11
|
+
// 3 ages minors_on_board
|
|
12
|
+
// 4 entity entity — a legal type (already formed) or an intent (not yet)
|
|
13
|
+
// 5 builders builders_at_start, builders_expected (the month-three estimate)
|
|
14
|
+
// 6 rewards reward_intents (all being considered — intent, never binding)
|
|
15
|
+
// 7 framing framing (A–D: managed, hybrid, local, open-source) and services
|
|
16
|
+
//
|
|
17
|
+
// THREE KINDS OF QUESTION. `one` takes a single member of its list, `many` takes an
|
|
18
|
+
// array of members (stored de-duplicated, in the list's own order, so two spellings of
|
|
19
|
+
// one answer store the same bytes), and `count` takes a whole number in a range.
|
|
20
|
+
// Every kind has the same two rules as `team_shape`: an unknown value is REFUSED at the
|
|
21
|
+
// boundary (badProjectDetail says which and why), and an explicit null un-answers.
|
|
22
|
+
//
|
|
23
|
+
// THESE ARE FACTS, NOT POLICY. Nothing here restricts what a project may do: the age
|
|
24
|
+
// tiers and the payout geography are enforced by nobody yet (out of scope, spec "Out
|
|
25
|
+
// of scope"). They are stored so the soft-limit table (soft-limits.js) can grey an
|
|
26
|
+
// option with a reason, and so the later enforcement has the facts it will need.
|
|
27
|
+
//
|
|
28
|
+
// PURE, and requires nothing — provisioning.js composes these into
|
|
29
|
+
// PROJECT_DETAIL_VOCAB, and soft-limits.js reads them, so a require of either from
|
|
30
|
+
// here would be a cycle.
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
// ISO 3166-1 alpha-2, the officially assigned codes. A closed list rather than a
|
|
35
|
+
// two-letter pattern, because "ZZ" is a typo the pattern would store as a country.
|
|
36
|
+
const COUNTRY_CODES = Object.freeze((
|
|
37
|
+
'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ ' +
|
|
38
|
+
'BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM ' +
|
|
39
|
+
'DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS ' +
|
|
40
|
+
'GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN ' +
|
|
41
|
+
'KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ ' +
|
|
42
|
+
'MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM ' +
|
|
43
|
+
'PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV ' +
|
|
44
|
+
'SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI ' +
|
|
45
|
+
'VN VU WF WS YE YT ZA ZM ZW'
|
|
46
|
+
).split(' '));
|
|
47
|
+
|
|
48
|
+
// The sub-national level the canvas asks for ("your country · state"), ISO 3166-2
|
|
49
|
+
// style. Only where the law that matters to a project — minors' work permits, what
|
|
50
|
+
// kind of company you can form — is set at that level: the US states (with DC and the
|
|
51
|
+
// territories) and the Canadian provinces and territories. Anywhere else the answer is
|
|
52
|
+
// the country alone, and a region is refused rather than stored unread.
|
|
53
|
+
const US_REGIONS = (
|
|
54
|
+
'AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH NJ ' +
|
|
55
|
+
'NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY DC AS GU MP PR UM VI'
|
|
56
|
+
).split(' ').map((s) => `US-${s}`);
|
|
57
|
+
const CA_REGIONS = 'AB BC MB NB NL NS NT NU ON PE QC SK YT'.split(' ').map((s) => `CA-${s}`);
|
|
58
|
+
const REGION_CODES = Object.freeze([...US_REGIONS, ...CA_REGIONS]);
|
|
59
|
+
|
|
60
|
+
// The European Union's member states — what "EU builders" means to the soft limits.
|
|
61
|
+
const EU_COUNTRIES = Object.freeze(
|
|
62
|
+
'AT BE BG CY CZ DE DK EE ES FI FR GR HR HU IE IT LT LU LV MT NL PL PT RO SE SI SK'.split(' ')
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const WORK_KINDS = Object.freeze(['code', 'digital-files', 'physical-work']);
|
|
66
|
+
|
|
67
|
+
// Question 4. A project that already exists picks its legal type; a new one says
|
|
68
|
+
// roughly where it is heading (the canvas: "there's nothing to sign yet"). One key,
|
|
69
|
+
// because they answer one question and a project holds exactly one of them.
|
|
70
|
+
const ENTITY_INTENTS = Object.freeze(['intent-company', 'intent-nonprofit', 'intent-solo', 'intent-unsure']);
|
|
71
|
+
const ENTITY_FORMS = Object.freeze([
|
|
72
|
+
'nonprofit', 'for-profit', 'corporate-division', 'research', // organization
|
|
73
|
+
'university-project', 'social-group', // group
|
|
74
|
+
'single-owner-llc', 'private-individual', // individual
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// Question 7's A–D, in that order (A = managed … D = open-source).
|
|
78
|
+
const FRAMINGS = Object.freeze(['managed', 'hybrid', 'local', 'open-source']);
|
|
79
|
+
|
|
80
|
+
// The suite a hybrid or local framing chooses from.
|
|
81
|
+
const SERVICES = Object.freeze([
|
|
82
|
+
'hosting', 'labor-payment', 'starter-kit', 'marketplace-buy', 'marketplace-sell',
|
|
83
|
+
'guild-access', 'guild-create', 'company-creation', 'cbaas-consulting', 'public-marketing',
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
// The ceiling on a builder count is a sanity bound, not a product limit.
|
|
87
|
+
const BUILDER_COUNT = Object.freeze({ kind: 'count', min: 1, max: 100000 });
|
|
88
|
+
|
|
89
|
+
const PLANET_PHYSICS_QUESTIONS = Object.freeze({
|
|
90
|
+
project_state: Object.freeze({ kind: 'one', values: Object.freeze(['new', 'existing']) }),
|
|
91
|
+
existing_work: Object.freeze({ kind: 'many', values: WORK_KINDS }),
|
|
92
|
+
main_work: Object.freeze({ kind: 'one', values: WORK_KINDS }),
|
|
93
|
+
home_country: Object.freeze({ kind: 'one', values: COUNTRY_CODES }),
|
|
94
|
+
home_region: Object.freeze({ kind: 'one', values: REGION_CODES }),
|
|
95
|
+
builder_geography: Object.freeze({ kind: 'one', values: Object.freeze(['us-only', 'us-eu', 'global', 'not-sure']) }),
|
|
96
|
+
minors_on_board: Object.freeze({ kind: 'one', values: Object.freeze([false, true]) }),
|
|
97
|
+
entity: Object.freeze({ kind: 'one', values: Object.freeze([...ENTITY_INTENTS, ...ENTITY_FORMS]) }),
|
|
98
|
+
builders_at_start: BUILDER_COUNT,
|
|
99
|
+
builders_expected: BUILDER_COUNT,
|
|
100
|
+
reward_intents: Object.freeze({ kind: 'many', values: Object.freeze(['non-monetary', 'cash', 'equity', 'future-payouts']) }),
|
|
101
|
+
framing: Object.freeze({ kind: 'one', values: FRAMINGS }),
|
|
102
|
+
services: Object.freeze({ kind: 'many', values: SERVICES }),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The answer as it may be STORED, or undefined when it is not one. `many` answers come
|
|
106
|
+
// back de-duplicated in the list's order; an empty array is a real answer ("none of
|
|
107
|
+
// these"), distinct from never answering. PURE.
|
|
108
|
+
function canonicalAnswer(question, value) {
|
|
109
|
+
if (!question) return undefined;
|
|
110
|
+
if (question.kind === 'one') return question.values.includes(value) ? value : undefined;
|
|
111
|
+
if (question.kind === 'many') {
|
|
112
|
+
if (!Array.isArray(value) || !value.every((v) => question.values.includes(v))) return undefined;
|
|
113
|
+
return question.values.filter((v) => value.includes(v));
|
|
114
|
+
}
|
|
115
|
+
if (question.kind === 'count') {
|
|
116
|
+
return Number.isInteger(value) && value >= question.min && value <= question.max ? value : undefined;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Why a value is not an answer, in a sentence the caller can act on. Lists longer
|
|
122
|
+
// than a dozen (the country and region codes) are named by their standard instead of
|
|
123
|
+
// printed. PURE.
|
|
124
|
+
function answerFaultMessage(key, question) {
|
|
125
|
+
const list = (values) => (values.length > 12
|
|
126
|
+
? (key === 'home_country' ? 'an ISO 3166-1 alpha-2 country code, like US or DE' : 'a US state or Canadian province code, like US-CA or CA-ON')
|
|
127
|
+
: `one of: ${values.join(', ')}`);
|
|
128
|
+
if (question.kind === 'many') return `"${key}" must be a list, each item ${list(question.values)} — or null to un-answer it.`;
|
|
129
|
+
if (question.kind === 'count') return `"${key}" must be a whole number from ${question.min} to ${question.max} — or null to un-answer it.`;
|
|
130
|
+
return `"${key}" must be ${list(question.values)} — or null to un-answer it.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Faults BETWEEN answers sent together in one body. Only within the body: a merge
|
|
134
|
+
// patch may legitimately change one half now and the other later, and refusing a
|
|
135
|
+
// write for disagreeing with a stored answer would make the order of edits matter.
|
|
136
|
+
// Returns a message, or null. PURE.
|
|
137
|
+
function crossAnswerFault(detail) {
|
|
138
|
+
if (Array.isArray(detail.existing_work) && typeof detail.main_work === 'string'
|
|
139
|
+
&& !detail.existing_work.includes(detail.main_work)) {
|
|
140
|
+
return `"main_work" must be one of the kinds in "existing_work" (${detail.existing_work.join(', ') || 'none given'}).`;
|
|
141
|
+
}
|
|
142
|
+
if (typeof detail.home_region === 'string' && typeof detail.home_country === 'string'
|
|
143
|
+
&& !detail.home_region.startsWith(`${detail.home_country}-`)) {
|
|
144
|
+
return `"home_region" ${detail.home_region} is not in "home_country" ${detail.home_country}.`;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
PLANET_PHYSICS_QUESTIONS,
|
|
151
|
+
COUNTRY_CODES, REGION_CODES, EU_COUNTRIES,
|
|
152
|
+
ENTITY_FORMS,
|
|
153
|
+
canonicalAnswer, answerFaultMessage, crossAnswerFault,
|
|
154
|
+
};
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
// valued, and the value it resolves to when nobody answered is the type's starter
|
|
20
20
|
// bundle — so storage and the read projection both have to read that declaration.
|
|
21
21
|
const { normalizeModuleSelection, effectiveModules } = require('./starter-bundles');
|
|
22
|
+
// The planet-physics answers and the soft limits over them (BV2.PS02, task 1004416) —
|
|
23
|
+
// pure declarations beside this file, for the same reason: storage and the read
|
|
24
|
+
// projection both have to read them.
|
|
25
|
+
const { PLANET_PHYSICS_QUESTIONS, canonicalAnswer } = require('./planet-physics');
|
|
26
|
+
const { softLimitsFor } = require('./soft-limits');
|
|
22
27
|
|
|
23
28
|
// ---------------------------------------------------------------------------
|
|
24
29
|
// Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
|
|
@@ -253,9 +258,18 @@ const PLANET_VOCAB = Object.freeze({
|
|
|
253
258
|
// obey, so an absent one has to resolve to today's behaviour; a detail answer is
|
|
254
259
|
// something the owner SAID, and inventing one they never said would feed R17 a
|
|
255
260
|
// recommendation nobody asked for. Absent stays absent, and skipped stays skipped.
|
|
256
|
-
|
|
257
|
-
|
|
261
|
+
//
|
|
262
|
+
// The seven planet-physics answers (BV2.PS02, task 1004416) ride the same column under
|
|
263
|
+
// the same contract. They add two kinds of question beside the closed single answer:
|
|
264
|
+
// a list of members (`many`) and a whole number (`count`) — see planet-physics.js.
|
|
265
|
+
// PROJECT_DETAIL_QUESTIONS is the whole contract; PROJECT_DETAIL_VOCAB is its closed
|
|
266
|
+
// answer lists, the shape every reader and the `options` reply already take.
|
|
267
|
+
const PROJECT_DETAIL_QUESTIONS = Object.freeze({
|
|
268
|
+
team_shape: Object.freeze({ kind: 'one', values: Object.freeze(['solo', 'small-team', 'community']) }),
|
|
269
|
+
...PLANET_PHYSICS_QUESTIONS,
|
|
258
270
|
});
|
|
271
|
+
const PROJECT_DETAIL_VOCAB = Object.freeze(Object.fromEntries(Object.entries(PROJECT_DETAIL_QUESTIONS)
|
|
272
|
+
.filter(([, question]) => question.values).map(([key, question]) => [key, question.values])));
|
|
259
273
|
// The publish-gate half of the interview is prose, so it needs a ceiling. Long
|
|
260
274
|
// enough for a real paragraph, short enough that the column is never a document —
|
|
261
275
|
// the Explore card renders `tagline`, not this.
|
|
@@ -401,6 +415,7 @@ async function isProvisionedHostname(db, hostname) {
|
|
|
401
415
|
// explicitly means a future sensitive column is never leaked by accident.
|
|
402
416
|
function publicInstance(row) {
|
|
403
417
|
if (!row) return { state: 'none' };
|
|
418
|
+
const detail = projectDetail(row);
|
|
404
419
|
return {
|
|
405
420
|
id: row.id,
|
|
406
421
|
slug: row.slug,
|
|
@@ -431,7 +446,10 @@ function publicInstance(row) {
|
|
|
431
446
|
// in (ADR 0182 D4). Surfaced so a form can render "here's what's still
|
|
432
447
|
// missing" from a plain instance read, without a second endpoint.
|
|
433
448
|
description: row.description || null,
|
|
434
|
-
detail
|
|
449
|
+
detail,
|
|
450
|
+
// Which startup options those answers hide, grey or annotate, each with its reason
|
|
451
|
+
// (BV2.PS02, spec D6). Advice only — `enforced: false` says so on every read.
|
|
452
|
+
soft_limits: softLimitsFor(detail),
|
|
435
453
|
// The creation picker's module set (task 1002339): the always-on core, what
|
|
436
454
|
// the owner ends up with, and whether that came from their own toggles or
|
|
437
455
|
// from the type's starter bundle because they never answered. Resolved, not
|
|
@@ -631,8 +649,9 @@ function normalizeDescription(input) {
|
|
|
631
649
|
function normalizeProjectDetail(input) {
|
|
632
650
|
const given = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
633
651
|
const out = {};
|
|
634
|
-
for (const [key,
|
|
635
|
-
|
|
652
|
+
for (const [key, question] of Object.entries(PROJECT_DETAIL_QUESTIONS)) {
|
|
653
|
+
const answer = canonicalAnswer(question, given[key]);
|
|
654
|
+
if (answer !== undefined) out[key] = answer;
|
|
636
655
|
}
|
|
637
656
|
return out;
|
|
638
657
|
}
|
|
@@ -650,9 +669,10 @@ function projectDetailPatch(input) {
|
|
|
650
669
|
const given = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
651
670
|
const set = {};
|
|
652
671
|
const clear = [];
|
|
653
|
-
for (const [key,
|
|
672
|
+
for (const [key, question] of Object.entries(PROJECT_DETAIL_QUESTIONS)) {
|
|
673
|
+
const answer = canonicalAnswer(question, given[key]);
|
|
654
674
|
if (given[key] === null) clear.push(key);
|
|
655
|
-
else if (
|
|
675
|
+
else if (answer !== undefined) set[key] = answer;
|
|
656
676
|
}
|
|
657
677
|
return { set, clear };
|
|
658
678
|
}
|
|
@@ -1445,7 +1465,7 @@ module.exports = {
|
|
|
1445
1465
|
SETTINGS_APPLY_FROM, SETTINGS_APPLY_SHAPES,
|
|
1446
1466
|
RESTART_FROM, RESTART_SHAPES,
|
|
1447
1467
|
TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
|
|
1448
|
-
SETTINGS_VOCAB, DEFAULT_SETTINGS, SETTINGS_COMPANION_ENV, PROJECT_DETAIL_VOCAB, DESCRIPTION_MAX,
|
|
1468
|
+
SETTINGS_VOCAB, DEFAULT_SETTINGS, SETTINGS_COMPANION_ENV, PROJECT_DETAIL_VOCAB, PROJECT_DETAIL_QUESTIONS, DESCRIPTION_MAX,
|
|
1449
1469
|
PLANET_TEMPLATES, PLANET_ACCENTS, PLANET_VOCAB,
|
|
1450
1470
|
// pure
|
|
1451
1471
|
isValidSlug, costEstimateUsd, DEFAULT_DEDICATED_TIER, publicInstance, normalizeHostname,
|
|
@@ -1453,7 +1473,7 @@ module.exports = {
|
|
|
1453
1473
|
settingsPushOwed,
|
|
1454
1474
|
// pure — the artist gate's non-retroactivity stamp (ADR 0241 §5, task 1003575)
|
|
1455
1475
|
ARTIST_GATE_STRICT_SINCE_KEY, artistGateStrictSince, artistGateStamp,
|
|
1456
|
-
normalizeDescription, normalizeProjectDetail, projectDetailPatch, projectDetail,
|
|
1476
|
+
normalizeDescription, normalizeProjectDetail, projectDetailPatch, projectDetail, softLimitsFor,
|
|
1457
1477
|
// pure — GitHub App Manifest flow (task 2080)
|
|
1458
1478
|
githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
|
|
1459
1479
|
// pure — the address race's database backstop (task 1002901, provisioning_015)
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
const provisioning = require('../provisioning');
|
|
17
17
|
const { ALWAYS_ON_CORE, OPTIONAL_MODULES } = require('../starter-bundles');
|
|
18
|
+
const { canonicalAnswer, answerFaultMessage, crossAnswerFault } = require('../planet-physics');
|
|
19
|
+
const { screenProject, STOP_MESSAGE } = require('../screening');
|
|
18
20
|
|
|
19
21
|
// The project-detail answers, validated LOUDLY (task 1002334). `detail` arrives as
|
|
20
22
|
// a nested object, and the strict body validator only reaches top-level fields — so
|
|
@@ -28,21 +30,37 @@ function badProjectDetail(detail) {
|
|
|
28
30
|
if (typeof detail !== 'object' || Array.isArray(detail)) {
|
|
29
31
|
return { code: 'bad_detail', message: 'detail must be an object of answers.' };
|
|
30
32
|
}
|
|
31
|
-
const
|
|
33
|
+
const questions = provisioning.PROJECT_DETAIL_QUESTIONS;
|
|
32
34
|
for (const [key, value] of Object.entries(detail)) {
|
|
33
|
-
if (!Object.prototype.hasOwnProperty.call(
|
|
34
|
-
return { code: 'bad_detail', message: `Unknown detail answer "${key}" — the interview asks: ${Object.keys(
|
|
35
|
+
if (!Object.prototype.hasOwnProperty.call(questions, key)) {
|
|
36
|
+
return { code: 'bad_detail', message: `Unknown detail answer "${key}" — the interview asks: ${Object.keys(questions).join(', ')}.` };
|
|
35
37
|
}
|
|
36
38
|
// An explicit null is the UN-ANSWER, not an off-vocabulary value (task 1003577):
|
|
37
39
|
// the merge-patch way back to "never answered". Only null — an empty string or any
|
|
38
40
|
// other sentinel stays a typo, so a caller trying to SET one is never silently
|
|
39
41
|
// read as having erased their answer.
|
|
40
42
|
if (value === null) continue;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
// Every kind of question (planet-physics.js, task 1004416) — a single answer, a
|
|
44
|
+
// list of members, or a whole number — refuses with a sentence naming its own shape.
|
|
45
|
+
if (canonicalAnswer(questions[key], value) === undefined) {
|
|
46
|
+
return { code: 'bad_detail', message: answerFaultMessage(key, questions[key]) };
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
|
-
|
|
49
|
+
const crossFault = crossAnswerFault(detail);
|
|
50
|
+
return crossFault ? { code: 'bad_detail', message: crossFault } : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The screening hook (BV2.PS02, task 1004416) at the boundary: the project's own words,
|
|
54
|
+
// screened BEFORE anything is created — the canvas's stop screen ("nothing has been set
|
|
55
|
+
// up"). 422, because the body is well-formed and the refusal is about what it describes.
|
|
56
|
+
// Neither the sentence nor `details` names the rule: a gate that says which rule it
|
|
57
|
+
// tripped teaches a caller how to reword around it (the task 1004416 review).
|
|
58
|
+
// Returns null when clean, or the res.fail options to stop with. The rules and the seam
|
|
59
|
+
// a real classifier replaces are in ../screening.js.
|
|
60
|
+
function screenedOut(description) {
|
|
61
|
+
const verdict = screenProject({ description });
|
|
62
|
+
if (!verdict.stop) return null;
|
|
63
|
+
return { status: 422, message: STOP_MESSAGE, details: { screened: true } };
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
// The creation picker's module selection, validated LOUDLY (BV1.R20, task
|
|
@@ -91,4 +109,4 @@ function badProjectType(type) {
|
|
|
91
109
|
return null;
|
|
92
110
|
}
|
|
93
111
|
|
|
94
|
-
module.exports = { badProjectDetail, badModuleSelection, badProjectType };
|
|
112
|
+
module.exports = { badProjectDetail, badModuleSelection, badProjectType, screenedOut };
|
|
@@ -61,7 +61,7 @@ const catalogBridge = require('../catalog-bridge');
|
|
|
61
61
|
const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
|
|
62
62
|
const { callbackPage } = require('./callback-page');
|
|
63
63
|
// the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
|
|
64
|
-
const { badProjectDetail, badModuleSelection, badProjectType } = require('./body-validators');
|
|
64
|
+
const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators');
|
|
65
65
|
const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
|
|
66
66
|
// the kernel ports this module provides (moved out at the size ratchet, task 1003578)
|
|
67
67
|
const { registerProvisioningSeams } = require('../seams');
|
|
@@ -330,6 +330,8 @@ module.exports = function provisioningRoutes() {
|
|
|
330
330
|
if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
|
|
331
331
|
const modulesFault = badModuleSelection(body.modules);
|
|
332
332
|
if (modulesFault) return res.fail(modulesFault.code, { status: 400, message: modulesFault.message });
|
|
333
|
+
const screened = screenedOut(body.description); // the stop screen (task 1004416): before anything exists
|
|
334
|
+
if (screened) return res.fail('screened_out', screened);
|
|
333
335
|
const slug = String(body.slug || '').trim().toLowerCase();
|
|
334
336
|
if (!provisioning.isValidSlug(slug)) {
|
|
335
337
|
return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
|
|
@@ -698,6 +700,8 @@ module.exports = function provisioningRoutes() {
|
|
|
698
700
|
if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
|
|
699
701
|
const modulesFault = badModuleSelection(body.modules);
|
|
700
702
|
if (modulesFault) return res.fail(modulesFault.code, { status: 400, message: modulesFault.message });
|
|
703
|
+
const screened = screenedOut(body.description); // the stop screen (task 1004416): before anything exists
|
|
704
|
+
if (screened) return res.fail('screened_out', screened);
|
|
701
705
|
// `type: null` reaches here because the kernel validator reads null as ABSENT —
|
|
702
706
|
// which is exactly why the refusal has to be its own check and not an enum rule.
|
|
703
707
|
const typeFault = badProjectType(Object.prototype.hasOwnProperty.call(body, 'type') ? body.type : undefined);
|
|
@@ -731,11 +735,12 @@ module.exports = function provisioningRoutes() {
|
|
|
731
735
|
// one CLEARED here has to leave it, which is why the hub assigns `description`
|
|
732
736
|
// rather than COALESCEing it.
|
|
733
737
|
await catalogBridge.publishInstanceToCatalog(pool, updated).catch(() => {});
|
|
738
|
+
const detail = provisioning.projectDetail(updated);
|
|
734
739
|
res.json({
|
|
735
740
|
ok: true,
|
|
736
741
|
description: updated.description || null,
|
|
737
|
-
detail: provisioning.
|
|
738
|
-
|
|
742
|
+
detail, options: provisioning.PROJECT_DETAIL_VOCAB,
|
|
743
|
+
soft_limits: provisioning.softLimitsFor(detail), // advice, never enforced (task 1004416)
|
|
739
744
|
// The whole point of the step, answered in the same breath (R16, task
|
|
740
745
|
// 1002335): an owner who just wrote a description learns HERE that the
|
|
741
746
|
// project is now on the map, and one who cleared it learns it just left.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// modules/provisioning/screening.js — the screening hook that can stop a project
|
|
2
|
+
// before anything is created (BV2.PS02, task 1004416; the canvas's "this can't be
|
|
3
|
+
// built on Cloud Bongos" stop screen).
|
|
4
|
+
//
|
|
5
|
+
// WHAT IT SCREENS. The words an owner uses to say what the project is: the idea text
|
|
6
|
+
// for a new planet, and the description of the work that already exists for an
|
|
7
|
+
// existing one. Both arrive as `description` today (the canvas asks one question, "what
|
|
8
|
+
// is your project, and what do you want Bongos to do for you?"), so the hook takes an
|
|
9
|
+
// object of named texts — a later field is one more key, not a new call site.
|
|
10
|
+
//
|
|
11
|
+
// THE SEAM. `screenProject(texts, rules)` is the whole contract: texts in, a verdict
|
|
12
|
+
// out, `{ stop: false }` or `{ stop: true, rule }`. The real classifier is out of scope
|
|
13
|
+
// (spec, PS02); when it exists it replaces SCREENING_RULES or this function's body, and
|
|
14
|
+
// no caller changes.
|
|
15
|
+
//
|
|
16
|
+
// THE RULE SET IS DELIBERATELY SMALL. A keyword rule that stops an honest project is a
|
|
17
|
+
// worse failure than one that misses a bad one — the classifier is the real answer, and
|
|
18
|
+
// the stop screen always offers "contact us". So each rule names something no
|
|
19
|
+
// legitimate project is built to BE, and a defensive project that merely talks about it
|
|
20
|
+
// (a ransomware detector, a phishing-awareness course) carries an `unless` that lets it
|
|
21
|
+
// through. When in doubt, a rule does not belong here.
|
|
22
|
+
//
|
|
23
|
+
// PURE — no I/O, no requires.
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const SCREENING_RULES = Object.freeze([
|
|
28
|
+
Object.freeze({
|
|
29
|
+
id: 'child-sexual-abuse',
|
|
30
|
+
match: /\b(csam|child (sexual abuse|porn\w*)|(sexual|nude|explicit) (images?|photos?|videos?|content) of (children|minors|kids))\b/i,
|
|
31
|
+
unless: null,
|
|
32
|
+
}),
|
|
33
|
+
Object.freeze({
|
|
34
|
+
id: 'malware',
|
|
35
|
+
match: /\b(build|make|create|write|develop|sell|spread|distribute|deploy)\w*\b[^.!?\n]{0,40}\b(ransomware|keyloggers?|info-?stealers?|credential stealers?|botnets?)\b/i,
|
|
36
|
+
unless: /\b(detect\w*|defen[cs]\w*|protect\w*|prevent\w*|block\w*|remov\w*|scann\w*|research\w*|against|anti-?\w+)\b/i,
|
|
37
|
+
}),
|
|
38
|
+
Object.freeze({
|
|
39
|
+
id: 'phishing',
|
|
40
|
+
match: /\b(build|make|create|write|develop|sell|run|host|launch)\w*\b[^.!?\n]{0,40}\bphishing (kits?|pages?|sites?|campaigns?)\b/i,
|
|
41
|
+
unless: /\b(awareness|training|simulat\w*|detect\w*|report\w*|defen[cs]\w*|protect\w*|against)\b/i,
|
|
42
|
+
}),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// The verdict for one project's texts. `texts` is `{ description, … }`; anything that is
|
|
46
|
+
// not a string is skipped, so an unanswered field can never stop a project. PURE.
|
|
47
|
+
function screenProject(texts, rules = SCREENING_RULES) {
|
|
48
|
+
const given = texts && typeof texts === 'object' ? texts : {};
|
|
49
|
+
const words = Object.values(given).filter((t) => typeof t === 'string' && t.trim()).join('\n');
|
|
50
|
+
if (!words) return { stop: false };
|
|
51
|
+
for (const rule of rules) {
|
|
52
|
+
if (rule.match.test(words) && !(rule.unless && rule.unless.test(words))) return { stop: true, rule: rule.id };
|
|
53
|
+
}
|
|
54
|
+
return { stop: false };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The owner-facing sentence the stop screen shows — the canvas's own copy. It names no
|
|
58
|
+
// rule, and nothing else in the reply does either: the person who answers "contact us"
|
|
59
|
+
// reads the description itself.
|
|
60
|
+
const STOP_MESSAGE = "What you described isn't something we can help create, so we've stopped here. Nothing has been set up. If you think we've read it wrong, contact us and a person will look at it.";
|
|
61
|
+
|
|
62
|
+
module.exports = { SCREENING_RULES, STOP_MESSAGE, screenProject };
|