@bongos/core 1.25.20 → 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.
- package/.bongos-core.json +213 -78
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +18 -0
- package/clients/bongos-client/index.cjs +18 -0
- package/clients/bongos-client/index.d.ts +34 -8
- package/clients/bongos-client/index.mjs +18 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +18 -0
- package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
- package/docs/api/openapi.json +693 -125
- package/docs/api-reference.md +13 -4
- package/docs/copy-inventory.md +432 -427
- package/docs/copy-registry.json +571 -526
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +350 -344
- package/modules/government/board.js +8 -7
- package/modules/government/charter-advice.js +10 -3
- package/modules/government/charter.js +88 -4
- package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
- package/modules/government/routes/government.js +12 -12
- package/modules/government/sole-member.js +75 -0
- package/modules/hall-ui/public/genesis-home.js +12 -5
- package/modules/lifecycle/kickoff-checklist.js +1 -1
- package/modules/lifecycle/routes/pay-type-gates.js +7 -2
- package/modules/onboarding/genesis-home.js +14 -2
- package/modules/onboarding/project-facts.js +318 -0
- package/modules/onboarding/routes/genesis-work.js +16 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-standing.js +49 -0
- package/modules/platform-identity/routes/my-projects.js +8 -0
- package/modules/provisioning/grants.js +126 -0
- package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
- package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/project-facts.js +55 -0
- package/modules/provisioning/project-refusal.js +107 -0
- package/modules/provisioning/provisioning.js +6 -3
- package/modules/provisioning/render-lookup.js +36 -1
- package/modules/provisioning/render-promote.js +80 -0
- package/modules/provisioning/render-sandbox.js +6 -0
- package/modules/provisioning/route-helpers.js +70 -16
- package/modules/provisioning/routes/env-manifest.js +21 -5
- package/modules/provisioning/routes/grants.js +141 -0
- package/modules/provisioning/routes/provisioning.js +8 -7
- package/modules/provisioning/routes/render-standup.js +82 -3
- package/modules/public-landing/public/projects.html +290 -32
- package/package-lock.json +2 -2
- package/package.json +2 -1
- package/release-notes.json +32 -0
- package/scripts/bongos/genesis.js +49 -3
- package/scripts/bongos/government-walk-sole.js +41 -0
- package/scripts/bongos/government-walk.js +15 -16
- package/scripts/bongos/init.js +13 -0
- package/scripts/bongos/package-core.js +2 -0
- package/scripts/bongos/provision-render-common.js +48 -0
- package/scripts/bongos/provision-render-promote.js +93 -0
- package/scripts/bongos/provision-render-sandbox.js +19 -3
- package/scripts/bongos/provision-render.js +4 -36
- package/scripts/bongos/provision.js +1 -0
- package/scripts/bongos/render-api.js +17 -2
- package/scripts/bongos/render-payload.js +5 -2
- package/scripts/gds/government-walk-sole.js +6 -0
- package/scripts/gds/provision-render-common.js +6 -0
- package/scripts/gds/provision-render-promote.js +6 -0
- 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/fleet_cost_view.mjs +2 -0
- package/tests/folder_upload_project.mjs +396 -0
- package/tests/genesis_government.mjs +5 -1
- package/tests/genesis_home.mjs +17 -0
- package/tests/genesis_work_routes.mjs +25 -1
- package/tests/government_charter_advice.mjs +49 -0
- package/tests/government_charter_forms.mjs +14 -3
- package/tests/government_seed.mjs +1 -0
- package/tests/government_sole_member.mjs +153 -0
- package/tests/project_catalog_routes.mjs +2 -0
- 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_env_manifest.mjs +4 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
- package/tests/provisioning_project_grants.mjs +458 -0
- package/tests/provisioning_project_owner_gate.mjs +3 -1
- package/tests/provisioning_project_refusal.mjs +316 -0
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +2 -0
- package/tests/provisioning_settings.mjs +2 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_settings_env.mjs +2 -2
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/public_landing_projects.mjs +27 -5
- package/tests/render_promote.mjs +309 -0
- package/tests/render_sandbox.mjs +16 -8
- package/tests/sandbox_gate.mjs +182 -0
|
@@ -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
|
};
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
"migrations": true,
|
|
17
17
|
"pollers": ["code-sweep", "visibility-pull", "guild-totals"]
|
|
18
18
|
},
|
|
19
|
-
"provides": ["platform-identity.hub", "project.publishGate", "project.catalog"],
|
|
19
|
+
"provides": ["platform-identity.hub", "project.publishGate", "project.catalog", "platform-identity.projectMembershipKind"],
|
|
20
20
|
"consumes": ["ideas.capture"]
|
|
21
21
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// modules/platform-identity/project-standing.js — "what is this person's standing on this
|
|
2
|
+
// project?", for the WORDING of a refusal only (task 1004162, ADR 0407 §6).
|
|
3
|
+
//
|
|
4
|
+
// Published as the port `platform-identity.projectMembershipKind` so provisioning can tell a
|
|
5
|
+
// stranger (who keeps the 404 that confirms nothing) from someone already connected to the
|
|
6
|
+
// project (who is told what to do next), without importing this module (ADR 0083).
|
|
7
|
+
//
|
|
8
|
+
// IT NEVER ADMITS ANYONE, and must never be read to. A `member` row is written at every hub
|
|
9
|
+
// sign-in, including the device flow any GitHub user can start (ADR 0328 amendment), so no
|
|
10
|
+
// answer here is a grant. Admission is provisioning's own grant row (ADR 0407 §1-3).
|
|
11
|
+
//
|
|
12
|
+
// Answers, most specific first:
|
|
13
|
+
// 'owner' | 'member' | 'visitor' — a membership the hub itself witnessed (signin_verified_at)
|
|
14
|
+
// 'pending' — a live invitation from the project (inside its 30 days)
|
|
15
|
+
// 'applied' — no row, but this person asked to join inside the
|
|
16
|
+
// application window and the project took the request
|
|
17
|
+
// null — none of these: a stranger
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const api = require('../../src/module-api');
|
|
21
|
+
const { isNumericGithubId } = require('./platform-identity');
|
|
22
|
+
const { APPLICATION_ECHO_WINDOW_DAYS } = require('./application-echo');
|
|
23
|
+
|
|
24
|
+
const KINDS = Object.freeze(['owner', 'member', 'visitor', 'pending', 'applied']);
|
|
25
|
+
|
|
26
|
+
async function projectMembershipKind(clientId, githubId, { pool = api.pool } = {}) {
|
|
27
|
+
const client = String(clientId || '');
|
|
28
|
+
if (!client || !isNumericGithubId(githubId)) return null;
|
|
29
|
+
const { rows } = await pool.query(
|
|
30
|
+
`SELECT m.membership_kind
|
|
31
|
+
FROM platform_identity_project_memberships m
|
|
32
|
+
WHERE m.client_id = $1 AND m.github_id = $2::bigint
|
|
33
|
+
AND ( (m.membership_kind <> 'pending' AND m.signin_verified_at IS NOT NULL)
|
|
34
|
+
OR (m.membership_kind = 'pending' AND m.joined_at > now() - make_interval(days => $3::int)))
|
|
35
|
+
LIMIT 1`,
|
|
36
|
+
[client, String(githubId), api.inviteExpiry.INVITE_EXPIRY_DAYS],
|
|
37
|
+
);
|
|
38
|
+
if (rows[0] && KINDS.includes(rows[0].membership_kind)) return rows[0].membership_kind;
|
|
39
|
+
const { rows: asked } = await pool.query(
|
|
40
|
+
`SELECT 1 FROM platform_identity_application_echoes
|
|
41
|
+
WHERE github_id = $1::bigint AND client_id = $2
|
|
42
|
+
AND applied_at > now() - make_interval(days => $3::int)
|
|
43
|
+
LIMIT 1`,
|
|
44
|
+
[String(githubId), client, APPLICATION_ECHO_WINDOW_DAYS],
|
|
45
|
+
);
|
|
46
|
+
return asked.length ? 'applied' : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { projectMembershipKind };
|
|
@@ -27,6 +27,7 @@ const api = require('../../../src/module-api');
|
|
|
27
27
|
const pi = require('../platform-identity');
|
|
28
28
|
const projectStats = require('../project-stats');
|
|
29
29
|
const applicationEcho = require('../application-echo');
|
|
30
|
+
const projectStanding = require('../project-standing');
|
|
30
31
|
const guilds = require('../guilds');
|
|
31
32
|
const { isMintedPersonKey } = require('../account-logins');
|
|
32
33
|
const guildEngagements = require('../guild-engagements');
|
|
@@ -159,6 +160,13 @@ async function applicationVouchHeaders(client, builder) {
|
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
module.exports = function myProjectsRoutes() {
|
|
163
|
+
// PROVIDES port `platform-identity.projectMembershipKind` (task 1004162, ADR 0407 §6): a
|
|
164
|
+
// person's standing on one project, so provisioning can word a REFUSAL — never admit.
|
|
165
|
+
if (!api.hasProvider('platform-identity.projectMembershipKind')) {
|
|
166
|
+
api.registerProvider('platform-identity.projectMembershipKind', {
|
|
167
|
+
projectMembershipKind: (clientId, githubId) => projectStanding.projectMembershipKind(clientId, githubId, { pool: api.pool }),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
162
170
|
const router = express.Router();
|
|
163
171
|
|
|
164
172
|
// rank: any-builder — the caller's OWN cross-project memberships (own data).
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// modules/provisioning/grants.js — the owner-made grant to help run a hosted project, and its
|
|
2
|
+
// ONLY writer (task 1004161, ADR 0407).
|
|
3
|
+
//
|
|
4
|
+
// Why it exists: a sign-in on the hub writes a `member` membership row for any GitHub user,
|
|
5
|
+
// the device flow included, so membership can never admit anyone to a project's hosting
|
|
6
|
+
// (ADR 0328 amendment). A grant is the one thing that can: a row in
|
|
7
|
+
// `provisioning_project_grants` (migration provisioning_039) that only the project's OWNER
|
|
8
|
+
// creates, from their own hub session. Nothing a visitor sends, nothing a project reports at
|
|
9
|
+
// check-in, and no sign-in reaches this file.
|
|
10
|
+
//
|
|
11
|
+
// Every INSERT and UPDATE of that table lives here; tests/provisioning_project_grants.mjs
|
|
12
|
+
// fails if another file writes it. Who may call which function is decided by the routes
|
|
13
|
+
// (routes/grants.js); this file holds the data rules that must hold whoever calls.
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const GRANT_LEVELS = Object.freeze(['operate']);
|
|
17
|
+
const MAX_LIVE_GRANTS = 25;
|
|
18
|
+
|
|
19
|
+
const GRANT_COLUMNS = `g.id, g.instance_id, g.grantee_builder_id, g.level, g.granted_by_builder_id,
|
|
20
|
+
g.granted_at, g.revoked_at, g.revoked_by_builder_id`;
|
|
21
|
+
|
|
22
|
+
// The live grant one person holds on one project, or null. Read on every request a
|
|
23
|
+
// grant-admitting route serves (ADR 0016: authority is read fresh, never cached), so a revoke
|
|
24
|
+
// binds on the grantee's very next request.
|
|
25
|
+
async function liveGrant(db, instanceId, builderId) {
|
|
26
|
+
const { rows } = await db.query(
|
|
27
|
+
`SELECT ${GRANT_COLUMNS} FROM provisioning_project_grants g
|
|
28
|
+
WHERE g.instance_id = $1 AND g.grantee_builder_id = $2 AND g.revoked_at IS NULL
|
|
29
|
+
LIMIT 1`,
|
|
30
|
+
[instanceId, builderId],
|
|
31
|
+
);
|
|
32
|
+
return rows[0] || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The project's live grants, with who each one names — the owner's "People with access" list.
|
|
36
|
+
async function listLiveGrants(db, instanceId) {
|
|
37
|
+
const { rows } = await db.query(
|
|
38
|
+
`SELECT ${GRANT_COLUMNS}, b.github_login, b.display_name, b.avatar_url
|
|
39
|
+
FROM provisioning_project_grants g
|
|
40
|
+
JOIN builders b ON b.id = g.grantee_builder_id
|
|
41
|
+
WHERE g.instance_id = $1 AND g.revoked_at IS NULL
|
|
42
|
+
ORDER BY g.granted_at, g.id`,
|
|
43
|
+
[instanceId],
|
|
44
|
+
);
|
|
45
|
+
return rows;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The ACTIVE hub builder a GitHub login names, or null. The owner names a person by login;
|
|
49
|
+
// the grant stores the builder id, so a later login rename cannot move the grant to someone else.
|
|
50
|
+
async function findGranteeByLogin(db, login) {
|
|
51
|
+
const { rows } = await db.query(
|
|
52
|
+
`SELECT id, github_login, display_name, avatar_url FROM builders
|
|
53
|
+
WHERE lower(github_login) = lower($1) AND status IS DISTINCT FROM 'inactive'
|
|
54
|
+
LIMIT 1`,
|
|
55
|
+
[login],
|
|
56
|
+
);
|
|
57
|
+
return rows[0] || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Make a grant. Returns { grant } on success or { refused: code } — never throws for a rule.
|
|
61
|
+
// Rules held here: a known level only; never the project's own owner; at most MAX_LIVE_GRANTS
|
|
62
|
+
// live grants per project; one live grant per person (an existing one is returned as is).
|
|
63
|
+
// The count and the insert run under the project row's lock, so two requests cannot both
|
|
64
|
+
// take the 25th place.
|
|
65
|
+
async function grantProjectAccess(db, { instanceId, granteeBuilderId, level = 'operate', grantedByBuilderId }) {
|
|
66
|
+
if (!GRANT_LEVELS.includes(level)) return { refused: 'bad_level' };
|
|
67
|
+
const client = typeof db.connect === 'function' ? await db.connect() : db;
|
|
68
|
+
const owned = client !== db;
|
|
69
|
+
try {
|
|
70
|
+
await client.query('BEGIN');
|
|
71
|
+
const { rows: inst } = await client.query(
|
|
72
|
+
'SELECT id, owner_builder_id FROM provisioning_instances WHERE id = $1 FOR UPDATE', [instanceId]);
|
|
73
|
+
if (!inst[0]) { await client.query('ROLLBACK'); return { refused: 'instance_not_found' }; }
|
|
74
|
+
if (String(inst[0].owner_builder_id) === String(granteeBuilderId)) {
|
|
75
|
+
await client.query('ROLLBACK');
|
|
76
|
+
return { refused: 'grantee_is_owner' };
|
|
77
|
+
}
|
|
78
|
+
const existing = await liveGrant(client, instanceId, granteeBuilderId);
|
|
79
|
+
if (existing) { await client.query('ROLLBACK'); return { grant: existing, created: false }; }
|
|
80
|
+
const { rows: n } = await client.query(
|
|
81
|
+
`SELECT count(*)::int AS n FROM provisioning_project_grants
|
|
82
|
+
WHERE instance_id = $1 AND revoked_at IS NULL`, [instanceId]);
|
|
83
|
+
if (n[0].n >= MAX_LIVE_GRANTS) { await client.query('ROLLBACK'); return { refused: 'grant_limit' }; }
|
|
84
|
+
const { rows } = await client.query(
|
|
85
|
+
`INSERT INTO provisioning_project_grants (instance_id, grantee_builder_id, level, granted_by_builder_id)
|
|
86
|
+
VALUES ($1, $2, $3, $4)
|
|
87
|
+
RETURNING id, instance_id, grantee_builder_id, level, granted_by_builder_id, granted_at, revoked_at, revoked_by_builder_id`,
|
|
88
|
+
[instanceId, granteeBuilderId, level, grantedByBuilderId],
|
|
89
|
+
);
|
|
90
|
+
await client.query('COMMIT');
|
|
91
|
+
return { grant: rows[0], created: true };
|
|
92
|
+
} catch (err) {
|
|
93
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
94
|
+
throw err;
|
|
95
|
+
} finally {
|
|
96
|
+
if (owned) client.release();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Revoke one live grant of this project. Returns the revoked row, or null when there is no
|
|
101
|
+
// such live grant (already revoked, another project's id, or none). A stamp, never a delete.
|
|
102
|
+
async function revokeProjectAccess(db, { instanceId, grantId, revokedByBuilderId }) {
|
|
103
|
+
const { rows } = await db.query(
|
|
104
|
+
`UPDATE provisioning_project_grants
|
|
105
|
+
SET revoked_at = now(), revoked_by_builder_id = $3
|
|
106
|
+
WHERE id = $1 AND instance_id = $2 AND revoked_at IS NULL
|
|
107
|
+
RETURNING id, instance_id, grantee_builder_id, level, granted_by_builder_id, granted_at, revoked_at, revoked_by_builder_id`,
|
|
108
|
+
[grantId, instanceId, revokedByBuilderId],
|
|
109
|
+
);
|
|
110
|
+
return rows[0] || null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// One grant of this project by id, live or not — the revoke route reads it to decide who may
|
|
114
|
+
// revoke (the grantee may revoke their own).
|
|
115
|
+
async function getGrant(db, instanceId, grantId) {
|
|
116
|
+
const { rows } = await db.query(
|
|
117
|
+
`SELECT ${GRANT_COLUMNS} FROM provisioning_project_grants g WHERE g.id = $1 AND g.instance_id = $2`,
|
|
118
|
+
[grantId, instanceId],
|
|
119
|
+
);
|
|
120
|
+
return rows[0] || null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
GRANT_LEVELS, MAX_LIVE_GRANTS,
|
|
125
|
+
liveGrant, listLiveGrants, findGranteeByLogin, grantProjectAccess, revokeProjectAccess, getGrant,
|
|
126
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- provisioning_038_render_promote.sql — a person promotes what ran on the sandbox to the
|
|
2
|
+
-- live app, and every promotion is recorded (task 1005290, ADR 0406 §2).
|
|
3
|
+
--
|
|
4
|
+
-- WHY. Once a project has a sandbox, finished work lands on the sandbox only: the live
|
|
5
|
+
-- Render service stops deploying on every merge, and moves only when a person presses
|
|
6
|
+
-- promote. That press queues a `render-promote` intent carrying the owner's borrowed key
|
|
7
|
+
-- (ADR 0327 §2), and the runner records the promotion BEFORE it asks Render to deploy, so
|
|
8
|
+
-- nothing reaches the live app through Bongos without a row here saying who, which change,
|
|
9
|
+
-- and what came of it.
|
|
10
|
+
--
|
|
11
|
+
-- Additive and namespaced (ADR 0083): one new action on the intent CHECK, one new table.
|
|
12
|
+
-- Idempotent — safe to re-run.
|
|
13
|
+
|
|
14
|
+
BEGIN;
|
|
15
|
+
|
|
16
|
+
ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
|
|
17
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
|
|
18
|
+
CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach',
|
|
19
|
+
'settings-apply', 'restart', 'disconnect', 'core-upgrade',
|
|
20
|
+
'core-upgrade-preflight', 'repo-private', 'render-standup',
|
|
21
|
+
'render-promote'));
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS provisioning_promotions (
|
|
24
|
+
id bigserial PRIMARY KEY,
|
|
25
|
+
instance_id bigint NOT NULL REFERENCES provisioning_instances(id) ON DELETE CASCADE,
|
|
26
|
+
commit_id text NOT NULL,
|
|
27
|
+
commit_message text,
|
|
28
|
+
from_commit_id text,
|
|
29
|
+
sandbox_deploy_id text,
|
|
30
|
+
render_deploy_id text,
|
|
31
|
+
requested_by text,
|
|
32
|
+
state text NOT NULL DEFAULT 'started' CHECK (state IN ('started', 'sent', 'failed')),
|
|
33
|
+
error text,
|
|
34
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
35
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS ix_provisioning_promotions_instance ON provisioning_promotions (instance_id, created_at DESC);
|
|
39
|
+
|
|
40
|
+
COMMENT ON TABLE provisioning_promotions IS
|
|
41
|
+
'Every promotion of a sandbox change to a project''s live Render app (task 1005290, ADR 0406): who asked, which commit, from which, and what Render answered. Written by the runner before it deploys.';
|
|
42
|
+
|
|
43
|
+
COMMIT;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- provisioning_039_project_grants.sql — an owner-made grant to help run a hosted project
|
|
2
|
+
-- (task 1004161, ADR 0407). Landed as provisioning_038 beside render_promote's 038; renumbered
|
|
3
|
+
-- by task 1004162 so the series has one migration per number. Every statement is IF NOT EXISTS,
|
|
4
|
+
-- so a database that already ran it under the old name runs it again harmlessly.
|
|
5
|
+
--
|
|
6
|
+
-- WHY. A hosted project's provisioning routes admitted only its owner (or an archon).
|
|
7
|
+
-- ADR 0328 Phase 2 would have admitted a `member` row of platform_identity_project_memberships,
|
|
8
|
+
-- but the hub writes that row at every sign-in — the device flow any GitHub user can start
|
|
9
|
+
-- included — so it is not a grant (ADR 0328 amendment, ADR 0407). This table is the grant:
|
|
10
|
+
-- made on purpose by the project's owner from their own hub session, stored apart from every
|
|
11
|
+
-- sign-in and check-in path, with one writer (modules/provisioning/grants.js).
|
|
12
|
+
--
|
|
13
|
+
-- A revoke is a stamp, never a delete, so the record survives. One LIVE grant per person per
|
|
14
|
+
-- project (the partial unique index); granting again after a revoke makes a new row. Deleting
|
|
15
|
+
-- the project row (disconnect) takes its grants with it.
|
|
16
|
+
--
|
|
17
|
+
-- `level` is a closed list. 'operate' is the only level (ADR 0407 §3); a second one is an ADR
|
|
18
|
+
-- amendment and a new migration, never a free string.
|
|
19
|
+
--
|
|
20
|
+
-- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
|
|
21
|
+
|
|
22
|
+
BEGIN;
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS provisioning_project_grants (
|
|
25
|
+
id bigserial PRIMARY KEY,
|
|
26
|
+
instance_id bigint NOT NULL REFERENCES provisioning_instances(id) ON DELETE CASCADE,
|
|
27
|
+
grantee_builder_id bigint NOT NULL REFERENCES builders(id) ON DELETE CASCADE,
|
|
28
|
+
level text NOT NULL CHECK (level IN ('operate')),
|
|
29
|
+
granted_by_builder_id bigint NOT NULL REFERENCES builders(id),
|
|
30
|
+
granted_at timestamptz NOT NULL DEFAULT now(),
|
|
31
|
+
revoked_at timestamptz,
|
|
32
|
+
revoked_by_builder_id bigint REFERENCES builders(id),
|
|
33
|
+
CHECK ((revoked_at IS NULL) = (revoked_by_builder_id IS NULL))
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ux_provisioning_project_grants_live
|
|
37
|
+
ON provisioning_project_grants (instance_id, grantee_builder_id)
|
|
38
|
+
WHERE revoked_at IS NULL;
|
|
39
|
+
|
|
40
|
+
COMMIT;
|