@bongos/core 1.20.26 → 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 +78 -33
- 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/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 +103 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +17 -17
- package/docs/copy-registry.json +18 -18
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +61 -59
- 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/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 +14 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/government_charter_forms.mjs +283 -0
- package/tests/pending_invite_purge_db.mjs +129 -0
- package/tests/provisioning_planet_physics.mjs +377 -0
|
@@ -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 };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// modules/provisioning/soft-limits.js — which startup options do not apply to a
|
|
2
|
+
// project, and why (BV2.PS02, task 1004416; spec decision D6, "soft limits only").
|
|
3
|
+
//
|
|
4
|
+
// WHAT. One frozen table. Each rule says: WHEN these planet-physics answers hold, this
|
|
5
|
+
// OPTION is hidden, greyed, or carries a note — and here is the REASON, in a line the
|
|
6
|
+
// owner reads beside it. The recommendations.js pattern (ADR 0239): condition ->
|
|
7
|
+
// effect -> reason, and a resolver over the table. Nothing else.
|
|
8
|
+
//
|
|
9
|
+
// SOFT MEANS NOBODY ENFORCES IT. No route refuses an answer for breaking one of these
|
|
10
|
+
// rules, and none may: the server-side age and geography enforcement is a later goal.
|
|
11
|
+
// So every resolution says `enforced: false`, and no reason in the table may word a
|
|
12
|
+
// limit as though it were enforced (the integrity test reads every reason for that).
|
|
13
|
+
// A screen that greys an option is giving advice with a reason, the same standing as
|
|
14
|
+
// `governance.applied: false` in recommendations.js.
|
|
15
|
+
//
|
|
16
|
+
// DECLARATIVE ON PURPOSE. A condition is data — `{ key: { in: [...] } }` for a single
|
|
17
|
+
// answer, `{ key: { has: value } }` or `{ key: { lacks: value } }` for a list answer —
|
|
18
|
+
// never a function, so a rule can be checked against the vocabulary by a test and could
|
|
19
|
+
// be sent to a browser as-is.
|
|
20
|
+
// Every key in `when` must hold; an UNANSWERED key never matches, so no rule fires on
|
|
21
|
+
// an answer nobody gave (the normalizeProjectDetail rule). An empty `when` always holds:
|
|
22
|
+
// that is a fact about the platform, not about the project.
|
|
23
|
+
//
|
|
24
|
+
// EFFECTS, strongest first: `hide` (the option does not apply at all), `grey` (shown,
|
|
25
|
+
// not offered, with the reason), `note` (offered, with the reason beside it — for an
|
|
26
|
+
// option that applies only in part). An option names a whole question (`services`) or
|
|
27
|
+
// one member of it (`reward_intents:equity`).
|
|
28
|
+
//
|
|
29
|
+
// PURE — requires only planet-physics.js.
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const { EU_COUNTRIES, ENTITY_FORMS } = require('./planet-physics');
|
|
34
|
+
|
|
35
|
+
const EFFECTS = Object.freeze(['hide', 'grey', 'note']);
|
|
36
|
+
|
|
37
|
+
const SOFT_LIMIT_RULES = Object.freeze([
|
|
38
|
+
Object.freeze({
|
|
39
|
+
id: 'existing-work-needs-existing-project',
|
|
40
|
+
when: { project_state: { in: ['new'] } },
|
|
41
|
+
options: ['existing_work', 'main_work'],
|
|
42
|
+
effect: 'hide',
|
|
43
|
+
reason: 'Your planet is new, so there is nothing built yet to describe.',
|
|
44
|
+
}),
|
|
45
|
+
Object.freeze({
|
|
46
|
+
id: 'equity-needs-a-company',
|
|
47
|
+
when: { entity: { in: ['intent-company', 'intent-nonprofit', 'intent-solo', 'intent-unsure', 'university-project', 'social-group', 'private-individual'] } },
|
|
48
|
+
options: ['reward_intents:equity'],
|
|
49
|
+
effect: 'grey',
|
|
50
|
+
reason: 'Equity is off until you form a company — there are no shares to give yet.',
|
|
51
|
+
}),
|
|
52
|
+
Object.freeze({
|
|
53
|
+
id: 'equity-not-in-a-nonprofit',
|
|
54
|
+
when: { entity: { in: ['nonprofit'] } },
|
|
55
|
+
options: ['reward_intents:equity'],
|
|
56
|
+
effect: 'grey',
|
|
57
|
+
reason: 'A nonprofit has no owners, so it has no shares to give.',
|
|
58
|
+
}),
|
|
59
|
+
Object.freeze({
|
|
60
|
+
id: 'no-cash-payouts-to-eu-builders-yet',
|
|
61
|
+
when: { builder_geography: { in: ['us-eu', 'global'] } },
|
|
62
|
+
options: ['reward_intents:cash'],
|
|
63
|
+
effect: 'note',
|
|
64
|
+
reason: "Cash payouts to builders in the EU aren't available yet. Builders elsewhere can still be paid in cash.",
|
|
65
|
+
}),
|
|
66
|
+
Object.freeze({
|
|
67
|
+
id: 'no-cash-payouts-from-an-eu-home-yet',
|
|
68
|
+
when: { home_country: { in: [...EU_COUNTRIES] } },
|
|
69
|
+
options: ['reward_intents:cash'],
|
|
70
|
+
effect: 'grey',
|
|
71
|
+
reason: "Cash payouts from a project based in the EU aren't available yet.",
|
|
72
|
+
}),
|
|
73
|
+
Object.freeze({
|
|
74
|
+
id: 'young-builders-non-monetary',
|
|
75
|
+
when: { minors_on_board: { in: [true] } },
|
|
76
|
+
options: ['reward_intents:cash', 'reward_intents:equity', 'reward_intents:future-payouts'],
|
|
77
|
+
effect: 'note',
|
|
78
|
+
reason: 'Builders aged 13 to 15 join non-monetary tracks only. 16 and 17 year olds need a guardian to co-sign paid work.',
|
|
79
|
+
}),
|
|
80
|
+
Object.freeze({
|
|
81
|
+
id: 'managed-includes-every-service',
|
|
82
|
+
when: { framing: { in: ['managed'] } },
|
|
83
|
+
options: ['services'],
|
|
84
|
+
effect: 'hide',
|
|
85
|
+
reason: 'All managed includes the whole suite, so there is nothing to pick.',
|
|
86
|
+
}),
|
|
87
|
+
Object.freeze({
|
|
88
|
+
id: 'open-source-is-the-free-core',
|
|
89
|
+
when: { framing: { in: ['open-source'] } },
|
|
90
|
+
options: ['services'],
|
|
91
|
+
effect: 'hide',
|
|
92
|
+
reason: 'Open source, local is the free core on its own. You can add services later.',
|
|
93
|
+
}),
|
|
94
|
+
Object.freeze({
|
|
95
|
+
id: 'company-creation-needs-no-entity-yet',
|
|
96
|
+
when: { entity: { in: [...ENTITY_FORMS] } },
|
|
97
|
+
options: ['services:company-creation'],
|
|
98
|
+
effect: 'hide',
|
|
99
|
+
reason: 'You already have a legal entity.',
|
|
100
|
+
}),
|
|
101
|
+
Object.freeze({
|
|
102
|
+
id: 'labor-payment-follows-cash',
|
|
103
|
+
when: { reward_intents: { lacks: 'cash' } },
|
|
104
|
+
options: ['services:labor-payment'],
|
|
105
|
+
effect: 'grey',
|
|
106
|
+
reason: "You aren't considering cash for work, so there is no pay to run.",
|
|
107
|
+
}),
|
|
108
|
+
Object.freeze({
|
|
109
|
+
id: 'public-marketing-on-request',
|
|
110
|
+
when: {},
|
|
111
|
+
options: ['services:public-marketing'],
|
|
112
|
+
effect: 'grey',
|
|
113
|
+
reason: "A publicly marketed project is on request for now — contact us and we'll talk it through.",
|
|
114
|
+
}),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
// Does one condition hold against the answers? An absent answer never matches — not
|
|
118
|
+
// even `lacks`, because "never said" is not "said no". PURE.
|
|
119
|
+
function conditionHolds(answers, key, test) {
|
|
120
|
+
if (!Object.prototype.hasOwnProperty.call(answers, key) || answers[key] === null || answers[key] === undefined) return false;
|
|
121
|
+
const value = answers[key];
|
|
122
|
+
if (test.in) return test.in.includes(value);
|
|
123
|
+
if ('has' in test) return Array.isArray(value) && value.includes(test.has);
|
|
124
|
+
if ('lacks' in test) return Array.isArray(value) && !value.includes(test.lacks);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The limits that apply to one project's answers:
|
|
129
|
+
//
|
|
130
|
+
// { enforced: false, limits: [{ option, effect, reason, rule }] }
|
|
131
|
+
//
|
|
132
|
+
// one entry per (rule, option), in table order — several rules may name one option,
|
|
133
|
+
// and a screen shows the strongest effect with every reason. `answers` is the stored
|
|
134
|
+
// detail (projectDetail(row)), or the in-progress answers of a flow that has not
|
|
135
|
+
// created anything yet. Never throws. PURE — the result is fresh, never the table.
|
|
136
|
+
function softLimitsFor(answers) {
|
|
137
|
+
const given = answers && typeof answers === 'object' && !Array.isArray(answers) ? answers : {};
|
|
138
|
+
const limits = [];
|
|
139
|
+
for (const rule of SOFT_LIMIT_RULES) {
|
|
140
|
+
if (!Object.entries(rule.when).every(([key, test]) => conditionHolds(given, key, test))) continue;
|
|
141
|
+
for (const option of rule.options) limits.push({ option, effect: rule.effect, reason: rule.reason, rule: rule.id });
|
|
142
|
+
}
|
|
143
|
+
return { enforced: false, limits };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { SOFT_LIMIT_RULES, EFFECTS, softLimitsFor };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.27",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.27",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.27",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8216,5 +8216,19 @@
|
|
|
8216
8216
|
"id": "1004429",
|
|
8217
8217
|
"text": "A new project's founding stages are now decided by its own government. When a stage's work is finished, closing the stage goes to the Board Room like any other decision. A solo founder under the default one-leader government c"
|
|
8218
8218
|
}
|
|
8219
|
+
],
|
|
8220
|
+
"1.20.27": [
|
|
8221
|
+
{
|
|
8222
|
+
"id": "1004416",
|
|
8223
|
+
"text": "A new project can now store the seven startup answers: new or existing, where everyone is, whether under-18s are building, what the entity is, how many builders, how they'll be rewarded, and how much Bongos should run. A wrong"
|
|
8224
|
+
},
|
|
8225
|
+
{
|
|
8226
|
+
"id": "1004427",
|
|
8227
|
+
"text": "A new project now chooses its government by the real names: Monarchy / BDFL, Timocratic Republic, Direct Democracy or Foundation, with Presidential, Cooperative and Federation shown as coming later. Every form and setting has"
|
|
8228
|
+
},
|
|
8229
|
+
{
|
|
8230
|
+
"id": "1004408",
|
|
8231
|
+
"text": "Old project invitations that led to a locked door are cleared from people's My Projects list."
|
|
8232
|
+
}
|
|
8219
8233
|
]
|
|
8220
8234
|
}
|
|
@@ -208,6 +208,10 @@ const INTEGRATION = new Set([
|
|
|
208
208
|
// prove the CHECK actually refuses a stored fourth state, that the backfill lands on a
|
|
209
209
|
// pre-migration row, and that publishableSql and projectPublishability agree.
|
|
210
210
|
'project_visibility_db',
|
|
211
|
+
// task 1004408: migration platform_identity_028 deletes the old hub invite's
|
|
212
|
+
// pending rows before a cutoff it reads from the core_upgrades ledger. Only a real
|
|
213
|
+
// engine runs the DO block, the numeric version compare and the one-shot guard.
|
|
214
|
+
'pending_invite_purge_db',
|
|
211
215
|
// task 1003956 (ADR 0294): that CLI sessions really never expire. The DB-free
|
|
212
216
|
// sibling (auth_session_ttl) pins the constant, createSession's value against a
|
|
213
217
|
// stub pool, and the TEXT of the refresh statement — but a regex over source
|
|
@@ -271,6 +271,9 @@ const GOVERNMENT_PINS = {
|
|
|
271
271
|
// task 1004429: the founding band — who each genesis stage is waiting on. A
|
|
272
272
|
// read of the open ballot, so the same atom as the room.
|
|
273
273
|
'GET /government/board/genesis-stages': 'perm:board.vote.cast',
|
|
274
|
+
// task 1004427: the genesis charter + legislation choice — an amendment by
|
|
275
|
+
// another door, so the same atom as proposing one.
|
|
276
|
+
'POST /government/board/charter': 'perm:board.item.open',
|
|
274
277
|
};
|
|
275
278
|
|
|
276
279
|
// Module-scoped privileged-rank pins: a privileged route that lives in a FEATURE
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.27'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|