@bongos/core 1.25.19 → 1.25.21
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 +237 -112
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +26 -9
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +10 -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 +444 -37
- package/docs/api-reference.md +11 -5
- package/docs/copy-inventory.md +223 -221
- package/docs/copy-registry.json +262 -244
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +348 -347
- package/docs/project-context.template.md +1 -1
- package/modules/front-door-guide/handoff.js +120 -0
- package/modules/front-door-guide/module.json +1 -1
- package/modules/front-door-guide/routes/front-door-guide-plan.js +24 -0
- 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/ai-studio.html +1 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/genesis-home.js +35 -9
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/home-city-draw.js +14 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +1 -1
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/money.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +32 -20
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +9 -4
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/lifecycle/home-city.js +17 -3
- package/modules/onboarding/founding-mode.js +45 -1
- package/modules/onboarding/genesis-home.js +16 -2
- package/modules/onboarding/guide-handoff.js +109 -0
- package/modules/onboarding/routes/genesis-work.js +9 -5
- 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/guide-handoff.js +65 -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-refusal.js +107 -0
- package/modules/provisioning/provisioning.js +4 -4
- package/modules/provisioning/render-lookup.js +36 -1
- package/modules/provisioning/render-promote.js +80 -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 +12 -9
- package/modules/provisioning/routes/render-standup.js +82 -3
- package/modules/public-landing/public/projects.html +178 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/bongos/genesis.js +11 -1
- package/scripts/bongos/government-walk-sole.js +41 -0
- package/scripts/bongos/government-walk.js +15 -16
- 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 +14 -2
- 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/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/founding-page-gate.js +67 -0
- package/src/bongos/serve-internal.js +3 -0
- package/src/branding.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/fleet_cost_view.mjs +2 -0
- package/tests/founding_mode.mjs +84 -8
- package/tests/founding_page_gate.mjs +105 -0
- package/tests/front_door_guide_existing.mjs +1 -0
- package/tests/genesis_government.mjs +5 -1
- package/tests/genesis_home.mjs +41 -0
- 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/guide_handoff.mjs +322 -0
- package/tests/home_city.mjs +52 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/project_catalog_routes.mjs +2 -0
- 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/render_promote.mjs +309 -0
- package/tests/render_sandbox.mjs +9 -4
|
@@ -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'); // born into genesis (task 1004506)
|
|
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)
|
|
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
|
|
@@ -513,7 +513,7 @@ function settingsEnvVars(row, prefix) {
|
|
|
513
513
|
// cutoff and every unresolved review counts, which is the right answer for a
|
|
514
514
|
// project that has been strict since it stood up.
|
|
515
515
|
const SETTINGS_COMPANION_ENV = Object.freeze({
|
|
516
|
-
ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, ...BIRTH_COMPANION_ENV, ...TEST_COMPANION_ENV, // + accent, logo (look.js); birth, demo box (birth.js); test mark (test-project.js)
|
|
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
517
|
});
|
|
518
518
|
|
|
519
519
|
// The web.env block the runner writes at STANDUP (task 1003139): a comment that
|
|
@@ -1192,7 +1192,7 @@ async function enqueueTeardown(db, instanceId, requestedBy) {
|
|
|
1192
1192
|
`UPDATE provisioning_intents
|
|
1193
1193
|
SET state = 'error', last_error = 'superseded: teardown requested', borrowed_key_enc = NULL,
|
|
1194
1194
|
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'
|
|
1195
|
+
WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup', 'render-promote') AND state = 'pending'
|
|
1196
1196
|
AND NOT EXISTS (SELECT 1 FROM provisioning_instances WHERE id = $1 AND hosting_shape = 'control-plane') RETURNING id`,
|
|
1197
1197
|
[instanceId]
|
|
1198
1198
|
);
|
|
@@ -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 };
|
|
@@ -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) {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// modules/provisioning/routes/grants.js — "People with access": the owner gives a person access
|
|
2
|
+
// to help run their hosted project, lists it, and takes it back (task 1004161, ADR 0407 §2).
|
|
3
|
+
//
|
|
4
|
+
// The grant itself is provisioning_project_grants, written only by ../grants.js. Who may do
|
|
5
|
+
// what here:
|
|
6
|
+
// • GIVE — the project's owner only, from their own session. NO archon override: an archon
|
|
7
|
+
// already reaches the project for fleet support, and a grant would let them hand someone
|
|
8
|
+
// else's project to a third person without its owner (owner decision, ADR 0407).
|
|
9
|
+
// • LIST — the owner, or an archon (the ordinary owner-or-archon gate).
|
|
10
|
+
// • TAKE BACK — the owner, the grantee themselves (leaving), or an archon. A revoke only ever
|
|
11
|
+
// removes access, so the archon door here is a safety lever, not a widening.
|
|
12
|
+
// Every give and take-back writes a provisioning_events row naming who did it.
|
|
13
|
+
//
|
|
14
|
+
// Its own routes file (declared in module.json) because routes/provisioning.js sits at the
|
|
15
|
+
// size ratchet.
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const express = require('express');
|
|
19
|
+
const api = require('../../../src/module-api');
|
|
20
|
+
const { pool, validateOrRespond, parseId } = api;
|
|
21
|
+
const provisioning = require('../provisioning');
|
|
22
|
+
const grants = require('../grants');
|
|
23
|
+
const { failFrom } = require('../public-refusal');
|
|
24
|
+
const { requireProjectOwner, projectActor, projectActorLabel, isProjectOwner } = require('../route-helpers');
|
|
25
|
+
|
|
26
|
+
const log = api.logger('provisioning');
|
|
27
|
+
const loadInstance = (id) => provisioning.getInstanceById(pool, id);
|
|
28
|
+
|
|
29
|
+
const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
30
|
+
|
|
31
|
+
function publicGrant(g) {
|
|
32
|
+
return {
|
|
33
|
+
id: String(g.id),
|
|
34
|
+
level: g.level,
|
|
35
|
+
granted_at: g.granted_at,
|
|
36
|
+
builder: g.github_login === undefined ? { id: String(g.grantee_builder_id) } : {
|
|
37
|
+
id: String(g.grantee_builder_id), github_login: g.github_login,
|
|
38
|
+
display_name: g.display_name || null, avatar_url: g.avatar_url || null,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = function buildGrantsRouter() {
|
|
44
|
+
const router = express.Router();
|
|
45
|
+
|
|
46
|
+
// GET /provisioning/instances/:id/grants — the project's live grants.
|
|
47
|
+
// rank: any authenticated builder, own resource (owner; archon may read any). 404 otherwise.
|
|
48
|
+
router.get('/provisioning/instances/:id/grants', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
49
|
+
try {
|
|
50
|
+
const rows = await grants.listLiveGrants(pool, req.project.id);
|
|
51
|
+
res.json({ grants: rows.map(publicGrant), levels: grants.GRANT_LEVELS, limit: grants.MAX_LIVE_GRANTS });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
log.error('[provisioning] GET /provisioning/instances/:id/grants', err);
|
|
54
|
+
failFrom(res, err, 'grants_read_failed');
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// POST /provisioning/instances/:id/grants { github_login } — give a person access.
|
|
59
|
+
// rank: any authenticated builder, own resource, OWNER ONLY (no archon override). 404 for
|
|
60
|
+
// anyone else, so a stranger learns nothing about the id.
|
|
61
|
+
router.post('/provisioning/instances/:id/grants', api.requireBuilder, requireProjectOwner(loadInstance, { archonOverride: false }), async (req, res) => {
|
|
62
|
+
if (validateOrRespond(req, res, {
|
|
63
|
+
github_login: { required: true, type: 'string', maxLength: 39 },
|
|
64
|
+
})) return;
|
|
65
|
+
const login = String(req.body.github_login).trim().replace(/^@/, '');
|
|
66
|
+
if (!GITHUB_LOGIN.test(login)) {
|
|
67
|
+
return res.fail('bad_github_login', { status: 400, message: 'That is not a GitHub username.' });
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const inst = req.project;
|
|
71
|
+
const person = await grants.findGranteeByLogin(pool, login);
|
|
72
|
+
if (!person) {
|
|
73
|
+
return res.fail('grantee_not_found', { status: 404,
|
|
74
|
+
message: `Nobody called ${login} has signed in to the hub yet. Ask them to sign in once, then try again.` });
|
|
75
|
+
}
|
|
76
|
+
const out = await grants.grantProjectAccess(pool, {
|
|
77
|
+
instanceId: inst.id, granteeBuilderId: person.id, level: 'operate', grantedByBuilderId: req.builder.id,
|
|
78
|
+
});
|
|
79
|
+
if (out.refused === 'grantee_is_owner') {
|
|
80
|
+
return res.fail('grantee_is_owner', { status: 400, message: 'You own this project already.' });
|
|
81
|
+
}
|
|
82
|
+
if (out.refused === 'grant_limit') {
|
|
83
|
+
return res.fail('grant_limit', { status: 409,
|
|
84
|
+
message: `A project can give access to at most ${grants.MAX_LIVE_GRANTS} people. Take someone off first.` });
|
|
85
|
+
}
|
|
86
|
+
if (out.refused) return res.fail('instance_not_found', 404);
|
|
87
|
+
if (out.created) {
|
|
88
|
+
await provisioning.recordEvent(pool, {
|
|
89
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'grant-made',
|
|
90
|
+
detail: `access (${out.grant.level}) given to builder ${person.id} (${person.github_login}) by ${projectActorLabel(req)}`,
|
|
91
|
+
actor: projectActor(req),
|
|
92
|
+
}).catch(() => {});
|
|
93
|
+
}
|
|
94
|
+
res.status(out.created ? 201 : 200).json({
|
|
95
|
+
ok: true, created: !!out.created,
|
|
96
|
+
grant: publicGrant({ ...out.grant, ...person, grantee_builder_id: person.id }),
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
log.error('[provisioning] POST /provisioning/instances/:id/grants', err);
|
|
100
|
+
failFrom(res, err, 'grant_failed');
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// DELETE /provisioning/instances/:id/grants/:grantId — take access back.
|
|
105
|
+
// rank: any authenticated builder: the project's owner, an archon, or the grantee removing
|
|
106
|
+
// their OWN grant — decided in the handler, because "whose grant is it" is a fact of the row.
|
|
107
|
+
// Anyone else gets the same 404 a missing project gets.
|
|
108
|
+
router.delete('/provisioning/instances/:id/grants/:grantId', api.requireBuilder, async (req, res) => {
|
|
109
|
+
const id = parseId(req, res);
|
|
110
|
+
if (!id) return;
|
|
111
|
+
const grantId = Number(req.params.grantId);
|
|
112
|
+
if (!Number.isSafeInteger(grantId) || grantId <= 0) return res.fail('bad_id', 400);
|
|
113
|
+
try {
|
|
114
|
+
const inst = await loadInstance(id);
|
|
115
|
+
if (!inst) return res.fail('instance_not_found', 404);
|
|
116
|
+
const grant = await grants.getGrant(pool, inst.id, grantId);
|
|
117
|
+
const isOwner = isProjectOwner(inst, req.builder);
|
|
118
|
+
const isArchon = req.builder.rank === 'archon';
|
|
119
|
+
const isOwnGrant = !!grant && String(grant.grantee_builder_id) === String(req.builder.id);
|
|
120
|
+
if (!isOwner && !isArchon && !isOwnGrant) return res.fail('instance_not_found', 404);
|
|
121
|
+
if (!grant || grant.revoked_at) {
|
|
122
|
+
return res.fail('grant_not_found', { status: 404, message: 'That access was already taken back.' });
|
|
123
|
+
}
|
|
124
|
+
const revoked = await grants.revokeProjectAccess(pool, { instanceId: inst.id, grantId, revokedByBuilderId: req.builder.id });
|
|
125
|
+
if (!revoked) return res.fail('grant_not_found', { status: 404, message: 'That access was already taken back.' });
|
|
126
|
+
req.project = inst;
|
|
127
|
+
req.projectDoor = isOwner ? 'owner' : (isOwnGrant ? 'grantee' : 'archon');
|
|
128
|
+
await provisioning.recordEvent(pool, {
|
|
129
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'grant-revoked',
|
|
130
|
+
detail: `access (${revoked.level}) of builder ${revoked.grantee_builder_id} taken back by ${projectActorLabel(req)}`,
|
|
131
|
+
actor: projectActor(req),
|
|
132
|
+
}).catch(() => {});
|
|
133
|
+
res.json({ ok: true, revoked: true });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
log.error('[provisioning] DELETE /provisioning/instances/:id/grants/:grantId', err);
|
|
136
|
+
failFrom(res, err, 'grant_revoke_failed');
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return router;
|
|
141
|
+
};
|