@bongos/core 1.19.936 → 1.19.938
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 +69 -44
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +2 -0
- package/docs/api/openapi.json +91 -2
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/self-host.md +1 -1
- package/modules/government/board.js +31 -0
- package/modules/government/db.js +33 -0
- package/modules/government/migrations/government_018_passed_items_index.sql +31 -0
- package/modules/government/routes/government.js +7 -0
- package/modules/hall-ui/public/deploy.js +1 -1
- package/modules/ideas/inbox.js +11 -0
- package/modules/ideas/migrations/ideas_010_goal_ratification.sql +103 -0
- package/modules/ideas/module.json +1 -0
- package/modules/ideas/ratify.js +288 -1
- package/modules/ideas/routes/ratify-goal.js +163 -0
- package/modules/ideas/templates.js +16 -4
- package/modules/lifecycle/goal-from-idea.js +131 -0
- package/modules/lifecycle/lifecycle.js +14 -0
- package/modules/provisioning/core-upgrade.js +1 -1
- package/modules/provisioning/routes/core-upgrade.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/audit-authorship.sh +33 -0
- package/scripts/gds/db-backup-nightly.sh +1 -1
- package/scripts/gds/instance-apex.js +11 -1
- package/scripts/gds/provision-config.js +3 -2
- package/scripts/gds/provision-core-upgrade.js +7 -7
- package/scripts/gds/provision-process.js +2 -2
- package/scripts/gds/provision-repo.js +35 -10
- package/scripts/gds/provision-units.js +3 -3
- package/scripts/gds/provision.js +6 -3
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_runner.mjs +10 -7
- package/tests/government_seed.mjs +1 -0
- package/tests/idea_goal_ratification.mjs +553 -0
- package/tests/provision.mjs +44 -7
- package/tests/provision_account_identity.mjs +16 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// modules/ideas/routes/ratify-goal.js — POST /inbox/:id/ratify-goal, the route
|
|
2
|
+
// that turns a Full Idea the board has carried into a GOAL (task 1004096 /
|
|
3
|
+
// BV2.R17, spec R23, goal 1000095, criterion `ideator-full-idea-shapes-the-space`).
|
|
4
|
+
//
|
|
5
|
+
// WHY IT IS ITS OWN FILE, and not one more route in inbox.js. That file sits at
|
|
6
|
+
// the 1,500-line size ratchet (`scripts/gds/fitness-ratchets.js` OVERSIZE_LINES)
|
|
7
|
+
// and this route pushed it 24 lines over. Trimming back under would have left it
|
|
8
|
+
// at ~1,497 — a landmine for whoever adds the next line — so the route moved
|
|
9
|
+
// out instead. It is the same call `trail-descent.js`, `trail-size.js` and
|
|
10
|
+
// `projection.js` were carved out on, and projection.js's own header names the
|
|
11
|
+
// failure mode being avoided: a ceiling that has "started deciding the design
|
|
12
|
+
// rather than the domain".
|
|
13
|
+
//
|
|
14
|
+
// It is also a clean cut on the domain, not only on the line count. Everything
|
|
15
|
+
// in inbox.js is INTAKE — a thought entering the system and becoming work, or
|
|
16
|
+
// not. This is the opposite end: a thought that has already been through the
|
|
17
|
+
// Board Room, becoming the structure other people's work lands in. The
|
|
18
|
+
// criterion half of ratification (`POST /inbox/:id/ratify`) stays in inbox.js
|
|
19
|
+
// where it has always been; moving it would be a rename with no argument behind
|
|
20
|
+
// it, and the two halves are documented as deliberately asymmetrical anyway
|
|
21
|
+
// (see `../ratify.js`).
|
|
22
|
+
//
|
|
23
|
+
// ROUTE ORDER IS NOT AT RISK HERE, unlike the literal paths inbox.js warns
|
|
24
|
+
// about. Express matches whole paths, so `/inbox/:id` cannot swallow
|
|
25
|
+
// `/inbox/:id/ratify-goal` however the two files are mounted. A literal segment
|
|
26
|
+
// AFTER a param — the `/tasks/removal-proposals` trap the lifecycle map
|
|
27
|
+
// records — is a different shape from a literal segment after a param plus a
|
|
28
|
+
// further segment, which is what this is.
|
|
29
|
+
//
|
|
30
|
+
// MODULE BOUNDARY (ADR 0093 §1): auth gates and request validators reach through
|
|
31
|
+
// the published doorway `src/module-api.js`; `../inbox` is an intra-module
|
|
32
|
+
// sibling. Never a core internal, never another module.
|
|
33
|
+
|
|
34
|
+
const express = require('express');
|
|
35
|
+
const api = require('../../../src/module-api');
|
|
36
|
+
const inbox = require('../inbox');
|
|
37
|
+
|
|
38
|
+
const log = api.logger('ideas');
|
|
39
|
+
const { validateOrRespond, LIMITS, parseId } = api;
|
|
40
|
+
|
|
41
|
+
module.exports = function buildRatifyGoalRouter() {
|
|
42
|
+
const router = express.Router();
|
|
43
|
+
|
|
44
|
+
// The fields a ratify-goal refusal may put on the wire — an ALLOWLIST, not a
|
|
45
|
+
// `{ ...err }` spread. Every shape thrown by `ratifyFullIdeaAsGoal` today is a
|
|
46
|
+
// plain typed refusal this code constructs itself, so a spread is harmless
|
|
47
|
+
// TODAY; what it is not is harmless tomorrow. A later call site that let a
|
|
48
|
+
// real exception carrying a `.code` fall into this catch would echo whatever
|
|
49
|
+
// else that object holds, and the refusals here already spread two internal
|
|
50
|
+
// objects into themselves (the composer's result, the lifecycle port's
|
|
51
|
+
// verdict) — so `ok`, `description` and `criterionMd` are all one edit away
|
|
52
|
+
// from the response body. Naming them explicitly costs nothing and means a
|
|
53
|
+
// new internal field is invisible by default rather than published by default.
|
|
54
|
+
const RATIFY_GOAL_DETAIL_FIELDS = [
|
|
55
|
+
'message', // a gate's own sentence, written to be read by the caller
|
|
56
|
+
'reason', // why the composer refused
|
|
57
|
+
'field', // which answer was missing
|
|
58
|
+
'template_key', // which vintage has no goal shape
|
|
59
|
+
'grade', // what the idea actually is, when it is not Full
|
|
60
|
+
'status', // the idea's status, when it is no longer open
|
|
61
|
+
'version_status', 'unknown', 'valid', 'scope_modules', 'detail',
|
|
62
|
+
];
|
|
63
|
+
const ratifyGoalDetail = (err) => {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const k of RATIFY_GOAL_DETAIL_FIELDS) {
|
|
66
|
+
if (err[k] !== undefined) out[k] = err[k];
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// BV2.R17 (task 1004096, spec R23, goal 1000095) — ratify a FULL IDEA into a
|
|
72
|
+
// GOAL, atomically:
|
|
73
|
+
// - INSERT goals (title from the idea, description = its five answers)
|
|
74
|
+
// - INSERT done_when_criteria on that goal from the "what does a successful
|
|
75
|
+
// implementation look like" answer
|
|
76
|
+
// - UPDATE idea_inbox SET status='promoted', reviewed_at=now()
|
|
77
|
+
// - INSERT ideas_goal_ratifications — who ratified, under which board
|
|
78
|
+
// decision, with the author's own recommendation recorded beside it
|
|
79
|
+
// All of it in one transaction; a partial failure rolls back to an open idea.
|
|
80
|
+
//
|
|
81
|
+
// A SEPARATE ROUTE FROM /ratify, not a mode of it. The two take different
|
|
82
|
+
// subjects (a `criterion-proposal` idea whose body_md is JSON vs. a graded Full
|
|
83
|
+
// Idea whose answers are prose), produce different rows, and differ on whether
|
|
84
|
+
// the board has a say. One route with a discriminator would have to document
|
|
85
|
+
// which half of its own body applies, and a caller who got the discriminator
|
|
86
|
+
// wrong would be told about a field rather than about the act.
|
|
87
|
+
//
|
|
88
|
+
// THE SCOPE AND THE VERSION COME FROM THE REQUEST because nothing else can
|
|
89
|
+
// supply them: no prose answer says which version a goal belongs to or which
|
|
90
|
+
// modules it may touch. THE GATES DO NOT come from the request — the handler
|
|
91
|
+
// passes the caller's LIVE rank through to the lifecycle port, which applies
|
|
92
|
+
// every check POST /goals applies (unknown module key, protected scope →
|
|
93
|
+
// Archon, a building version's goal set closed without a named Archon
|
|
94
|
+
// admission). `idea.ratify`'s floor is Metic, so those gates are load-bearing
|
|
95
|
+
// here rather than ceremonial.
|
|
96
|
+
//
|
|
97
|
+
// Statuses: 400 a bad body / an unratifiable idea / an unmappable template ·
|
|
98
|
+
// 403 a rank gate · 404 no such idea · 409 the idea is not open, the board has
|
|
99
|
+
// not passed it, the version will not take a goal, or it is already ratified.
|
|
100
|
+
router.post(
|
|
101
|
+
'/inbox/:id/ratify-goal',
|
|
102
|
+
api.requireBuilder,
|
|
103
|
+
api.requirePermission('idea.ratify'),
|
|
104
|
+
async (req, res) => {
|
|
105
|
+
if (validateOrRespond(req, res, {
|
|
106
|
+
target_version: { required: true, type: 'string', minLength: 1, maxLength: 64 },
|
|
107
|
+
scope_modules: { type: 'array', maxItems: 64, itemsType: 'string' },
|
|
108
|
+
// Mirrors POST /goals: the PRESENCE of a reason IS the override, so one
|
|
109
|
+
// cannot be requested without stating a cause, and minLength 8 because
|
|
110
|
+
// 'ok' is not a reason.
|
|
111
|
+
admit_reason: { type: 'string', minLength: 8, maxLength: 500 },
|
|
112
|
+
notes_md: { type: 'string', maxLength: LIMITS.NOTES },
|
|
113
|
+
})) return;
|
|
114
|
+
const id = parseId(req, res, { code: 'bad_id' });
|
|
115
|
+
if (id === null) return;
|
|
116
|
+
try {
|
|
117
|
+
const result = await inbox.ratifyFullIdeaAsGoal(id, req.builder.id, {
|
|
118
|
+
targetVersion: req.body.target_version,
|
|
119
|
+
scopeModules: req.body.scope_modules || [],
|
|
120
|
+
admitReason: (req.body.admit_reason || '').trim() || null,
|
|
121
|
+
// The live rank read off the authenticated request, never a body field
|
|
122
|
+
// (ADR 0016) — the port denies when it is absent.
|
|
123
|
+
actorRank: req.builder.rank,
|
|
124
|
+
notesMd: typeof req.body.notes_md === 'string' ? req.body.notes_md : '',
|
|
125
|
+
});
|
|
126
|
+
res.status(201).json({ ok: true, ...result });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err && err.code) {
|
|
129
|
+
// A refusal that already carries its own status is one the lifecycle
|
|
130
|
+
// port named (the goal-creation gates) — rendered as given rather than
|
|
131
|
+
// re-mapped here, so the two files cannot disagree about what a gate
|
|
132
|
+
// returns.
|
|
133
|
+
if (Number.isInteger(err.status)) {
|
|
134
|
+
return res.fail(err.code, err.status, ratifyGoalDetail(err));
|
|
135
|
+
}
|
|
136
|
+
const status =
|
|
137
|
+
err.code === 'IDEA_NOT_FOUND' ? 404 :
|
|
138
|
+
err.code === 'NOT_A_FULL_IDEA' ? 400 :
|
|
139
|
+
err.code === 'CANNOT_COMPOSE_GOAL' ? 400 :
|
|
140
|
+
err.code === 'TARGET_VERSION_REQUIRED' ? 400 :
|
|
141
|
+
err.code === 'BAD_ID' ? 400 :
|
|
142
|
+
err.code === 'BAD_RATIFIER' ? 400 :
|
|
143
|
+
err.code === 'IDEA_NOT_OPEN' ? 409 :
|
|
144
|
+
err.code === 'BOARD_HAS_NOT_PASSED' ? 409 :
|
|
145
|
+
err.code === 'NO_LIFECYCLE' ? 503 :
|
|
146
|
+
err.code === 'NO_BOARD' ? 503 :
|
|
147
|
+
500;
|
|
148
|
+
if (status !== 500) return res.fail(err.code, status, ratifyGoalDetail(err));
|
|
149
|
+
}
|
|
150
|
+
// The UNIQUE trips are the double-ratification guards: one on the audit
|
|
151
|
+
// row's idea_id, one on (version_id, criterion_id). Either means this
|
|
152
|
+
// idea has already become a goal.
|
|
153
|
+
if (err && err.code === '23505') {
|
|
154
|
+
return res.fail('IDEA_ALREADY_RATIFIED', 409, { detail: 'This idea has already been ratified into a goal.' });
|
|
155
|
+
}
|
|
156
|
+
log.error('[gds] POST /inbox/:id/ratify-goal', err);
|
|
157
|
+
res.fail('ratify_goal_failed', { status: 500, message: 'internal error' });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return router;
|
|
163
|
+
};
|
|
@@ -35,13 +35,25 @@
|
|
|
35
35
|
// resolve its own registry and pass it through; nothing below cares where a
|
|
36
36
|
// registry came from. No customization UI, no config plumbing — just the seam.
|
|
37
37
|
//
|
|
38
|
-
// Same-module import: DEFAULT_TEMPLATE_KEY and the payload cap live in
|
|
39
|
-
// (declared there for the route validators, mirroring the DB
|
|
40
|
-
// source, so this file can never drift from what the store
|
|
38
|
+
// Same-module import: DEFAULT_TEMPLATE_KEY and the payload cap live in
|
|
39
|
+
// projection.js (declared there for the route validators, mirroring the DB
|
|
40
|
+
// constraints) — one source, so this file can never drift from what the store
|
|
41
|
+
// accepts.
|
|
42
|
+
//
|
|
43
|
+
// IT READS THEM FROM projection.js, NOT FROM inbox.js, and that is load-bearing
|
|
44
|
+
// rather than tidy. Both names are the same constants either way — inbox.js only
|
|
45
|
+
// re-exports what the 1004115 carve moved down to projection.js — but inbox.js
|
|
46
|
+
// requires ./ratify, so importing them from there makes this file part of a
|
|
47
|
+
// require CYCLE the moment anything in the ratify half wants a template
|
|
48
|
+
// (BV2.R17 wants exactly that: a Full Idea's answers are ordered by the
|
|
49
|
+
// declaration here). projection.js requires NOTHING, by design — its own header
|
|
50
|
+
// calls that out as the property that keeps the module acyclic — so reading the
|
|
51
|
+
// constants from the file that declares them is both shorter and the only
|
|
52
|
+
// spelling that stays acyclic.
|
|
41
53
|
|
|
42
54
|
'use strict';
|
|
43
55
|
|
|
44
|
-
const { DEFAULT_TEMPLATE_KEY, FIELD_VALUES_MAX } = require('./
|
|
56
|
+
const { DEFAULT_TEMPLATE_KEY, FIELD_VALUES_MAX } = require('./projection');
|
|
45
57
|
|
|
46
58
|
// The five base fields, verbatim from the owner's brief. `key` is the stable
|
|
47
59
|
// snake_case identity `field_values` is stored under (renaming a label is free;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/goal-from-idea.js — creating the GOAL a ratified Full Idea
|
|
4
|
+
// becomes, inside somebody else's transaction (task 1004096 / BV2.R17, spec R23,
|
|
5
|
+
// goal 1000095, criterion `ideator-full-idea-shapes-the-space`).
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS LIVES IN LIFECYCLE AND NOT IN modules/ideas. `goals`, `versions` and
|
|
8
|
+
// `done_when_criteria` are this module's tables, and the ideas module may not
|
|
9
|
+
// import lifecycle (the one-way module rule, ADR 0083). The alternative was to
|
|
10
|
+
// widen the `lifecycle` port with four primitives — getVersion, createGoal,
|
|
11
|
+
// createCriterion, authorizeGoalCreate — and let the ideas module compose them.
|
|
12
|
+
// That is the shape to avoid: composing them IS the policy, and the policy is
|
|
13
|
+
// "may a goal be created here, by this rank, with this scope", which is a
|
|
14
|
+
// lifecycle judgement. Handing a caller the four pieces hands them four chances
|
|
15
|
+
// to leave one out, and the one most easily left out is the gate.
|
|
16
|
+
//
|
|
17
|
+
// SO THIS IS ONE PORT CALL THAT EITHER CREATES BOTH ROWS OR NAMES ITS REFUSAL.
|
|
18
|
+
// The ideas module supplies the DATA a ratification carries (title, the five
|
|
19
|
+
// answers, the version and scope the board chose) and the actor's live rank; it
|
|
20
|
+
// never gets to decide whether the create is allowed.
|
|
21
|
+
//
|
|
22
|
+
// IT RUNS IN THE CALLER'S TRANSACTION (`deps.client`, required — see below), for
|
|
23
|
+
// the ensureMaintenanceGoal reason: a goal committed on its own connection would
|
|
24
|
+
// survive a rolled-back ratification as an empty goal nobody asked for, pointing
|
|
25
|
+
// at an idea still sitting `open` in the inbox.
|
|
26
|
+
//
|
|
27
|
+
// EVERY GATE POST /goals APPLIES IS APPLIED HERE, and that is the point of the
|
|
28
|
+
// file rather than a courtesy. `idea.ratify`'s floor is METIC (ADR 0157 lowered
|
|
29
|
+
// it from archon), while `POST /goals` refuses a protected-`scope_modules` goal
|
|
30
|
+
// to anyone below Archon and refuses to grow a BUILDING version's goal set at
|
|
31
|
+
// all without a named Archon admission. A ratification path that skipped either
|
|
32
|
+
// would be a Metic-reachable way around both — the scope wall and the R03 gate
|
|
33
|
+
// would still be in the code and no longer in the way of anything.
|
|
34
|
+
|
|
35
|
+
const api = require('../../src/module-api');
|
|
36
|
+
const db = require('./db.js');
|
|
37
|
+
const { authorizeGoalCreate } = require('./routes/goal-route-authz.js');
|
|
38
|
+
|
|
39
|
+
const { isModuleKey, scopeIncludesProtected, moduleKeys } = api.moduleScopeMap;
|
|
40
|
+
|
|
41
|
+
// A typed refusal, never a throw: the caller is inside a transaction it has to
|
|
42
|
+
// roll back itself, and an exception thrown across a port is the thing that
|
|
43
|
+
// makes a rollback easy to forget. `status` travels with the refusal so the
|
|
44
|
+
// route renders it without a second mapping table.
|
|
45
|
+
function refuse(code, status, extra = {}) {
|
|
46
|
+
return { ok: false, code, status, ...extra };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// createRatifiedGoal — the goal + its first done-when criterion, in the caller's
|
|
50
|
+
// transaction, or a named refusal.
|
|
51
|
+
//
|
|
52
|
+
// `criterionMd` is REQUIRED and is not a convenience. The criterion is what makes
|
|
53
|
+
// the created row a goal in this project's sense rather than an empty container:
|
|
54
|
+
// ADR 0263's close gate reads done-when criteria, so a goal born with none can
|
|
55
|
+
// never be achieved and holds its version open forever. BV2.R17's whole claim is
|
|
56
|
+
// that a Full Idea's "what does a successful implementation look like" answer IS
|
|
57
|
+
// that criterion — so the two rows are one act, and this function will not make
|
|
58
|
+
// the first without the second.
|
|
59
|
+
//
|
|
60
|
+
// `criterionRef` is the stable `criterion_id` text. The caller derives it from
|
|
61
|
+
// the source idea (`idea-<n>`), which makes the UNIQUE (version_id,
|
|
62
|
+
// criterion_id) constraint a second, database-level guard against ratifying the
|
|
63
|
+
// same idea twice onto the same version — the `proposal-<idea_id>` trick
|
|
64
|
+
// ratifyCriterionProposal already uses, for the same reason.
|
|
65
|
+
async function createRatifiedGoal(
|
|
66
|
+
{ versionId, title, description = null, subtitle = null, scopeModules = [], createdBy = null,
|
|
67
|
+
criterionMd, criterionRef, admitReason = null, actorRank = null },
|
|
68
|
+
deps = {}
|
|
69
|
+
) {
|
|
70
|
+
if (!deps.client) {
|
|
71
|
+
// Not a policy refusal — a programming error, and one whose consequence is a
|
|
72
|
+
// half-committed ratification. It throws rather than returning, because no
|
|
73
|
+
// caller can sensibly handle it and none should try.
|
|
74
|
+
throw new Error('createRatifiedGoal requires deps.client — it must run in the ratification transaction');
|
|
75
|
+
}
|
|
76
|
+
if (typeof title !== 'string' || !title.trim()) return refuse('TITLE_REQUIRED', 400);
|
|
77
|
+
if (typeof criterionMd !== 'string' || !criterionMd.trim()) return refuse('CRITERION_REQUIRED', 400);
|
|
78
|
+
if (typeof criterionRef !== 'string' || !criterionRef.trim()) return refuse('CRITERION_REF_REQUIRED', 400);
|
|
79
|
+
|
|
80
|
+
const scope = Array.isArray(scopeModules) ? scopeModules : [];
|
|
81
|
+
// Fail closed on an unknown module key, POST /goals' first gate: a scope wall
|
|
82
|
+
// that can name a module which does not exist is not a wall.
|
|
83
|
+
const unknown = scope.filter((k) => !isModuleKey(k));
|
|
84
|
+
if (unknown.length) return refuse('UNKNOWN_MODULE', 400, { unknown, valid: moduleKeys() });
|
|
85
|
+
// The two RANK gates. `actorRank` is the caller's LIVE rank read (ADR 0016) —
|
|
86
|
+
// never a value a request body carried — and an absent rank denies, because a
|
|
87
|
+
// rank we were not told is not a rank we may assume.
|
|
88
|
+
if (scopeIncludesProtected(scope) && actorRank !== 'archon') {
|
|
89
|
+
return refuse('ARCHON_REQUIRED_FOR_PROTECTED_SCOPE', 403, { scope_modules: scope });
|
|
90
|
+
}
|
|
91
|
+
const reason = typeof admitReason === 'string' ? admitReason.trim() || null : null;
|
|
92
|
+
// Refused by RANK before the version is fetched, the POST /goals ordering: a
|
|
93
|
+
// Metic must not learn whether the override would have worked.
|
|
94
|
+
if (reason !== null && actorRank !== 'archon') {
|
|
95
|
+
return refuse('ARCHON_REQUIRED_FOR_ADMISSION', 403);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// FOR UPDATE, so a version freeze committing between this read and this
|
|
99
|
+
// transaction's COMMIT cannot leave the goal and its criterion on a version
|
|
100
|
+
// that closed underneath them (getVersion's own note explains why a plain read
|
|
101
|
+
// is not enough under READ COMMITTED).
|
|
102
|
+
const version = await db.getVersion(versionId, { client: deps.client, forUpdate: true });
|
|
103
|
+
const decision = authorizeGoalCreate({ version, override: reason !== null });
|
|
104
|
+
if (!decision.ok) {
|
|
105
|
+
return refuse(decision.body.error, decision.status, { message: decision.body.message, version_status: decision.body.status });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const goal = await db.createGoal({
|
|
109
|
+
versionId,
|
|
110
|
+
title: title.trim(),
|
|
111
|
+
subtitle,
|
|
112
|
+
description,
|
|
113
|
+
scopeModules: scope,
|
|
114
|
+
createdBy,
|
|
115
|
+
// The admission row is written ONLY when the override actually opened the
|
|
116
|
+
// gate — `overrideApplied`, not "a reason was supplied". A reason on a
|
|
117
|
+
// planning version changed nothing and must not be filed as a skipped gate.
|
|
118
|
+
admission: decision.overrideApplied ? { admittedBy: createdBy, reason } : null,
|
|
119
|
+
}, { client: deps.client });
|
|
120
|
+
|
|
121
|
+
const criterion = await db.createCriterion({
|
|
122
|
+
versionId,
|
|
123
|
+
criterionId: criterionRef.trim(),
|
|
124
|
+
criterionMd,
|
|
125
|
+
goalId: goal.id,
|
|
126
|
+
}, { client: deps.client });
|
|
127
|
+
|
|
128
|
+
return { ok: true, goal, criterion, admitted: decision.overrideApplied };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { createRatifiedGoal };
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
const db = require('./db');
|
|
20
20
|
const classifier = require('./task-classifier');
|
|
21
21
|
const goalSuggest = require('./goal-suggest');
|
|
22
|
+
const goalFromIdea = require('./goal-from-idea');
|
|
22
23
|
const helpRequests = require('./help-requests');
|
|
23
24
|
const taskRecommendations = require('./task-recommendations');
|
|
24
25
|
const activity = require('./activity');
|
|
@@ -66,6 +67,19 @@ module.exports = {
|
|
|
66
67
|
// lifecycle tables and the ideas module may not import lifecycle (ADR 0083).
|
|
67
68
|
ensureMaintenanceGoal: (...a) => db.ensureMaintenanceGoal(...a),
|
|
68
69
|
currentBuildingVersionId: (...a) => db.currentBuildingVersionId(...a),
|
|
70
|
+
// createRatifiedGoal (task 1004096 / BV2.R17): the GOAL a ratified Full Idea
|
|
71
|
+
// becomes, plus the done-when criterion its "what does a successful
|
|
72
|
+
// implementation look like" answer supplies — both rows, in the ideas module's
|
|
73
|
+
// ratification transaction, or one named refusal.
|
|
74
|
+
//
|
|
75
|
+
// ONE CALL RATHER THAN FOUR PRIMITIVES, deliberately. The ideas module could
|
|
76
|
+
// have been handed getVersion + createGoal + createCriterion +
|
|
77
|
+
// authorizeGoalCreate and left to compose them, but composing them IS the
|
|
78
|
+
// goal-creation policy — the scope wall, the rank gates, the R03 building-
|
|
79
|
+
// version gate — and that policy is a lifecycle judgement. `idea.ratify`'s
|
|
80
|
+
// floor is Metic, so a caller free to leave the gate out would be a
|
|
81
|
+
// Metic-reachable way around it. See modules/lifecycle/goal-from-idea.js.
|
|
82
|
+
createRatifiedGoal: (...a) => goalFromIdea.createRatifiedGoal(...a),
|
|
69
83
|
|
|
70
84
|
// --- work-tracking READS the staying-core read routes resolve --------------
|
|
71
85
|
// public.js (the status surface): version/ship feed + grade trend.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// that makes shipped work visible to users. It was reachable from exactly one
|
|
8
8
|
// place — a shell on the control-plane box with an ssh key — so the owner could not
|
|
9
9
|
// take it, and neither could an agent (the permission classifier blocks a mutating
|
|
10
|
-
// `sudo -u
|
|
10
|
+
// `sudo -u <app user>` over ssh by design).
|
|
11
11
|
//
|
|
12
12
|
// THIS FILE ONLY ASKS. It validates and queues an intent; the control-plane runner
|
|
13
13
|
// drains it and drives the proven `bongos upgrade` path with its health gate and
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// reachable from exactly one place — a shell on the control-plane box holding an
|
|
9
9
|
// ssh key — which is why the owner said "I don't think I can make anything live",
|
|
10
10
|
// and why an agent could not close the loop for them either: the permission
|
|
11
|
-
// classifier blocks a mutating `sudo -u
|
|
11
|
+
// classifier blocks a mutating `sudo -u <app user>` over ssh by design.
|
|
12
12
|
//
|
|
13
13
|
// THE WEB TIER ONLY ASKS. This queues an intent; the control-plane runner drains it
|
|
14
14
|
// and drives the proven `bongos upgrade` path with its health gate and auto-rollback
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.938",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.938",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.938",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
# during a builder's session. (Use `req.builder.id` / the active claim.)
|
|
14
14
|
# * `lars589` / `larskristensen` literals in setup-path code or test data
|
|
15
15
|
# that would mis-attribute a second builder's work.
|
|
16
|
+
# * The founder's unix account in account position (`-o`/`-u <it>`,
|
|
17
|
+
# `User=<it>`, `APP_USER = '<it>'`) in runtime code — section 6.
|
|
16
18
|
#
|
|
17
19
|
# What is INTENTIONAL and allow-listed (NOT a violation):
|
|
18
20
|
# * Archived shipping docs in `limitations/*-shipped.md` — historical record.
|
|
@@ -280,6 +282,36 @@ fi
|
|
|
280
282
|
grep -vE "$OPS_ALLOWLIST_PATTERN" "$TMP_HITS" \
|
|
281
283
|
| grep -vE "$SPEC_ALLOWLIST_PATTERN" > "$TMP_FILTERED" || true
|
|
282
284
|
|
|
285
|
+
# ----------------------------------------------------------------------------
|
|
286
|
+
# 6. The founder's UNIX ACCOUNT in account position (task 1002719). Family (c)
|
|
287
|
+
# greps the GitHub login, and a unix name is not a GitHub login — which is
|
|
288
|
+
# how the standup's `install -d -o <founder> …` reached every operator's box
|
|
289
|
+
# unseen. Runtime code only (tests/ pin fixtures by design), and the ops
|
|
290
|
+
# allowlist above does NOT apply: "that is the droplet's unix user" is the
|
|
291
|
+
# very assumption this family refuses in code that runs on other planes.
|
|
292
|
+
# Derive the account instead (provision-repo.js appUser()).
|
|
293
|
+
# ----------------------------------------------------------------------------
|
|
294
|
+
UNIX_USER_PATTERN='((^|[^A-Za-z0-9_.-])(-o|-g|-u)[ =]lars\b)|(\b(User|Group)=lars\b)|(\bchown( -R)? lars\b)|([A-Za-z_]*[Uu][Ss][Ee][Rr][A-Za-z_]*\s*[:=]\s*["'"'"']lars["'"'"'])'
|
|
295
|
+
UNIX_ALLOWLIST=(
|
|
296
|
+
# One-off backfill sweeps (tasks 1003939, 1003947), run by hand on the plane
|
|
297
|
+
# whose rows predate the fix; they name that plane's account on purpose.
|
|
298
|
+
'scripts/gds/address-park-sweep.sh'
|
|
299
|
+
'scripts/gds/stray-unit-sweep.sh'
|
|
300
|
+
|
|
301
|
+
# This script's own pattern.
|
|
302
|
+
'scripts/gds/audit-authorship.sh'
|
|
303
|
+
)
|
|
304
|
+
UNIX_ALLOWLIST_RE=''
|
|
305
|
+
for e in "${UNIX_ALLOWLIST[@]}"; do
|
|
306
|
+
UNIX_ALLOWLIST_RE+="^${e//./\\.}:|"
|
|
307
|
+
done
|
|
308
|
+
UNIX_ALLOWLIST_RE="${UNIX_ALLOWLIST_RE%|}"
|
|
309
|
+
|
|
310
|
+
grep -RIn -E "${EXCLUDE_DIRS[@]}" --exclude-dir=tests --exclude-dir=test "${EXCLUDE_FILES[@]}" -e "$UNIX_USER_PATTERN" . 2>/dev/null \
|
|
311
|
+
| sed 's|^\./||' \
|
|
312
|
+
| sort -u \
|
|
313
|
+
| grep -vE "$UNIX_ALLOWLIST_RE" >> "$TMP_FILTERED" || true
|
|
314
|
+
|
|
283
315
|
HIT_COUNT=$(wc -l < "$TMP_FILTERED" | tr -d ' ')
|
|
284
316
|
|
|
285
317
|
if [ "$VERBOSE" = "1" ]; then
|
|
@@ -306,6 +338,7 @@ echo "Remediation:"
|
|
|
306
338
|
echo " * Paths like /Users/larskristensen/... -> use \$HOME or os.path.expanduser('~')"
|
|
307
339
|
echo " * builder_id = 1 / shipped_by = 1 -> use req.builder.id / the active claim"
|
|
308
340
|
echo " * lars589 / larskristensen literals -> use the current builder's github_login"
|
|
341
|
+
echo " * -o/-u lars, User=lars, *user = 'lars' -> derive the account (appUser() in provision-repo.js)"
|
|
309
342
|
echo ""
|
|
310
343
|
echo "If a hit IS intentional (historical doc, droplet operational identity,"
|
|
311
344
|
echo "or a frozen seed/import script), add it to the ALLOWLIST or"
|
|
@@ -69,7 +69,7 @@ DUMP_GLOB="${DB_NAME}-${D}${D}${D}${D}-${D}${D}-${D}${D}T${D}${D}${D}${D}${D}${D
|
|
|
69
69
|
if [ ! -d "$BACKUP_DIR" ]; then
|
|
70
70
|
echo "ERROR: backup dir $BACKUP_DIR does not exist." >&2
|
|
71
71
|
echo " Create it with:" >&2
|
|
72
|
-
echo " sudo install -d -o
|
|
72
|
+
echo " sudo install -d -o $(id -un) -g $(id -gn) -m 0750 $BACKUP_DIR" >&2
|
|
73
73
|
exit 2
|
|
74
74
|
fi
|
|
75
75
|
|
|
@@ -25,4 +25,14 @@ function provisionerBotEmail(env = process.env) {
|
|
|
25
25
|
return apex ? `bot@${apex}` : 'bot@cloud-bongos.invalid';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// The name that identity commits under: the pack's product name, else the platform's
|
|
29
|
+
// (task 1002719). Every caller builds `git config user.name "<name>"`, so a name the
|
|
30
|
+
// shell cannot carry inside double quotes falls back instead of being interpolated.
|
|
31
|
+
function brandProductName() {
|
|
32
|
+
try { return String(require('../../src/branding').branding().identity.productName || '').trim(); } catch { return ''; }
|
|
33
|
+
}
|
|
34
|
+
function provisionerBotName(name = brandProductName()) {
|
|
35
|
+
return name && !/["`$\\\r\n]/.test(name) ? name : 'Cloud Bongos';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { instanceApex, provisionerBotEmail, provisionerBotName };
|
|
@@ -108,7 +108,7 @@ const CONFIG = {
|
|
|
108
108
|
standaloneBase: process.env.PROVISION_STANDALONE_BASE || '/srv/cloudbongos',
|
|
109
109
|
// ── Privilege escalation (task 2066, ADR 0128) ───────────────────────────
|
|
110
110
|
// The intent runner runs as a NON-root user (provision-intent-runner.service:
|
|
111
|
-
// User=
|
|
111
|
+
// User= the app user) that holds passwordless sudo. That user cannot itself write under
|
|
112
112
|
// /etc, reload systemd/Caddy, or peer-auth to Postgres as a superuser — so the
|
|
113
113
|
// runbook steps below FAIL as bare commands (the goal-35 blocker). With
|
|
114
114
|
// PROVISION_SUDO=1 (alias PROVISION_PRIVILEGED=1) the privileged steps escalate:
|
|
@@ -163,7 +163,7 @@ const CONFIG = {
|
|
|
163
163
|
|
|
164
164
|
// task 1002757 — the provisioner's git identity resolves from the instance's own
|
|
165
165
|
// apex (env → bot@<apex> → neutral), never the hub's address hardcoded.
|
|
166
|
-
const { provisionerBotEmail } = require('./instance-apex');
|
|
166
|
+
const { provisionerBotEmail, provisionerBotName } = require('./instance-apex');
|
|
167
167
|
|
|
168
168
|
const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
|
|
169
169
|
|
|
@@ -229,5 +229,6 @@ module.exports = {
|
|
|
229
229
|
numEnv,
|
|
230
230
|
oauthSecret,
|
|
231
231
|
provisionerBotEmail,
|
|
232
|
+
provisionerBotName,
|
|
232
233
|
softFailResult,
|
|
233
234
|
};
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
const path = require('path');
|
|
32
32
|
const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
|
|
33
|
-
const {
|
|
33
|
+
const { appUser, dbName, instanceUser, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
34
34
|
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
35
35
|
|
|
36
36
|
// The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
|
|
@@ -277,19 +277,19 @@ function parsePreflight(out) {
|
|
|
277
277
|
* Which unix account should this instance's upgrade run as? Asks the box (task 1004128).
|
|
278
278
|
*
|
|
279
279
|
* The fleet is heterogeneous: instances provisioned before task 1003369 run as the
|
|
280
|
-
* shared
|
|
280
|
+
* shared app user, instances provisioned after run as their own `bongos-<slug>`. Neither
|
|
281
281
|
* is guessable from the row, and getting it wrong is not a soft failure — `sudo` refuses
|
|
282
282
|
* an unknown user and the upgrade dies before pre-flight, which is exactly how the
|
|
283
283
|
* tenant leg has been broken.
|
|
284
284
|
*
|
|
285
285
|
* Falling back is NOT a privilege escalation: an instance with no account of its own is
|
|
286
|
-
* already running as
|
|
286
|
+
* already running as the app user (its unit says so), so this runs the upgrade as precisely
|
|
287
287
|
* the account that owns the files and the database it is about to touch. It IS reported,
|
|
288
288
|
* because a missing per-instance account is a provisioning gap someone should close, and
|
|
289
289
|
* a silent fallback would hide it forever.
|
|
290
290
|
*/
|
|
291
291
|
function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
|
|
292
|
-
if (inst.hosting_shape === 'control-plane') return { runAs:
|
|
292
|
+
if (inst.hosting_shape === 'control-plane') return { runAs: appUser(), fellBack: false };
|
|
293
293
|
// Re-checked HERE rather than left to instanceUser()'s own throw, for the reason
|
|
294
294
|
// upgradeInvocation states about itself: a call site whose safety depends on a
|
|
295
295
|
// helper's internals is one refactor away from a shell-interpolation path.
|
|
@@ -317,7 +317,7 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
|
|
|
317
317
|
if (!/no such user|unknown user/i.test(said)) {
|
|
318
318
|
return { runAs: wanted, fellBack: false, probeInconclusive: true };
|
|
319
319
|
}
|
|
320
|
-
return { runAs:
|
|
320
|
+
return { runAs: appUser(), fellBack: true, wanted };
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
// ONE BUILDER FOR BOTH LEGS (task 1004121).
|
|
@@ -357,7 +357,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
|
|
|
357
357
|
// half right. The control-plane leg is correct and verified live. The tenant leg
|
|
358
358
|
// assumed the per-instance account model of task 1003369 is provisioned everywhere —
|
|
359
359
|
// and on the live box NO `bongos-*` account exists, no such Postgres role exists, and
|
|
360
|
-
// every instance unit still
|
|
360
|
+
// every instance unit still names the shared app user. So the fix for "Postgres has no role
|
|
361
361
|
// root" swapped one absent account for another, and every tenant upgrade died with
|
|
362
362
|
// `sudo: unknown user bongos-test4`.
|
|
363
363
|
//
|
|
@@ -368,7 +368,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
|
|
|
368
368
|
// (resolveRunAs below) instead of either side assuming. Passing it in keeps this
|
|
369
369
|
// builder pure; the default preserves the old derivation for callers that have not
|
|
370
370
|
// probed.
|
|
371
|
-
const account = runAs || (inst.hosting_shape === 'control-plane' ?
|
|
371
|
+
const account = runAs || (inst.hosting_shape === 'control-plane' ? appUser() : instanceUser(inst));
|
|
372
372
|
const sudoP = privileged ? `sudo -u ${account} env PGDATABASE=${dbName(inst)} ` : '';
|
|
373
373
|
const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
|
|
374
374
|
const shared = [`${sudoP}node ${cli}`, '--registry', `--to ${to}`,
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
const fs = require('node:fs');
|
|
21
21
|
const path = require('node:path');
|
|
22
22
|
const { CONFIG, REPO_ROOT } = require('./provision-config.js');
|
|
23
|
-
const {
|
|
23
|
+
const { CORE_PKG_DIR, appGroupExpr, appUser, instanceUser, standaloneRoot } = require('./provision-repo.js');
|
|
24
24
|
const {
|
|
25
25
|
backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath,
|
|
26
26
|
backupUnitName, serviceUnit, serviceUnitPath,
|
|
@@ -39,7 +39,7 @@ const systemd = {
|
|
|
39
39
|
// The backup DIR belongs to this step, not to the runbook: it exists only to serve the
|
|
40
40
|
// schedule, so a manager that installs no schedule must not create it either.
|
|
41
41
|
installBackup(inst, { exec, writeFile, sudoP }) {
|
|
42
|
-
exec(`${sudoP}install -d -o ${
|
|
42
|
+
exec(`${sudoP}install -d -o ${appUser()} -g ${appGroupExpr()} -m 0750 ${CONFIG.backupDir}`);
|
|
43
43
|
writeFile(backupServicePath(inst), backupService(inst), { mode: 0o644, sudo: true });
|
|
44
44
|
writeFile(backupTimerPath(inst), backupTimer(inst), { mode: 0o644, sudo: true });
|
|
45
45
|
exec(`${sudoP}systemctl daemon-reload`);
|