@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,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 };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// modules/provisioning/project-refusal.js — what a person the project gate REFUSED is told
|
|
2
|
+
// (task 1004162, ADR 0407 §6, replacing ADR 0328 decision 3).
|
|
3
|
+
//
|
|
4
|
+
// Called only after requireProjectOwner has already said no. It can never admit: it never
|
|
5
|
+
// calls next() and never sets req.project. It only picks the wording of the refusal, so that
|
|
6
|
+
// someone already connected to the project gets a next step instead of a dead end, while a
|
|
7
|
+
// stranger still learns nothing.
|
|
8
|
+
//
|
|
9
|
+
// no connection at all → 404 instance_not_found (as before)
|
|
10
|
+
// holds a grant, route not open to grantees → 403 owner_only
|
|
11
|
+
// 'visitor' (the project knows them) → 403 project_membership_required + how to apply
|
|
12
|
+
// 'pending' (the project invited them) → 403 project_membership_required + accept it
|
|
13
|
+
// 'applied' (asked to join inside 30 days) → 403 project_membership_required + it's with the owner
|
|
14
|
+
// 'member' / 'owner' row, PUBLIC project → 403 project_access_required (ask the owner)
|
|
15
|
+
// 'member' / 'owner' row, private or stealth → 404 — a `member` row can be had by anyone who
|
|
16
|
+
// runs the device flow (ADR 0328 amendment), so
|
|
17
|
+
// it is no evidence they may learn a closed
|
|
18
|
+
// project's id belongs to it.
|
|
19
|
+
//
|
|
20
|
+
// Any doubt — the standing port missing, throwing, or answering something unknown, or the
|
|
21
|
+
// grant read failing — is the 404: on doubt, say less.
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const api = require('../../src/module-api');
|
|
25
|
+
const provisioning = require('./provisioning');
|
|
26
|
+
const grants = require('./grants');
|
|
27
|
+
|
|
28
|
+
const log = api.logger('provisioning');
|
|
29
|
+
|
|
30
|
+
const STANDING_PORT = 'platform-identity.projectMembershipKind';
|
|
31
|
+
const HOSTING_IS_SEPARATE = 'Managing its hosting is something the project’s owner gives separately.';
|
|
32
|
+
|
|
33
|
+
function notFound(res) {
|
|
34
|
+
return res.fail('instance_not_found', 404);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function projectOrigin(inst) {
|
|
38
|
+
return inst.domain ? `https://${inst.domain}` : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The project's join door, as its owner set it: only a PUBLIC project may confirm itself to a
|
|
42
|
+
// person whose only link is a sign-in.
|
|
43
|
+
function isPublicProject(inst) {
|
|
44
|
+
try {
|
|
45
|
+
return provisioning.effectiveSettings(inst).visibility === 'public';
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function refuseProject(req, res, inst) {
|
|
52
|
+
let grant = null;
|
|
53
|
+
try {
|
|
54
|
+
grant = await grants.liveGrant(api.pool, inst.id, req.builder.id);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
log.error(`[provisioning] ${req.method} ${req.path} — refusal grant read`, err);
|
|
57
|
+
return notFound(res);
|
|
58
|
+
}
|
|
59
|
+
if (grant) {
|
|
60
|
+
return res.fail('owner_only', { status: 403, message: 'Only the project’s owner can do this.' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const port = api.resolveOptional(STANDING_PORT);
|
|
64
|
+
if (!port || typeof port.projectMembershipKind !== 'function') return notFound(res);
|
|
65
|
+
let kind;
|
|
66
|
+
try {
|
|
67
|
+
kind = await port.projectMembershipKind(inst.slug, req.builder.github_id);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
log.error(`[provisioning] ${req.method} ${req.path} — standing read`, err);
|
|
70
|
+
return notFound(res);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const origin = projectOrigin(inst);
|
|
74
|
+
switch (kind) {
|
|
75
|
+
case 'visitor':
|
|
76
|
+
return res.fail('project_membership_required', {
|
|
77
|
+
status: 403,
|
|
78
|
+
message: `You’re not a member of this project yet. Ask to join it, and its owner decides. ${HOSTING_IS_SEPARATE}`,
|
|
79
|
+
details: { apply: origin
|
|
80
|
+
? { state: 'can_apply', method: 'POST', path: '/api/bongos/my-projects/join', body: { origin } }
|
|
81
|
+
: { state: 'can_apply' } },
|
|
82
|
+
});
|
|
83
|
+
case 'pending':
|
|
84
|
+
return res.fail('project_membership_required', {
|
|
85
|
+
status: 403,
|
|
86
|
+
message: `This project has invited you. Sign in to it to accept. ${HOSTING_IS_SEPARATE}`,
|
|
87
|
+
details: { apply: { state: 'invited', sign_in: origin } },
|
|
88
|
+
});
|
|
89
|
+
case 'applied':
|
|
90
|
+
return res.fail('project_membership_required', {
|
|
91
|
+
status: 403,
|
|
92
|
+
message: `You’ve asked to join this project, and its owner decides. ${HOSTING_IS_SEPARATE}`,
|
|
93
|
+
details: { apply: { state: 'applied' } },
|
|
94
|
+
});
|
|
95
|
+
case 'member':
|
|
96
|
+
case 'owner':
|
|
97
|
+
if (!isPublicProject(inst)) return notFound(res);
|
|
98
|
+
return res.fail('project_access_required', {
|
|
99
|
+
status: 403,
|
|
100
|
+
message: 'Only the project’s owner, and people the owner has given access, can manage its hosting. Ask the owner to give you access.',
|
|
101
|
+
});
|
|
102
|
+
default:
|
|
103
|
+
return notFound(res);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { refuseProject };
|
|
@@ -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)
|
|
@@ -66,7 +66,7 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
|
|
|
66
66
|
// 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
|
|
67
67
|
// 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
|
|
68
68
|
// deploy door. The web tier only QUEUES it; the control-plane runner performs it.
|
|
69
|
-
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight', 'repo-private', 'render-standup'];
|
|
69
|
+
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight', 'repo-private', 'render-standup', 'render-promote'];
|
|
70
70
|
// Every state a teardown can be asked for — i.e. everything except the one
|
|
71
71
|
// state where there is provably nothing left to release. DERIVED from
|
|
72
72
|
// INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
|
|
@@ -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
|
|
@@ -1192,7 +1195,7 @@ async function enqueueTeardown(db, instanceId, requestedBy) {
|
|
|
1192
1195
|
`UPDATE provisioning_intents
|
|
1193
1196
|
SET state = 'error', last_error = 'superseded: teardown requested', borrowed_key_enc = NULL,
|
|
1194
1197
|
updated_at = now(), resolved_at = now()
|
|
1195
|
-
WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup') AND state = 'pending'
|
|
1198
|
+
WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup', 'render-promote') AND state = 'pending'
|
|
1196
1199
|
AND NOT EXISTS (SELECT 1 FROM provisioning_instances WHERE id = $1 AND hosting_shape = 'control-plane') RETURNING id`,
|
|
1197
1200
|
[instanceId]
|
|
1198
1201
|
);
|
|
@@ -65,4 +65,39 @@ async function lookupRender(key, { ownerId = null, fetchImpl = globalThis.fetch
|
|
|
65
65
|
return { workspaces, services };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
// WHAT IS WAITING TO GO LIVE (task 1005290, ADR 0406 §2; the ADR 0406 §11 amendment widens
|
|
69
|
+
// ADR 0348 by exactly this): the owner's two recorded services — their live app and its
|
|
70
|
+
// sandbox — and the deploys of each. GET only, the same key rules as above, every service
|
|
71
|
+
// asserted to carry the workspace. Nothing here can promote; promoting is the runner's
|
|
72
|
+
// (scripts/bongos/provision-render-promote.js), on a key lent with the press.
|
|
73
|
+
const COMMIT_RE = /^[0-9a-f]{7,40}$/;
|
|
74
|
+
const SERVED = ['live', 'deactivated'];
|
|
75
|
+
function shapeDeploy(d) {
|
|
76
|
+
const c = (d && d.commit) || {};
|
|
77
|
+
return {
|
|
78
|
+
deploy_id: d.id,
|
|
79
|
+
commit_id: COMMIT_RE.test(String(c.id || '')) ? c.id : null,
|
|
80
|
+
short: String(c.id || '').slice(0, 7),
|
|
81
|
+
message: String(c.message || '').split('\n')[0].slice(0, 200),
|
|
82
|
+
at: (c.createdAt || d.createdAt) || null,
|
|
83
|
+
status: d.status || null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function lookupPromotion(key, { ownerId, liveServiceId, sandboxServiceId, fetchImpl = globalThis.fetch }) {
|
|
87
|
+
for (const id of [liveServiceId, sandboxServiceId]) {
|
|
88
|
+
const s = await get(`/services/${encodeURIComponent(id)}`, key, fetchImpl);
|
|
89
|
+
if (!s || s.ownerId !== ownerId) throw new RenderLookupError('render_owner_mismatch', 'Render answered from another workspace, so nothing was shown.');
|
|
90
|
+
}
|
|
91
|
+
const deploys = async (id, n) => unwrap(await get(`/services/${encodeURIComponent(id)}/deploys?limit=${n}`, key, fetchImpl), 'deploy').map(shapeDeploy);
|
|
92
|
+
const live = await deploys(liveServiceId, 10);
|
|
93
|
+
const sandbox = await deploys(sandboxServiceId, 30);
|
|
94
|
+
const liveNow = live.find((d) => d.status === 'live') || null;
|
|
95
|
+
// Waiting = what served on the sandbox since the change live runs, newest first. If live's
|
|
96
|
+
// change is not in the sandbox's list (older than it reaches), everything listed waits.
|
|
97
|
+
const served = sandbox.filter((d) => SERVED.includes(d.status) && d.commit_id);
|
|
98
|
+
const at = liveNow ? served.findIndex((d) => d.commit_id === liveNow.commit_id) : -1;
|
|
99
|
+
const waiting = (at === -1 ? served : served.slice(0, at)).filter((d, i, all) => all.findIndex((x) => x.commit_id === d.commit_id) === i);
|
|
100
|
+
return { live: liveNow, waiting };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { lookupRender, lookupPromotion, RenderLookupError, RENDER_API };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// modules/provisioning/render-promote.js — the web tier's half of PROMOTE: a person moves
|
|
2
|
+
// what ran on the project's sandbox to its live app (task 1005290; ADR 0406 §2, ADR 0327).
|
|
3
|
+
//
|
|
4
|
+
// WHY. Finished work lands on the sandbox (it follows `main`); the live app moves only
|
|
5
|
+
// when a person presses promote — the project law "Land only. Ask before going live". This
|
|
6
|
+
// file queues that press (a `render-promote` intent holding the owner's encrypted key, and
|
|
7
|
+
// the chosen change on the row, in ONE statement), reads back the record of promotions,
|
|
8
|
+
// and shapes what the owner is told. It never calls Render: the read-only look at what is
|
|
9
|
+
// waiting is render-lookup.js (ADR 0348 as widened by ADR 0406 §11), and the promotion
|
|
10
|
+
// itself is the runner's (scripts/bongos/provision-render-promote.js).
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const COMMIT_RE = /^[0-9a-f]{7,40}$/;
|
|
14
|
+
|
|
15
|
+
// Why this project cannot promote right now, in the owner's words, or null.
|
|
16
|
+
function noPromoteReason(inst) {
|
|
17
|
+
const h = (inst && inst.app_host) || {};
|
|
18
|
+
if (!inst || inst.status === 'torn_down' || inst.status === 'tearing_down') return 'This project has been removed.';
|
|
19
|
+
if (h.platform !== 'render' || !h.serviceId) return 'Your app is not set up on Render, so there is nothing to promote to.';
|
|
20
|
+
if (!h.sandbox || !h.sandbox.serviceId || ['removed', 'left', 'failed'].includes(h.sandbox.state)) return 'This project has no sandbox, so there is nothing to promote from. Add one first.';
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Queue a promotion. Returns { intent } or { conflict: 'open_intent', intent }.
|
|
25
|
+
async function enqueuePromote(db, instanceId, { keyEnc, commitId, expectLiveCommit, requestedBy }) {
|
|
26
|
+
await require('./control-plane-guard').assertIntentAllowed(db, instanceId, 'render-promote');
|
|
27
|
+
const ask = { state: 'requested', commitId, expectLiveCommit: expectLiveCommit || null, requestedBy: requestedBy == null ? null : String(requestedBy), requestedAt: new Date().toISOString(), error: null };
|
|
28
|
+
try {
|
|
29
|
+
const { rows } = await db.query(
|
|
30
|
+
`WITH i AS (
|
|
31
|
+
INSERT INTO provisioning_intents (instance_id, action, requested_by, state, borrowed_key_enc)
|
|
32
|
+
VALUES ($1, 'render-promote', $2, 'pending', $3) RETURNING *
|
|
33
|
+
), u AS (
|
|
34
|
+
UPDATE provisioning_instances SET app_host = app_host || jsonb_build_object('promote', $4::jsonb)
|
|
35
|
+
WHERE id = $1 RETURNING id
|
|
36
|
+
)
|
|
37
|
+
SELECT i.id, i.instance_id, i.action, i.state, i.created_at FROM i`,
|
|
38
|
+
[instanceId, 'api:self', keyEnc, JSON.stringify(ask)]);
|
|
39
|
+
return { intent: rows[0] };
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (e && e.code === '23505') {
|
|
42
|
+
const { rows } = await db.query(
|
|
43
|
+
`SELECT id, action, state FROM provisioning_intents
|
|
44
|
+
WHERE instance_id = $1 AND state IN ('pending', 'running') ORDER BY created_at DESC LIMIT 1`, [instanceId]);
|
|
45
|
+
return { conflict: 'open_intent', intent: rows[0] || { action: 'unknown', state: 'open' } };
|
|
46
|
+
}
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The project's recent promotions, newest first: what went live, when, by whose press.
|
|
52
|
+
async function listPromotions(db, instanceId, limit = 5) {
|
|
53
|
+
const { rows } = await db.query(
|
|
54
|
+
`SELECT commit_id, commit_message, from_commit_id, state, error, created_at
|
|
55
|
+
FROM provisioning_promotions WHERE instance_id = $1 ORDER BY created_at DESC LIMIT $2`, [instanceId, limit]);
|
|
56
|
+
return (rows || []).filter((r) => r && r.commit_id).map((r) => ({
|
|
57
|
+
commit: String(r.commit_id).slice(0, 7),
|
|
58
|
+
message: r.commit_message || null,
|
|
59
|
+
from: r.from_commit_id ? String(r.from_commit_id).slice(0, 7) : null,
|
|
60
|
+
state: r.state,
|
|
61
|
+
error: r.state === 'failed' ? (r.error || 'It did not go through.') : null,
|
|
62
|
+
at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// What the owner is told about promoting: whether they can, whether the live app still
|
|
67
|
+
// deploys every merge, and where the last press stands.
|
|
68
|
+
function promoteView(inst) {
|
|
69
|
+
const h = (inst && inst.app_host) || {};
|
|
70
|
+
const why = noPromoteReason(inst);
|
|
71
|
+
const p = h.promote || null;
|
|
72
|
+
return {
|
|
73
|
+
can: !why,
|
|
74
|
+
reason: why,
|
|
75
|
+
live_follows_main: h.liveAutoDeploy !== 'off',
|
|
76
|
+
last: p ? { state: p.state, commit: p.commitId ? String(p.commitId).slice(0, 7) : null, error: p.state === 'failed' ? (p.error || null) : null } : null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { noPromoteReason, enqueuePromote, listPromotions, promoteView, COMMIT_RE };
|
|
@@ -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
|
};
|
|
@@ -1,34 +1,72 @@
|
|
|
1
|
-
// modules/provisioning/route-helpers.js — the one
|
|
1
|
+
// modules/provisioning/route-helpers.js — the one gate every own-scoped
|
|
2
2
|
// /provisioning/instances/:id route mounts (task 1004159, ADR 0328 decision 2). It replaced
|
|
3
3
|
// three hand-written copies of the same rule, and the three routes that had only the owner
|
|
4
|
-
// half of it (teardown, domain, github-app).
|
|
4
|
+
// half of it (teardown, domain, github-app). Since task 1004161 (ADR 0407) a route may also
|
|
5
|
+
// admit a person the project's OWNER granted access to, by opting in — never by default.
|
|
5
6
|
'use strict';
|
|
6
7
|
|
|
7
8
|
const api = require('../../src/module-api');
|
|
8
9
|
const { failFrom } = require('./public-refusal');
|
|
10
|
+
const grants = require('./grants');
|
|
11
|
+
const { refuseProject } = require('./project-refusal');
|
|
9
12
|
|
|
10
13
|
const log = api.logger('provisioning');
|
|
11
14
|
|
|
15
|
+
// The grant levels a route may opt into. One today (ADR 0407 §3).
|
|
16
|
+
const GRANTEE_LEVELS = Object.freeze(['operate']);
|
|
17
|
+
|
|
12
18
|
function isProjectOwner(inst, builder) {
|
|
13
19
|
return String(inst.owner_builder_id) === String(builder.id);
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
//
|
|
17
|
-
//
|
|
22
|
+
// Which door admitted this request: 'owner' · 'grantee' · 'archon'. Set by the gate; for a
|
|
23
|
+
// request the gate did not see, decided from the row the old way (owner, else archon).
|
|
24
|
+
function projectDoor(req) {
|
|
25
|
+
if (req.projectDoor) return req.projectDoor;
|
|
26
|
+
return isProjectOwner(req.project, req.builder) ? 'owner' : 'archon';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The audit actor for a request the gate admitted: the owner acts as themselves, and a grantee
|
|
30
|
+
// or an archon is named, so neither ever reads as the owner's act (ADR 0407 §4).
|
|
18
31
|
function projectActor(req) {
|
|
19
|
-
|
|
32
|
+
const door = projectDoor(req);
|
|
33
|
+
if (door === 'owner') return 'api:self';
|
|
34
|
+
return `api:${door}:${req.builder.id}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The same, in words, for an event's detail line: "owner", "grantee <login>", "archon <login>".
|
|
38
|
+
function projectActorLabel(req) {
|
|
39
|
+
const door = projectDoor(req);
|
|
40
|
+
return door === 'owner' ? 'owner' : `${door} ${req.builder.github_login || req.builder.id}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// What a grantee is shown of the project read (ADR 0407 §3): everything the owner sees except
|
|
44
|
+
// the owner's own hosting bill and the GitHub App steps, which act on the owner's GitHub
|
|
45
|
+
// account. The owner and an archon get the body unchanged.
|
|
46
|
+
function grantedView(req, body) {
|
|
47
|
+
if (projectDoor(req) !== 'grantee') return body;
|
|
48
|
+
const { owner_monthly_usd, github_install, ...instance } = body.instance || {};
|
|
49
|
+
return { ...body, instance, github_app: null, github_setup: null };
|
|
20
50
|
}
|
|
21
51
|
|
|
22
|
-
// requireProjectOwner(loadFn, { archonOverride }) — mounted after requireBuilder, and
|
|
23
|
-
// in the route's own middleware list so route-rank-check.js can see it (ADR 0328 §4).
|
|
24
|
-
// the row with the caller's loader (`loadFn(id)`) and admits
|
|
25
|
-
//
|
|
26
|
-
//
|
|
52
|
+
// requireProjectOwner(loadFn, { archonOverride, grantee }) — mounted after requireBuilder, and
|
|
53
|
+
// spelled in the route's own middleware list so route-rank-check.js can see it (ADR 0328 §4).
|
|
54
|
+
// Loads the row with the caller's loader (`loadFn(id)`) and admits, in this order:
|
|
55
|
+
// 1. its owner;
|
|
56
|
+
// 2. when the route opts in with `grantee: 'operate'`, a person holding a LIVE grant of that
|
|
57
|
+
// level on this project — read from provisioning_project_grants on every request, never a
|
|
58
|
+
// membership row (ADR 0407 §§1-3);
|
|
59
|
+
// 3. by default, an archon — a plain RANK test on purpose: `provisioning.fleet.manage`
|
|
60
|
+
// floors at metic (ADR 0157), so gating on it would widen every route it guards.
|
|
27
61
|
//
|
|
28
62
|
// A missing row and someone else's row are the same 404, and the gate runs before the
|
|
29
63
|
// handler's body validator, so a stranger cannot tell a real id from a malformed request.
|
|
30
|
-
//
|
|
31
|
-
|
|
64
|
+
// A grant read that fails DENIES (the opaque 500), never falls through to admit.
|
|
65
|
+
// The loaded row rides on `req.project`; the door that admitted it on `req.projectDoor`.
|
|
66
|
+
function requireProjectOwner(loadFn, { archonOverride = true, grantee = null } = {}) {
|
|
67
|
+
if (grantee !== null && !GRANTEE_LEVELS.includes(grantee)) {
|
|
68
|
+
throw new Error(`requireProjectOwner: unknown grantee level ${grantee}`);
|
|
69
|
+
}
|
|
32
70
|
return async function projectOwnerGate(req, res, next) {
|
|
33
71
|
const id = api.parseId(req, res); // sends 400 bad_id + returns null on failure
|
|
34
72
|
if (!id) return;
|
|
@@ -39,12 +77,28 @@ function requireProjectOwner(loadFn, { archonOverride = true } = {}) {
|
|
|
39
77
|
log.error(`[provisioning] ${req.method} ${req.path} — project load`, err);
|
|
40
78
|
return failFrom(res, err, 'instance_read_failed');
|
|
41
79
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
80
|
+
if (!inst) return res.fail('instance_not_found', 404);
|
|
81
|
+
let door = null;
|
|
82
|
+
if (isProjectOwner(inst, req.builder)) door = 'owner';
|
|
83
|
+
if (!door && grantee) {
|
|
84
|
+
let grant;
|
|
85
|
+
try {
|
|
86
|
+
grant = await grants.liveGrant(api.pool, inst.id, req.builder.id);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
log.error(`[provisioning] ${req.method} ${req.path} — grant read`, err);
|
|
89
|
+
return failFrom(res, err, 'instance_read_failed');
|
|
90
|
+
}
|
|
91
|
+
if (grant && grant.level === grantee) door = 'grantee';
|
|
92
|
+
}
|
|
93
|
+
if (!door && archonOverride && req.builder.rank === 'archon') door = 'archon';
|
|
94
|
+
// Refused. The WORDING depends on who is asking (task 1004162, ADR 0407 §6): a stranger
|
|
95
|
+
// still gets the 404, someone already connected to the project gets a next step. That
|
|
96
|
+
// file can only refuse — it never calls next() and never sets req.project.
|
|
97
|
+
if (!door) return refuseProject(req, res, inst);
|
|
45
98
|
req.project = inst;
|
|
99
|
+
req.projectDoor = door;
|
|
46
100
|
return next();
|
|
47
101
|
};
|
|
48
102
|
}
|
|
49
103
|
|
|
50
|
-
module.exports = { requireProjectOwner, projectActor, isProjectOwner };
|
|
104
|
+
module.exports = { requireProjectOwner, projectActor, projectActorLabel, grantedView, isProjectOwner };
|
|
@@ -25,10 +25,23 @@ const { pool, validateOrRespond } = api;
|
|
|
25
25
|
const provisioning = require('../provisioning');
|
|
26
26
|
const envManifest = require('../env-manifest');
|
|
27
27
|
const { failFrom } = require('../public-refusal');
|
|
28
|
-
const { requireProjectOwner } = require('../route-helpers');
|
|
28
|
+
const { requireProjectOwner, projectActor, projectActorLabel } = require('../route-helpers');
|
|
29
29
|
|
|
30
30
|
const log = api.logger('provisioning');
|
|
31
31
|
const loadInstance = (id) => provisioning.getInstanceById(pool, id);
|
|
32
|
+
// All four routes also admit a person the owner granted 'operate' access (task 1004161,
|
|
33
|
+
// ADR 0407 §3): a variable NAME changes no running behaviour, and this list exists for the
|
|
34
|
+
// builder sessions that help run the project to read and keep current.
|
|
35
|
+
const GRANTEE = { grantee: 'operate' };
|
|
36
|
+
|
|
37
|
+
// Every change to the list is an audit row naming who made it, so a grantee's change never
|
|
38
|
+
// reads as the owner's (ADR 0407 §4). Best-effort: the change is already saved.
|
|
39
|
+
function recordManifestChange(req, what) {
|
|
40
|
+
return provisioning.recordEvent(pool, {
|
|
41
|
+
instanceId: req.project.id, ownerBuilderId: req.project.owner_builder_id, event: 'env-manifest',
|
|
42
|
+
detail: `${what} by ${projectActorLabel(req)}`, actor: projectActor(req),
|
|
43
|
+
}).catch(() => {});
|
|
44
|
+
}
|
|
32
45
|
|
|
33
46
|
// The `:varId` path segment. parseId reads `:id`, which is the PROJECT here, so the
|
|
34
47
|
// declaration's id is parsed separately. A non-numeric id is a 400 naming the field
|
|
@@ -91,7 +104,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
91
104
|
|
|
92
105
|
// GET — the declared surface. This is the route a BUILDER SESSION reads to learn which
|
|
93
106
|
// variables a project has, which is the whole reason the manifest exists.
|
|
94
|
-
router.get('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
107
|
+
router.get('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
|
|
95
108
|
try {
|
|
96
109
|
const inst = req.project;
|
|
97
110
|
const vars = await envManifest.listEnvVars(pool, inst.id);
|
|
@@ -113,7 +126,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
113
126
|
|
|
114
127
|
// POST — declare a variable, or re-declare one that exists (an edit, not a duplicate:
|
|
115
128
|
// see ../env-manifest.js's declareEnvVar).
|
|
116
|
-
router.post('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
129
|
+
router.post('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
|
|
117
130
|
if (refusesValue(req, res)) return; // before the strict validator — see refusesValue
|
|
118
131
|
if (validateOrRespond(req, res, {
|
|
119
132
|
name: { required: true, type: 'string', maxLength: envManifest.ENV_NAME_MAX },
|
|
@@ -131,6 +144,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
131
144
|
environments: req.body.environments,
|
|
132
145
|
required: req.body.required,
|
|
133
146
|
});
|
|
147
|
+
await recordManifestChange(req, `${created ? 'declared' : 're-declared'} ${row.name}`);
|
|
134
148
|
res.status(created ? 201 : 200).json({ ok: true, created, env_var: row, values_held: false });
|
|
135
149
|
} catch (err) {
|
|
136
150
|
log.error('[provisioning] POST /provisioning/instances/:id/env-manifest', err);
|
|
@@ -140,7 +154,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
140
154
|
|
|
141
155
|
// PATCH — edit one declaration. Merge-patch: an omitted field is left alone, so the
|
|
142
156
|
// hall can fix a description without restating the environments.
|
|
143
|
-
router.patch('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
157
|
+
router.patch('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
|
|
144
158
|
if (refusesValue(req, res)) return; // before the strict validator — see refusesValue
|
|
145
159
|
if (validateOrRespond(req, res, {
|
|
146
160
|
name: { type: 'string', maxLength: envManifest.ENV_NAME_MAX },
|
|
@@ -157,6 +171,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
157
171
|
// Not that project's declaration → the same 404 a missing one gets, so a guessed id
|
|
158
172
|
// never confirms what another project declares.
|
|
159
173
|
if (!row) return res.fail('env_var_not_found', 404);
|
|
174
|
+
await recordManifestChange(req, `edited ${row.name}`);
|
|
160
175
|
res.json({ ok: true, env_var: row, values_held: false });
|
|
161
176
|
} catch (err) {
|
|
162
177
|
log.error('[provisioning] PATCH /provisioning/instances/:id/env-manifest/:varId', err);
|
|
@@ -167,13 +182,14 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
167
182
|
// DELETE — remove one declaration. Nothing outside this table is affected: the value,
|
|
168
183
|
// wherever it lives, is untouched, and the response says so rather than letting an
|
|
169
184
|
// owner believe removing the name removed the secret.
|
|
170
|
-
router.delete('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
185
|
+
router.delete('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
|
|
171
186
|
try {
|
|
172
187
|
const inst = req.project;
|
|
173
188
|
const varId = parseVarId(req, res);
|
|
174
189
|
if (!varId) return;
|
|
175
190
|
const removed = await envManifest.removeEnvVar(pool, inst.id, varId);
|
|
176
191
|
if (!removed) return res.fail('env_var_not_found', 404);
|
|
192
|
+
await recordManifestChange(req, `removed declaration ${varId}`);
|
|
177
193
|
res.json({ ok: true, removed: true,
|
|
178
194
|
message: 'Declaration removed. The variable\'s value, wherever you keep it, is untouched — this platform never had it.' });
|
|
179
195
|
} catch (err) {
|