@bongos/core 1.25.21 → 1.25.22

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.
Files changed (46) hide show
  1. package/.bongos-core.json +84 -44
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +11 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
  8. package/docs/api/openapi.json +198 -3
  9. package/docs/api-reference.md +7 -3
  10. package/docs/copy-inventory.md +412 -407
  11. package/docs/copy-registry.json +548 -503
  12. package/docs/file-map.md +1 -0
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/page-readings.json +349 -343
  15. package/modules/lifecycle/kickoff-checklist.js +1 -1
  16. package/modules/lifecycle/routes/pay-type-gates.js +7 -2
  17. package/modules/onboarding/project-facts.js +318 -0
  18. package/modules/onboarding/routes/genesis-work.js +16 -0
  19. package/modules/provisioning/project-facts.js +55 -0
  20. package/modules/provisioning/provisioning.js +4 -1
  21. package/modules/provisioning/render-sandbox.js +6 -0
  22. package/modules/public-landing/public/projects.html +191 -31
  23. package/package-lock.json +2 -2
  24. package/package.json +2 -1
  25. package/release-notes.json +14 -0
  26. package/scripts/bongos/genesis.js +38 -2
  27. package/scripts/bongos/init.js +13 -0
  28. package/scripts/bongos/package-core.js +2 -0
  29. package/scripts/bongos/provision-render-sandbox.js +5 -1
  30. package/scripts/bongos/render-payload.js +5 -2
  31. package/src/bongos/folder-upload.js +243 -0
  32. package/src/bongos/routes/github.js +109 -16
  33. package/src/bongos/routes.js +3 -0
  34. package/src/branding.js +4 -0
  35. package/src/module-api.js +1 -1
  36. package/src/sandbox/gate.js +164 -0
  37. package/src/sandbox/index.js +144 -0
  38. package/tests/folder_upload_project.mjs +396 -0
  39. package/tests/genesis_work_routes.mjs +25 -1
  40. package/tests/project_facts.mjs +158 -0
  41. package/tests/projects_hub.mjs +1 -1
  42. package/tests/provision_settings_apply.mjs +2 -1
  43. package/tests/provisioning_settings_env.mjs +2 -2
  44. package/tests/public_landing_projects.mjs +27 -5
  45. package/tests/render_sandbox.mjs +7 -4
  46. package/tests/sandbox_gate.mjs +182 -0
@@ -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', { refuseUnclearedGates: gates.refuseUnclearedGates });
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
  };