@bongos/core 1.25.21 → 1.25.23
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 +89 -49
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
- package/docs/api/openapi.json +198 -3
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +415 -410
- package/docs/copy-registry.json +551 -506
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +382 -376
- package/modules/hall-ui/public/palette.js +11 -4
- package/modules/hall-ui/records/founding-mode.md +6 -0
- package/modules/lifecycle/kickoff-checklist.js +1 -1
- package/modules/lifecycle/routes/pay-type-gates.js +7 -2
- package/modules/onboarding/project-facts.js +318 -0
- package/modules/onboarding/routes/genesis-work.js +16 -0
- package/modules/provisioning/project-facts.js +55 -0
- package/modules/provisioning/provisioning.js +4 -1
- package/modules/provisioning/render-sandbox.js +6 -0
- package/modules/public-landing/public/projects.html +191 -31
- package/package-lock.json +2 -2
- package/package.json +2 -1
- package/release-notes.json +20 -0
- package/scripts/bongos/genesis.js +38 -2
- package/scripts/bongos/init.js +13 -0
- package/scripts/bongos/package-core.js +2 -0
- package/scripts/bongos/provision-render-sandbox.js +5 -1
- package/scripts/bongos/render-payload.js +5 -2
- package/scripts/hall-preview/README.md +1 -0
- package/scripts/hall-preview/server.js +32 -1
- package/src/bongos/folder-upload.js +243 -0
- package/src/bongos/routes/github.js +109 -16
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +4 -0
- package/src/module-api.js +1 -1
- package/src/sandbox/gate.js +164 -0
- package/src/sandbox/index.js +144 -0
- package/tests/folder_upload_project.mjs +396 -0
- package/tests/genesis_work_routes.mjs +25 -1
- package/tests/hall_palette.mjs +59 -2
- package/tests/project_facts.mjs +158 -0
- package/tests/projects_hub.mjs +1 -1
- package/tests/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_settings_env.mjs +2 -2
- package/tests/public_landing_projects.mjs +27 -5
- package/tests/render_sandbox.mjs +7 -4
- package/tests/sandbox_gate.mjs +182 -0
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// The index has three CLIENT-SIDE layers (task 2178 scope):
|
|
13
13
|
// 1. Pages — read live from the sidebar shell.js rendered, so rank + module
|
|
14
14
|
// gating come for free and can never drift from the nav. A hidden nav item
|
|
15
|
-
// is a hidden destination
|
|
15
|
+
// is a hidden destination, and so is a room asleep while the project is
|
|
16
|
+
// founding. (Cosmetic only; the server still gates each page.)
|
|
16
17
|
// 2. Sections — the sub-destinations of the four pages that have them. These
|
|
17
18
|
// cannot be read from another page's DOM, so they are declared below and
|
|
18
19
|
// held to the real ids by tests/hall_palette.mjs.
|
|
@@ -145,9 +146,15 @@
|
|
|
145
146
|
var searchCache = {}; // query (trimmed+lowercased) -> raw hits[], this OPEN only
|
|
146
147
|
|
|
147
148
|
function esc(s) { return window.OTB.escapeHtml(s); }
|
|
149
|
+
// A nav item the sidebar does not show is not a destination: hidden by its gate, or
|
|
150
|
+
// asleep while the project is founding (task 1005285). The sleeping mark is the
|
|
151
|
+
// shell's, stamped from the server's /me.founding_mode.asleep, never decided here,
|
|
152
|
+
// and the server refuses the page as well (src/bongos/founding-page-gate.js).
|
|
153
|
+
function offered(el) {
|
|
154
|
+
return !!(el && !el.hidden && !(el.classList && el.classList.contains('nav-item--asleep')));
|
|
155
|
+
}
|
|
148
156
|
function visible(id) {
|
|
149
|
-
|
|
150
|
-
return !!(el && !el.hidden);
|
|
157
|
+
return offered(document.getElementById(id));
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
// Pages, straight from the rendered sidebar. Skips the external API-docs link's
|
|
@@ -160,7 +167,7 @@
|
|
|
160
167
|
function pageEntries() {
|
|
161
168
|
var out = [];
|
|
162
169
|
document.querySelectorAll('#app-sidebar .nav-item').forEach(function (a) {
|
|
163
|
-
if (a
|
|
170
|
+
if (!offered(a)) return;
|
|
164
171
|
var label = (a.querySelector('.nav-item__label') || {}).textContent || '';
|
|
165
172
|
if (!label) return;
|
|
166
173
|
out.push({
|
|
@@ -29,3 +29,9 @@ work-founding.states.json renders an ordinary hall page with a founding /me (kit
|
|
|
29
29
|
- The genesis home, the stage tasks and the "what's this?" destination belong to PS08 (task 1004423). The band links home until then.
|
|
30
30
|
- The founding mark belongs to PS10 (task 1004424).
|
|
31
31
|
- Starting founding on a new project and moving its stage is `setFoundingMode`, on the onboarding port, and PS08 drives it. Nothing in this task writes it.
|
|
32
|
+
|
|
33
|
+
## The classic hall wears all three genesis behaviours (task 1005285)
|
|
34
|
+
|
|
35
|
+
Since task 1005112 an asleep room is **hidden, not dimmed**, and its page is refused (`src/bongos/founding-page-gate.js` redirects home with `?asleep=<room>&opens=<stage>`, and the shell says which stage opens it); the "cosmetic, nothing enforced" line above is history. The owner asked (2026-11-13) that the classic hall get the same three genesis behaviours as the game. Two were already shared code: the stage checklist (done / open / locked, and what a locked task waits on, from `shapeGenesis`) and Copy prompt on each open task are the Home banner card `genesis-home.js`, which both views load. The gap was the **jump list**: `palette.js` skipped only a nav item with the `hidden` attribute, so Ctrl/Cmd-K still offered Tasks, Goals, Sky, Collab and Builders, and the Tasks tabs, while they slept. It now treats `.nav-item--asleep` (the shell's mark, stamped from `/me.founding_mode.asleep`) as not a destination, for pages and for their sections; it never works out which rooms sleep itself. `tests/hall_palette.mjs` holds it against the real `shapeFoundingMode`.
|
|
36
|
+
|
|
37
|
+
**To walk it:** `node scripts/hall-preview/server.js --fixture-me --founding scope` (the `hall-preview-founding` launch entry). `/me.founding_mode` is the real `shapeFoundingMode`, `GET /founding/genesis` the real `shapeGenesis` over `scripts/hall-preview/fixtures/founding__genesis__tasks.json`, and a sleeping room's page answers through the real `foundingPageGate`.
|
|
@@ -159,7 +159,7 @@ const GENESIS_STEPS = Object.freeze({
|
|
|
159
159
|
],
|
|
160
160
|
legalize: [
|
|
161
161
|
{ title: 'Decide how contributors are rewarded and who can be paid',
|
|
162
|
-
description: 'Genesis stage 4 (legalize). Whether builders earn credits, money or nothing, and the limits that apply (age, country, the entity behind the project). When it is done, the Economy wakes.' },
|
|
162
|
+
description: 'Genesis stage 4 (legalize). Whether builders earn credits, money or nothing, and the limits that apply (age, country, the entity behind the project). Run `npx bongos genesis options` first: it lists every reward option with what the server enforces and the facts about this project beside it (`npx bongos genesis facts` shows and changes those facts). Explain those rules to the founder; never write a rule of your own into a document. If a rule seems missing, say so and leave the line to the founder. When it is done, the Economy wakes.' },
|
|
163
163
|
],
|
|
164
164
|
legislation: [
|
|
165
165
|
{ title: 'Set the voting rules: who votes and how many must agree',
|
|
@@ -24,9 +24,14 @@ module.exports = function buildPayTypeGatesRouter() {
|
|
|
24
24
|
// The `lifecycle.payTypeGates` port (task 1004910): core's module-sale payouts ask it
|
|
25
25
|
// whether module_share is cleared (licence, takedown contact, payee onboarding) before
|
|
26
26
|
// a rule, a sale or a share moves. hasProvider-guarded so a rebuilt router never
|
|
27
|
-
// registers it twice.
|
|
27
|
+
// registers it twice. Genesis's legalize stage (task 1005129) reads every gate through
|
|
28
|
+
// it too, met/missing only, so the founder sees what a pay type needs as they choose.
|
|
28
29
|
if (!api.hasProvider('lifecycle.payTypeGates')) {
|
|
29
|
-
api.registerProvider('lifecycle.payTypeGates', {
|
|
30
|
+
api.registerProvider('lifecycle.payTypeGates', {
|
|
31
|
+
refuseUnclearedGates: gates.refuseUnclearedGates,
|
|
32
|
+
readGates: async () => (await gates.readGates()).map(gates.withoutEvidence),
|
|
33
|
+
labels: require('../pay-terms').PAY_TYPE_LABELS,
|
|
34
|
+
});
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
// rank: any-builder for met/missing (the same pay information `GET /pay-terms` shows, no person's
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/onboarding/project-facts.js — WHAT THE PROJECT KNOWS ABOUT ITSELF, AND WHAT EACH
|
|
4
|
+
// REWARD OPTION REALLY MEANS FOR IT (task 1005129, goal 1000106).
|
|
5
|
+
//
|
|
6
|
+
// WHY. Working the legalize stage, a genesis session wrote a policy line into a project's
|
|
7
|
+
// bylaws ("§3 limits paid work to the US for now") that the founder never chose: the
|
|
8
|
+
// platform already knew the rules but never said them at the point of choice, and the
|
|
9
|
+
// project never knew where it was. The owner: "we should know the implications of the
|
|
10
|
+
// options we lay out to the user … and make them aware when they are selecting."
|
|
11
|
+
//
|
|
12
|
+
// TWO HALVES.
|
|
13
|
+
// 1. THE FACTS. The startup flow's answers (where the project is, where its builders
|
|
14
|
+
// are, minors, entity, reward intent) arrive from the hub ONCE as founding.projectFacts
|
|
15
|
+
// (modules/provisioning/project-facts.js), together with the reward soft-limit rules as
|
|
16
|
+
// data. They land here as the project's OWN EDITABLE COPY (project_settings FACTS_KEY),
|
|
17
|
+
// not a frozen snapshot: a project that starts solo in one country and later has
|
|
18
|
+
// builders in three is normal. An answer never given reads "not answered" — so does
|
|
19
|
+
// every fact on a project not created through the hub — and nothing here guesses.
|
|
20
|
+
// 2. THE OPTIONS AT THE POINT OF CHOICE. legalizeOptions states, for every pay type, what
|
|
21
|
+
// it REQUIRES and what is CLOSED, read from the two tables that already hold the rules
|
|
22
|
+
// and never written fresh by a session:
|
|
23
|
+
// enforced the lifecycle module's pay-type gates (the server refuses a pay term
|
|
24
|
+
// whose gate is open; eu_labour can never be cleared). Through the
|
|
25
|
+
// `lifecycle.payTypeGates` port, met/missing only.
|
|
26
|
+
// facts the hub's soft limits, evaluated against THIS project's current facts.
|
|
27
|
+
// Advice with a reason, `enforced: false`: nobody refuses anything for them.
|
|
28
|
+
// The two are kept apart in the answer, never merged into one list, so advice is never
|
|
29
|
+
// shown as a wall and a wall never as advice. A BLOCKED OPTION IS SHOWN with its reason,
|
|
30
|
+
// not hidden — a founder with EU contributors must see that EU pay cannot be switched on.
|
|
31
|
+
// Changing a fact re-reads every option at once; setFacts says which options it changed.
|
|
32
|
+
//
|
|
33
|
+
// THE STANDING RULE this exists to make possible: an agent may EXPLAIN a rule; it must not
|
|
34
|
+
// INVENT one. A session that finds itself deriving a policy line has found something the
|
|
35
|
+
// platform failed to model — it says so, and leaves the line to the founder.
|
|
36
|
+
//
|
|
37
|
+
// THE BOUNDARY. Core through ../../src/module-api.js (project settings, branding, ports);
|
|
38
|
+
// the lifecycle module through its port only. The soft-limit rules are carried as data from
|
|
39
|
+
// the hub, so this file never reaches into the provisioning module either.
|
|
40
|
+
|
|
41
|
+
const FACTS_KEY = 'project.facts';
|
|
42
|
+
const FACT_KEYS = Object.freeze(['home_country', 'home_region', 'builder_geography', 'minors_on_board', 'entity', 'reward_intents']);
|
|
43
|
+
const GEOGRAPHY = Object.freeze(['us-only', 'us-eu', 'global', 'not-sure']);
|
|
44
|
+
const REWARD_INTENTS = Object.freeze(['non-monetary', 'cash', 'equity', 'future-payouts']);
|
|
45
|
+
const FACT_LABELS = Object.freeze({
|
|
46
|
+
home_country: 'Where the project is based',
|
|
47
|
+
home_region: 'Its state or province',
|
|
48
|
+
builder_geography: 'Where its builders are',
|
|
49
|
+
minors_on_board: 'Whether builders under 18 take part',
|
|
50
|
+
entity: 'What stands behind it (a company, a group, a person)',
|
|
51
|
+
reward_intents: 'How it means to reward people',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Which startup reward intent speaks to which pay type, so a soft limit on the intent is
|
|
55
|
+
// shown beside the pay type it is about. A pay type with no intent carries no soft limits.
|
|
56
|
+
const INTENT_FOR_PAY_TYPE = Object.freeze({
|
|
57
|
+
cash: 'cash', equity: 'equity', non_monetary: 'non-monetary', credit_pool: 'future-payouts',
|
|
58
|
+
});
|
|
59
|
+
const EXTRA_LABELS = Object.freeze({ eu_labour: 'Paying builders in the EU' });
|
|
60
|
+
|
|
61
|
+
const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
62
|
+
|
|
63
|
+
// ── pure: one fact checked ───────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
// checkFact(key, value, facts) → { ok, value } or { ok:false, reason }. null un-answers.
|
|
66
|
+
function checkFact(key, value, facts = {}) {
|
|
67
|
+
if (!FACT_KEYS.includes(key)) return { ok: false, reason: 'unknown_fact', key };
|
|
68
|
+
if (value === null) return { ok: true, value: null };
|
|
69
|
+
const bad = { ok: false, reason: 'bad_fact', key };
|
|
70
|
+
switch (key) {
|
|
71
|
+
case 'home_country': return typeof value === 'string' && /^[A-Z]{2}$/.test(value) ? { ok: true, value } : bad;
|
|
72
|
+
case 'home_region': {
|
|
73
|
+
if (typeof value !== 'string' || !/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(value)) return bad;
|
|
74
|
+
const country = facts.home_country;
|
|
75
|
+
return country && !value.startsWith(`${country}-`) ? { ok: false, reason: 'region_not_in_country', key } : { ok: true, value };
|
|
76
|
+
}
|
|
77
|
+
case 'builder_geography': return GEOGRAPHY.includes(value) ? { ok: true, value } : bad;
|
|
78
|
+
case 'minors_on_board': return typeof value === 'boolean' ? { ok: true, value } : bad;
|
|
79
|
+
case 'entity': return typeof value === 'string' && /^[a-z][a-z0-9-]{1,39}$/.test(value) ? { ok: true, value } : bad;
|
|
80
|
+
case 'reward_intents': {
|
|
81
|
+
if (!Array.isArray(value) || !value.every((v) => REWARD_INTENTS.includes(v))) return bad;
|
|
82
|
+
return { ok: true, value: REWARD_INTENTS.filter((v) => value.includes(v)) };
|
|
83
|
+
}
|
|
84
|
+
default: return bad;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── pure: the soft-limit rules, evaluated here ───────────────────────────────
|
|
89
|
+
|
|
90
|
+
// The hub's rule shape (modules/provisioning/soft-limits.js): an absent answer never
|
|
91
|
+
// matches, not even `lacks` — "never said" is not "said no".
|
|
92
|
+
function conditionHolds(facts, key, test) {
|
|
93
|
+
if (!Object.prototype.hasOwnProperty.call(facts, key) || facts[key] == null) return false;
|
|
94
|
+
const v = facts[key];
|
|
95
|
+
if (test && Array.isArray(test.in)) return test.in.includes(v);
|
|
96
|
+
if (test && 'has' in test) return Array.isArray(v) && v.includes(test.has);
|
|
97
|
+
if (test && 'lacks' in test) return Array.isArray(v) && !v.includes(test.lacks);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function factNotesFor(intent, facts, rules) {
|
|
102
|
+
if (!intent) return [];
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const r of rules || []) {
|
|
105
|
+
if (!isObj(r) || !Array.isArray(r.options) || !isObj(r.when)) continue;
|
|
106
|
+
if (!r.options.includes(`reward_intents:${intent}`) && !r.options.includes('reward_intents')) continue;
|
|
107
|
+
if (!Object.entries(r.when).every(([k, t]) => conditionHolds(facts, k, t))) continue;
|
|
108
|
+
out.push({ effect: String(r.effect), reason: String(r.reason), rule: String(r.id), enforced: false });
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// legalizeOptions({ facts, rules, gates, labels }) — PURE. Every reward option the legalize
|
|
114
|
+
// stage offers, with what it requires (enforced) and what the project's facts say about it
|
|
115
|
+
// (not enforced), in two separate lists. `gates` null means this project runs no pay-rules
|
|
116
|
+
// module: then nothing is enforced here, and the answer says so rather than inventing rules.
|
|
117
|
+
function legalizeOptions({ facts = {}, rules = [], gates = null, labels = {} } = {}) {
|
|
118
|
+
const names = { ...EXTRA_LABELS, ...labels };
|
|
119
|
+
const types = gates ? gates.map((g) => g.pay_type) : Object.keys(INTENT_FOR_PAY_TYPE);
|
|
120
|
+
const options = types.map((payType) => {
|
|
121
|
+
const g = gates ? gates.find((x) => x.pay_type === payType) : null;
|
|
122
|
+
let enforced = null;
|
|
123
|
+
let status = 'no_rules_module';
|
|
124
|
+
if (g && g.never) {
|
|
125
|
+
enforced = { never: true, cleared: false, requires: [], missing: [],
|
|
126
|
+
reason: 'The server refuses this pay type, and it cannot be switched on yet.' };
|
|
127
|
+
status = 'closed';
|
|
128
|
+
} else if (g) {
|
|
129
|
+
const requires = (g.requirements || []).map((r) => ({ requirement: r.requirement, label: r.label, met: !!r.met }));
|
|
130
|
+
enforced = { never: false, cleared: !!g.cleared, requires, missing: requires.filter((r) => !r.met).map((r) => r.label),
|
|
131
|
+
reason: g.cleared ? 'Every requirement is on file; the server allows it.' : 'The server refuses it until every requirement below is on file.' };
|
|
132
|
+
status = g.cleared ? 'open' : 'needs_setup';
|
|
133
|
+
}
|
|
134
|
+
const notes = factNotesFor(INTENT_FOR_PAY_TYPE[payType], facts, rules);
|
|
135
|
+
return {
|
|
136
|
+
pay_type: payType,
|
|
137
|
+
label: names[payType] || payType,
|
|
138
|
+
status,
|
|
139
|
+
enforced,
|
|
140
|
+
// Facts, not policy (soft-limits.js): advice with its reason, refused by nobody.
|
|
141
|
+
facts: notes,
|
|
142
|
+
advised_against: notes.some((n) => n.effect === 'grey' || n.effect === 'hide'),
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
return options;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// What changed for the options when the facts changed: the founder is told, so an old
|
|
149
|
+
// decision is never left standing silently on a fact that moved.
|
|
150
|
+
function optionChanges(before, after) {
|
|
151
|
+
const key = (o) => JSON.stringify([o.status, o.facts.map((n) => n.rule)]);
|
|
152
|
+
const was = new Map(before.map((o) => [o.pay_type, o]));
|
|
153
|
+
return after.filter((o) => !was.has(o.pay_type) || key(was.get(o.pay_type)) !== key(o)).map((o) => {
|
|
154
|
+
const prev = was.get(o.pay_type);
|
|
155
|
+
const had = new Set(prev ? prev.facts.map((n) => n.rule) : []);
|
|
156
|
+
const has = new Set(o.facts.map((n) => n.rule));
|
|
157
|
+
return {
|
|
158
|
+
pay_type: o.pay_type, label: o.label,
|
|
159
|
+
new_notes: o.facts.filter((n) => !had.has(n.rule)).map((n) => n.reason),
|
|
160
|
+
gone_notes: prev ? prev.facts.filter((n) => !has.has(n.rule)).map((n) => n.reason) : [],
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── the hub's marker and the stored copy ─────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
// factsMarker(founding) — PURE. founding.projectFacts → { facts, rules } or null.
|
|
168
|
+
function factsMarker(founding) {
|
|
169
|
+
const raw = isObj(founding) ? founding.projectFacts : null;
|
|
170
|
+
if (typeof raw !== 'string' || !raw.trim()) return null;
|
|
171
|
+
try {
|
|
172
|
+
const m = JSON.parse(Buffer.from(raw.trim(), 'base64url').toString('utf8'));
|
|
173
|
+
if (!isObj(m) || !isObj(m.facts)) return null;
|
|
174
|
+
const facts = {};
|
|
175
|
+
for (const k of FACT_KEYS) {
|
|
176
|
+
const c = checkFact(k, m.facts[k], m.facts);
|
|
177
|
+
if (m.facts[k] != null && c.ok) facts[k] = c.value;
|
|
178
|
+
}
|
|
179
|
+
return { facts, rules: Array.isArray(m.rules) ? m.rules : [] };
|
|
180
|
+
} catch (_) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function readStored(raw) {
|
|
186
|
+
if (!raw) return null;
|
|
187
|
+
try {
|
|
188
|
+
const s = JSON.parse(raw);
|
|
189
|
+
if (!isObj(s) || !isObj(s.facts)) return null;
|
|
190
|
+
return { facts: s.facts, origin: isObj(s.origin) ? s.origin : {}, rules: Array.isArray(s.rules) ? s.rules : [], updated_at: s.updated_at || null };
|
|
191
|
+
} catch (_) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function depsOf(deps = {}) {
|
|
197
|
+
const api = () => require('../../src/module-api');
|
|
198
|
+
return {
|
|
199
|
+
get: deps.getSetting || ((k) => api().projectSettings.get(k)),
|
|
200
|
+
set: deps.setSetting || ((k, v, by) => api().projectSettings.set(k, v, by)),
|
|
201
|
+
marker: Object.prototype.hasOwnProperty.call(deps, 'factsMarker') ? deps.factsMarker
|
|
202
|
+
: (() => { try { return factsMarker(api().branding().founding); } catch (_) { return null; } })(),
|
|
203
|
+
gates: deps.payGates !== undefined ? deps.payGates : (() => { try { return api().resolveOptional('lifecycle.payTypeGates'); } catch (_) { return null; } })(),
|
|
204
|
+
now: deps.now || (() => new Date()),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The stored copy, seeded ONCE from the hub's marker. The project's copy wins from then on:
|
|
209
|
+
// a later edit here is never overwritten by what was said at creation.
|
|
210
|
+
async function loadStored(d) {
|
|
211
|
+
const row = await d.get(FACTS_KEY);
|
|
212
|
+
const stored = readStored(row && row.value);
|
|
213
|
+
if (stored) return stored;
|
|
214
|
+
if (!d.marker) return { facts: {}, origin: {}, rules: [], updated_at: null };
|
|
215
|
+
const seeded = {
|
|
216
|
+
facts: d.marker.facts,
|
|
217
|
+
origin: Object.fromEntries(Object.keys(d.marker.facts).map((k) => [k, 'carried_over'])),
|
|
218
|
+
rules: d.marker.rules,
|
|
219
|
+
updated_at: d.now().toISOString(),
|
|
220
|
+
};
|
|
221
|
+
await d.set(FACTS_KEY, JSON.stringify(seeded), null);
|
|
222
|
+
return seeded;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// shapeFacts(stored) — PURE. Every fact, answered or not, in one constant shape.
|
|
226
|
+
function shapeFacts(stored) {
|
|
227
|
+
const facts = FACT_KEYS.map((key) => {
|
|
228
|
+
const has = stored.facts[key] != null;
|
|
229
|
+
return {
|
|
230
|
+
key, label: FACT_LABELS[key],
|
|
231
|
+
value: has ? stored.facts[key] : null,
|
|
232
|
+
answered: has,
|
|
233
|
+
// carried_over until the founder confirms or edits it here; founder after.
|
|
234
|
+
origin: has ? (stored.origin[key] || 'founder') : null,
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
return { facts, unanswered: facts.filter((f) => !f.answered).map((f) => f.key), updated_at: stored.updated_at };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function gatesOf(d) {
|
|
241
|
+
if (!d.gates || typeof d.gates.readGates !== 'function') return { gates: null, labels: {} };
|
|
242
|
+
return { gates: await d.gates.readGates(), labels: d.gates.labels || {} };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// readFacts(deps) → { facts, unanswered, updated_at }
|
|
246
|
+
async function readFacts(deps = {}) {
|
|
247
|
+
return shapeFacts(await loadStored(depsOf(deps)));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// readOptions(deps) → the legalize stage's options, with the facts they were read against.
|
|
251
|
+
async function readOptions(deps = {}) {
|
|
252
|
+
const d = depsOf(deps);
|
|
253
|
+
const stored = await loadStored(d);
|
|
254
|
+
const { gates, labels } = await gatesOf(d);
|
|
255
|
+
return {
|
|
256
|
+
...shapeFacts(stored),
|
|
257
|
+
rules_module: !!gates,
|
|
258
|
+
options: legalizeOptions({ facts: stored.facts, rules: stored.rules, gates, labels }),
|
|
259
|
+
// The two kinds of statement, named for whoever shows them.
|
|
260
|
+
explain: {
|
|
261
|
+
enforced: 'Enforced: the server refuses this until the requirement is on file.',
|
|
262
|
+
facts: 'A fact about your project, not a rule: nobody refuses anything for it.',
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// setFacts(patch, by, deps) — the founder edits the project's own copy. Each key in the
|
|
268
|
+
// patch is set (or un-answered with null); the rest stay. Answers the new facts and every
|
|
269
|
+
// option whose notes changed, so nothing decided on the old facts is left silently.
|
|
270
|
+
async function setFacts(patch, by, deps = {}) {
|
|
271
|
+
if (!isObj(patch)) return { ok: false, reason: 'bad_fact' };
|
|
272
|
+
const d = depsOf(deps);
|
|
273
|
+
const stored = await loadStored(d);
|
|
274
|
+
const next = { ...stored.facts };
|
|
275
|
+
const origin = { ...stored.origin };
|
|
276
|
+
for (const key of Object.keys(patch)) {
|
|
277
|
+
const c = checkFact(key, patch[key], { ...next, ...patch });
|
|
278
|
+
if (!c.ok) return c;
|
|
279
|
+
if (c.value === null) { delete next[key]; delete origin[key]; } else { next[key] = c.value; origin[key] = 'founder'; }
|
|
280
|
+
}
|
|
281
|
+
if (next.home_region && next.home_country && !next.home_region.startsWith(`${next.home_country}-`)) {
|
|
282
|
+
return { ok: false, reason: 'region_not_in_country', key: 'home_region' };
|
|
283
|
+
}
|
|
284
|
+
const { gates, labels } = await gatesOf(d);
|
|
285
|
+
const before = legalizeOptions({ facts: stored.facts, rules: stored.rules, gates, labels });
|
|
286
|
+
const after = legalizeOptions({ facts: next, rules: stored.rules, gates, labels });
|
|
287
|
+
const saved = { facts: next, origin, rules: stored.rules, updated_at: d.now().toISOString() };
|
|
288
|
+
await d.set(FACTS_KEY, JSON.stringify(saved), by == null ? null : by);
|
|
289
|
+
return { ok: true, ...shapeFacts(saved), options: after, changed: optionChanges(before, after) };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// optionsMarkdown(read) — PURE. The options as plain text, for an AI session working the
|
|
293
|
+
// legalize stage with the founder (the CLI's `genesis options`). It explains; it decides nothing.
|
|
294
|
+
function optionsMarkdown(r) {
|
|
295
|
+
const lines = ['# Reward options for this project', '',
|
|
296
|
+
'Read from the platform\'s own rules. Explain these; never add a rule of your own. If something here looks missing, say so to the founder instead of writing a policy line.', ''];
|
|
297
|
+
const said = r.facts.filter((f) => f.answered);
|
|
298
|
+
lines.push('## What the project has said about itself', '');
|
|
299
|
+
for (const f of said) lines.push(`- ${f.label}: ${Array.isArray(f.value) ? f.value.join(', ') : String(f.value)}${f.origin === 'carried_over' ? ' (from project creation, not yet confirmed)' : ''}`);
|
|
300
|
+
for (const k of r.unanswered) lines.push(`- ${FACT_LABELS[k]}: not answered`);
|
|
301
|
+
lines.push('');
|
|
302
|
+
if (!r.rules_module) lines.push('This project runs no pay-rules module, so nothing below is enforced by the server.', '');
|
|
303
|
+
for (const o of r.options) {
|
|
304
|
+
lines.push(`## ${o.label}`);
|
|
305
|
+
if (o.enforced && o.enforced.never) lines.push(`- Closed (enforced): ${o.enforced.reason}`);
|
|
306
|
+
else if (o.enforced) {
|
|
307
|
+
lines.push(`- ${o.enforced.cleared ? 'Allowed (enforced)' : 'Not yet allowed (enforced)'}: ${o.enforced.reason}`);
|
|
308
|
+
for (const q of o.enforced.requires) lines.push(` - ${q.met ? '[on file]' : '[missing]'} ${q.label}`);
|
|
309
|
+
}
|
|
310
|
+
for (const n of o.facts) lines.push(`- Fact, not a rule: ${n.reason}`);
|
|
311
|
+
lines.push('');
|
|
312
|
+
}
|
|
313
|
+
return lines.join('\n').trimEnd() + '\n';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = {
|
|
317
|
+
FACTS_KEY, factsMarker, readFacts, readOptions, setFacts, optionsMarkdown,
|
|
318
|
+
};
|
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
|
|
13
13
|
const api = require('../../../src/module-api');
|
|
14
14
|
const work = require('../genesis-work');
|
|
15
|
+
const facts = require('../project-facts');
|
|
15
16
|
const handoff = require('../guide-handoff'); // the start-a-project guide's answers, carried in once (task 1005274)
|
|
16
17
|
|
|
17
18
|
const REFUSALS = {
|
|
18
19
|
unknown_stage: 404, unknown_question: 404, bad_door: 400, bad_state: 400, bad_origin: 400,
|
|
20
|
+
unknown_fact: 400, bad_fact: 400, region_not_in_country: 400,
|
|
19
21
|
empty_answer: 400, not_a_choice: 400, answer_too_long: 400, page_too_long: 400, link_too_long: 400, bad_link: 400,
|
|
20
22
|
};
|
|
21
23
|
|
|
@@ -89,4 +91,18 @@ module.exports = function mountGenesisWorkRoutes(router, deps = {}) {
|
|
|
89
91
|
// Un-answer: back to empty, which is not the same as skipped.
|
|
90
92
|
router.delete('/founding/work/:stage/answers/:question', api.requireBuilder, owns,
|
|
91
93
|
handle((req) => work.clearAnswer(stage(req), q(req), req.builder.id, deps)));
|
|
94
|
+
|
|
95
|
+
// The project's own facts and the legalize stage's options (task 1005129, ../project-facts.js):
|
|
96
|
+
// any builder reads them; the facts are the owner's to change, like every genesis write.
|
|
97
|
+
router.get('/founding/facts', api.requireBuilder, handle(() => facts.readFacts(deps)));
|
|
98
|
+
router.put('/founding/facts', api.requireBuilder, owns,
|
|
99
|
+
(req, res, next) => {
|
|
100
|
+
if (api.validateOrRespond(req, res, { facts: { type: 'object', required: true } })) return;
|
|
101
|
+
next();
|
|
102
|
+
},
|
|
103
|
+
handle((req) => facts.setFacts(req.body.facts, req.builder.id, deps)));
|
|
104
|
+
router.get('/founding/legalize/options', api.requireBuilder, handle(async () => {
|
|
105
|
+
const r = await facts.readOptions(deps);
|
|
106
|
+
return { ...r, markdown: facts.optionsMarkdown(r) };
|
|
107
|
+
}));
|
|
92
108
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// modules/provisioning/project-facts.js — THE PROJECT'S OWN FACTS, CARRIED FROM THE HUB
|
|
2
|
+
// INTO THE PROJECT (task 1005129, goal 1000106).
|
|
3
|
+
//
|
|
4
|
+
// WHY. The startup flow asks where the project is, where its builders are, whether minors
|
|
5
|
+
// are on board, what entity stands behind it and how it means to reward people
|
|
6
|
+
// (planet-physics.js). Until now none of it crossed to the project, so a genesis session
|
|
7
|
+
// working the legalize stage had to derive the rules from scratch — and once wrote a
|
|
8
|
+
// policy line into the bylaws that the founder never chose. The owner: "we should know
|
|
9
|
+
// the implications of the options we lay out to the user … have that carry throughout
|
|
10
|
+
// the rest of the project setup."
|
|
11
|
+
//
|
|
12
|
+
// WHAT CROSSES, and only that: the answers a genesis stage reads — FACT_KEYS — plus the
|
|
13
|
+
// soft-limit RULES (soft-limits.js) that speak to rewards, as the declarative data they
|
|
14
|
+
// already are. The project evaluates those rules against ITS OWN editable copy of the
|
|
15
|
+
// facts (modules/onboarding/project-facts.js), so a fact changed later re-checks every
|
|
16
|
+
// note at once instead of leaving an old resolution standing. Soft limits stay facts,
|
|
17
|
+
// never policy: each carries `enforced: false`. The ENFORCED pay-type gates are not
|
|
18
|
+
// carried — the project's own lifecycle module holds and enforces them.
|
|
19
|
+
//
|
|
20
|
+
// HOW. One companion env line (SETTINGS_COMPANION_ENV, provisioning.js): base64url JSON
|
|
21
|
+
// { facts, rules }, or '' when the founder answered none of them (a project made before
|
|
22
|
+
// the startup flow, or a skip) — the project then says "not answered" rather than
|
|
23
|
+
// guessing. src/branding.js maps it onto the SERVER-ONLY founding.projectFacts.
|
|
24
|
+
//
|
|
25
|
+
// PURE — requires only soft-limits.js.
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const { SOFT_LIMIT_RULES } = require('./soft-limits');
|
|
30
|
+
|
|
31
|
+
// The answers that cross. Keep it to what a stage reads (the task's own rule): the
|
|
32
|
+
// legalize stage reads all six; nothing else about the project leaves the hub.
|
|
33
|
+
const FACT_KEYS = Object.freeze(['home_country', 'home_region', 'builder_geography', 'minors_on_board', 'entity', 'reward_intents']);
|
|
34
|
+
|
|
35
|
+
// The soft-limit rules a reward choice is shaped by: every rule naming a reward option.
|
|
36
|
+
const REWARD_RULES = Object.freeze(SOFT_LIMIT_RULES.filter((r) => r.options.some((o) => o.startsWith('reward_intents'))));
|
|
37
|
+
|
|
38
|
+
// projectFacts(detail) — PURE. The answered FACT_KEYS of a normalized project detail
|
|
39
|
+
// (provisioning.js projectDetail); an unanswered key is left out, never null-filled.
|
|
40
|
+
function projectFacts(detail) {
|
|
41
|
+
const d = detail && typeof detail === 'object' ? detail : {};
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const k of FACT_KEYS) if (d[k] !== undefined && d[k] !== null) out[k] = d[k];
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// projectFactsEnv(detail) — PURE. The companion line's value, or '' when nothing was answered.
|
|
48
|
+
function projectFactsEnv(detail) {
|
|
49
|
+
const facts = projectFacts(detail);
|
|
50
|
+
if (!Object.keys(facts).length) return '';
|
|
51
|
+
const rules = REWARD_RULES.map(({ id, when, options, effect, reason }) => ({ id, when, options, effect, reason }));
|
|
52
|
+
return Buffer.from(JSON.stringify({ facts, rules }), 'utf8').toString('base64url');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { REWARD_RULES, projectFactsEnv };
|
|
@@ -25,7 +25,7 @@ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundle
|
|
|
25
25
|
const { PLANET_PHYSICS_QUESTIONS, canonicalAnswer } = require('./planet-physics');
|
|
26
26
|
const { softLimitsFor } = require('./soft-limits'); const { demoView } = require('./demo'); const { isTestProject, TEST_COMPANION_ENV } = require('./test-project'); // the demo marker's owner view (task 1004420); the test mark (task 1005036)
|
|
27
27
|
const { dnsResolves, recordDnsResolved, lookupAnswers } = require('./dns-resolves'); // has the platform's lookup seen the address answer (task 1004505)
|
|
28
|
-
const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look'); const { BIRTH_COMPANION_ENV } = require('./birth'); const { GUIDE_COMPANION_ENV } = require('./guide-handoff'); // born into genesis (task 1004506); the guide's hand-off (task 1005274)
|
|
28
|
+
const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look'); const { BIRTH_COMPANION_ENV } = require('./birth'); const { GUIDE_COMPANION_ENV } = require('./guide-handoff'); const { projectFactsEnv } = require('./project-facts'); // born into genesis (task 1004506); the guide's hand-off (task 1005274)
|
|
29
29
|
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
// Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
|
|
@@ -514,6 +514,9 @@ function settingsEnvVars(row, prefix) {
|
|
|
514
514
|
// project that has been strict since it stood up.
|
|
515
515
|
const SETTINGS_COMPANION_ENV = Object.freeze({
|
|
516
516
|
ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, ...BIRTH_COMPANION_ENV, ...TEST_COMPANION_ENV, ...GUIDE_COMPANION_ENV, // + accent, logo (look.js); birth, demo box (birth.js); test mark (test-project.js); the guide's hand-off (guide-handoff.js)
|
|
517
|
+
// The project's own facts — where it is, where its builders are, minors, entity, reward intent — and
|
|
518
|
+
// the reward soft-limit rules, so genesis can state an option's implications (task 1005129, project-facts.js).
|
|
519
|
+
PROJECT_FACTS: (row) => projectFactsEnv(projectDetail(row)),
|
|
517
520
|
});
|
|
518
521
|
|
|
519
522
|
// The web.env block the runner writes at STANDUP (task 1003139): a comment that
|
|
@@ -18,6 +18,7 @@ const { appStatus } = require('./app-status');
|
|
|
18
18
|
// The sandbox's own database starts EMPTY: the platform cannot know an app's tables, so
|
|
19
19
|
// the fake data is the app's own seed, run when it sees BONGOS_SANDBOX=1 (the starter's
|
|
20
20
|
// half, task 1005291). Said to the owner plainly rather than promised.
|
|
21
|
+
const SANDBOX_PAGE = '/__bongos/sandbox';
|
|
21
22
|
const READY_DB_NOTE = 'It has its own database, separate from your live data. It starts empty: your app fills it with test data when it runs as a sandbox.';
|
|
22
23
|
|
|
23
24
|
// Why a project cannot have a sandbox right now, in the owner's words, or null.
|
|
@@ -52,6 +53,11 @@ function sandboxStatus(inst, { now = Date.now() } = {}) {
|
|
|
52
53
|
retry: undefined,
|
|
53
54
|
plan: 'free',
|
|
54
55
|
follows: 'main',
|
|
56
|
+
// The sandbox's own page (src/sandbox, task 1005291): what it refused, and what it cannot catch.
|
|
57
|
+
// The path is src/sandbox's PAGE; a module may not require core (ADR 0083), so it is
|
|
58
|
+
// spelled here once and pinned equal by tests/sandbox_gate.mjs. The key rides only this
|
|
59
|
+
// owner-only read.
|
|
60
|
+
safety_page: s.url ? s.url + SANDBOX_PAGE + (/^[0-9a-f]{48}$/.test(String(sb.pageKey || '')) ? `?key=${sb.pageKey}` : '') : null,
|
|
55
61
|
database: { state: sb.database || 'none', note: sb.dbNote || (sb.database === 'ready' ? READY_DB_NOTE : null) },
|
|
56
62
|
can_add: sb.state === 'failed' && !noSandboxReason(inst),
|
|
57
63
|
};
|