@bongos/core 1.20.24 → 1.20.25
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 +45 -25
- package/docs/adr/0354-a-builder-hosts-one-project-free-and-more-takes-the-fleet-permission.md +46 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -1
- package/docs/design/project-startup-direction.md +131 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +1 -1
- package/modules/provisioning/free-place-lock.js +52 -0
- package/modules/provisioning/paid-shape-gate.js +90 -30
- package/modules/provisioning/routes/provisioning.js +6 -6
- package/modules/public-landing/public/projects.html +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/src/module-api.js +1 -1
- package/tests/project_catalog_routes.mjs +2 -0
- package/tests/projects_hub_pre_uat.mjs +1 -1
- package/tests/provisioning_capacity_gate.mjs +2 -0
- package/tests/provisioning_domain_attach.mjs +2 -0
- package/tests/provisioning_paid_shape_gate.mjs +43 -11
- package/tests/provisioning_teardown_intent.mjs +2 -0
- package/tests/provisioning_wizard_create_xenos.mjs +381 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// modules/provisioning/paid-shape-gate.js — who may ask for which hosting SHAPE
|
|
2
|
-
//
|
|
1
|
+
// modules/provisioning/paid-shape-gate.js — who may ask for which hosting SHAPE, and how
|
|
2
|
+
// many hosted projects a builder may hold without the fleet permission
|
|
3
|
+
// (task 1003682, widened by task 1003370 / audit B4, one free hosted project by task 1004412).
|
|
3
4
|
//
|
|
4
5
|
// THE FILENAME IS NARROWER THAN THE FILE, DELIBERATELY. This started as the gate over
|
|
5
6
|
// METERED shapes alone. Task 1003370 widened it to every shape that is not the free
|
|
@@ -9,9 +10,9 @@
|
|
|
9
10
|
// clarity for free.
|
|
10
11
|
//
|
|
11
12
|
// The create route is open to any authenticated builder, and it must stay that way:
|
|
12
|
-
// standing up a project is the platform's front door, not a privilege. But only
|
|
13
|
-
// the shapes it accepts
|
|
14
|
-
// something the platform pays for or trusts:
|
|
13
|
+
// standing up a project is the platform's front door, not a privilege. But only two of
|
|
14
|
+
// the shapes it accepts are that front door, and only one of them without limit. The
|
|
15
|
+
// others each hand the caller something the platform pays for or trusts:
|
|
15
16
|
//
|
|
16
17
|
// byo-host — the front door since ADR 0323. The OWNER supplies the host, so the
|
|
17
18
|
// platform pays nothing and runs none of the caller's code. No
|
|
@@ -22,23 +23,23 @@
|
|
|
22
23
|
// co-tenant — RETIRED (ADR 0323). Was the free front door; ran on the platform's
|
|
23
24
|
// own box, which is why the name could not be carried over to
|
|
24
25
|
// bring-your-own-host. Now UNREQUESTABLE — see below.
|
|
25
|
-
// cloud-host —
|
|
26
|
-
//
|
|
27
|
-
//
|
|
26
|
+
// cloud-host — the project's hall, hosted by us (ADR 0345), and the only shape the
|
|
27
|
+
// create wizard sends. It runs the caller's OWN repo on the platform's
|
|
28
|
+
// own box: the unit's WorkingDirectory is that checkout
|
|
28
29
|
// (`scripts/gds/provision-units.js`) and the module loader discovers
|
|
29
30
|
// modules from `resolveInstanceRoot()`, which is process.cwd()
|
|
30
31
|
// (`src/module-loader/loader.js`). So the caller names a repo and the
|
|
31
|
-
// platform require()s its `modules/` —
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// "any Xenos may run code here" an acceptable default.
|
|
32
|
+
// platform require()s its `modules/` — code execution on the control
|
|
33
|
+
// plane's own machine. Task 1003369 gave each instance its own uid,
|
|
34
|
+
// which bounds the blast radius to that account.
|
|
35
35
|
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
36
|
+
// Task 1003370 gated it outright, and that also refused every new user
|
|
37
|
+
// at the wizard's last button (task 1004412): ADR 0345 decision 1 says
|
|
38
|
+
// creating a project needs no account, key or bill. The owner's ruling
|
|
39
|
+
// (2026-09-30, ADR 0354): a builder's FIRST live hosted project needs no
|
|
40
|
+
// authority, and a further one needs the fleet permission. The limit is
|
|
41
|
+
// what stops one person taking every place on the box (ADR 0285). See
|
|
42
|
+
// ONE_FREE_SHAPES below.
|
|
42
43
|
//
|
|
43
44
|
// Stored as 'standalone' until task 1004182 renamed it to match the
|
|
44
45
|
// owner-facing name (migration provisioning_027). The old 'dedicated'
|
|
@@ -54,7 +55,8 @@
|
|
|
54
55
|
// reason this is a middleware over one field rather than a floor on the endpoint.
|
|
55
56
|
//
|
|
56
57
|
// AN ALLOW-LIST, NOT A DENY-LIST — the control-plane-guard.js rule, for the same reason.
|
|
57
|
-
// The
|
|
58
|
+
// The sets named below are the ones that need no authority (OPEN_SHAPES), or none for a
|
|
59
|
+
// builder's first project (ONE_FREE_SHAPES). Every other shape, including
|
|
58
60
|
// one added after this file was last read, requires the permission by omission. The
|
|
59
61
|
// previous shape of this gate listed the one shape that was gated and admitted the rest,
|
|
60
62
|
// and that is exactly how `cloud-host` and `control-plane` stayed open: `cloud-host`
|
|
@@ -79,14 +81,30 @@
|
|
|
79
81
|
'use strict';
|
|
80
82
|
|
|
81
83
|
const provisioning = require('./provisioning.js');
|
|
84
|
+
const { failFrom } = require('./public-refusal');
|
|
82
85
|
|
|
83
|
-
// The shapes any authenticated builder may request with no permission at all
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// provisioning_paid_shape_gate.mjs` pins HOSTING_SHAPES against
|
|
86
|
+
// The shapes any authenticated builder may request with no permission at all, as many
|
|
87
|
+
// times as they like. Deliberately one entry long: byo-host runs on the owner's own
|
|
88
|
+
// host, so it costs the platform nothing and runs none of the caller's code. `tests/
|
|
89
|
+
// provisioning_paid_shape_gate.mjs` pins HOSTING_SHAPES against these sets so a new shape
|
|
87
90
|
// cannot be added without someone deciding, in writing, which side of the line it is on.
|
|
88
91
|
const OPEN_SHAPES = Object.freeze(new Set(['byo-host']));
|
|
89
92
|
|
|
93
|
+
// The shapes a builder may hold ONE live project of with no permission (task 1004412,
|
|
94
|
+
// ADR 0354). A second one needs provisioning.fleet.manage, like any shape outside every
|
|
95
|
+
// set here. cloud-host is the only member: it is the wizard's front door under ADR 0345,
|
|
96
|
+
// and it spends a place on the shared box, of which there are about fifteen (ADR 0285).
|
|
97
|
+
// "Live" is any status but torn_down: a failed or queued project still holds its place,
|
|
98
|
+
// and its owner can retry it (the same slug) or take it offline to free the place.
|
|
99
|
+
const ONE_FREE_SHAPES = Object.freeze(new Set(['cloud-host']));
|
|
100
|
+
|
|
101
|
+
// One first-free request per builder at a time. The count and the insert are two steps
|
|
102
|
+
// with awaits between them, so two requests sent at once would both count zero and both
|
|
103
|
+
// take the free place. The builder's advisory lock is held from before the count until
|
|
104
|
+
// the response closes, so a second request sent meanwhile — from any web process — is
|
|
105
|
+
// refused (the wizard never sends two). ./free-place-lock.js says how it is held.
|
|
106
|
+
const freePlace = require('./free-place-lock');
|
|
107
|
+
|
|
90
108
|
// Shapes that are not requestable through this route AT ALL — no permission admits them,
|
|
91
109
|
// because there is no rank at which "ask the API for a second control plane" is a
|
|
92
110
|
// coherent request. The control-plane row is the platform describing ITSELF; a builder
|
|
@@ -105,6 +123,8 @@ const UNREQUESTABLE_SHAPES = Object.freeze(new Set(['control-plane', 'co-tenant'
|
|
|
105
123
|
|
|
106
124
|
// shapeAuthority — what does this wire value demand? PURE. Returns one of:
|
|
107
125
|
// 'open' → admit with no permission
|
|
126
|
+
// 'one-free' → admit the caller's first live project of the shape; after that,
|
|
127
|
+
// require provisioning.fleet.manage
|
|
108
128
|
// 'unrequestable' → refuse outright, whatever the caller holds
|
|
109
129
|
// 'permission' → require provisioning.fleet.manage
|
|
110
130
|
// 'defer' → not a shape this gate recognises; let the route's own validator
|
|
@@ -128,18 +148,58 @@ function shapeAuthority(rawShape) {
|
|
|
128
148
|
if (!provisioning.HOSTING_SHAPES.includes(shape)) return 'defer';
|
|
129
149
|
if (UNREQUESTABLE_SHAPES.has(shape)) return 'unrequestable';
|
|
130
150
|
if (OPEN_SHAPES.has(shape)) return 'open';
|
|
151
|
+
if (ONE_FREE_SHAPES.has(shape)) return 'one-free';
|
|
131
152
|
return 'permission';
|
|
132
153
|
}
|
|
133
154
|
|
|
155
|
+
// otherLiveProjects — the caller's own projects of this shape that still hold their place,
|
|
156
|
+
// leaving out the slug being asked for: a retry or revive of your own project is that
|
|
157
|
+
// project again, not a second one. The slug is normalised exactly as the route does.
|
|
158
|
+
// Reads provisioning through its module object at request time so a test can re-point it.
|
|
159
|
+
async function otherLiveProjects(db, builderId, shape, rawSlug) {
|
|
160
|
+
const slug = String(rawSlug || '').trim().toLowerCase();
|
|
161
|
+
const rows = await provisioning.listInstancesForOwner(db, builderId);
|
|
162
|
+
return rows.filter((r) => r.hosting_shape === shape && r.status !== 'torn_down' && r.slug !== slug);
|
|
163
|
+
}
|
|
164
|
+
|
|
134
165
|
// requireAuthorityForShape — the create route's shape gate. Mount it AFTER requireBuilder
|
|
135
166
|
// and BEFORE the handler, so a refusal leaves no row behind for the runner to drain later.
|
|
136
167
|
// `requirePermission` is injected (rather than required here) so the gate is exercisable
|
|
137
|
-
// without standing up the auth stack.
|
|
138
|
-
function requireAuthorityForShape(requirePermission) {
|
|
168
|
+
// without standing up the auth stack; `db` and `log` serve the one-free count.
|
|
169
|
+
function requireAuthorityForShape(requirePermission, db, log) {
|
|
139
170
|
const gate = requirePermission('provisioning.fleet.manage');
|
|
140
|
-
return function shapeGate(req, res, next) {
|
|
171
|
+
return async function shapeGate(req, res, next) {
|
|
141
172
|
const verdict = shapeAuthority(req.body && req.body.hosting_shape);
|
|
142
173
|
if (verdict === 'open' || verdict === 'defer') return next();
|
|
174
|
+
if (verdict === 'one-free') {
|
|
175
|
+
let others;
|
|
176
|
+
try {
|
|
177
|
+
// Read through the module object at request time so a test can re-point it.
|
|
178
|
+
const release = await freePlace.holdFreePlace(db, req.builder.id);
|
|
179
|
+
if (!release) {
|
|
180
|
+
return res.fail('create_in_progress', {
|
|
181
|
+
status: 409,
|
|
182
|
+
message: 'Another project request from this account is still being answered. Try again once it finishes.',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
// Held until the row exists: the handler calls req.releaseFreePlace right after its
|
|
186
|
+
// insert, so the lock's connection is not kept through the rest of the request.
|
|
187
|
+
// Every earlier exit (a refusal, a validation 400, a thrown error, a client that
|
|
188
|
+
// hangs up) releases on 'close' instead — 'close', not 'finish', because 'finish'
|
|
189
|
+
// never fires for a response the client abandoned. Release is idempotent.
|
|
190
|
+
req.releaseFreePlace = release;
|
|
191
|
+
res.once('close', () => { release().catch(() => {}); });
|
|
192
|
+
others = await otherLiveProjects(db, req.builder.id, req.body.hosting_shape, req.body.slug);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
// Fail CLOSED (ADR 0016): a lock or count that could not be read is not a free place.
|
|
195
|
+
log.error('[provisioning] shape gate could not lock or count the caller\'s projects; refusing', err);
|
|
196
|
+
return failFrom(res, err, 'request_failed');
|
|
197
|
+
}
|
|
198
|
+
if (others.length === 0) return next();
|
|
199
|
+
// A further live project: the same permission as any gated shape, and the kernel
|
|
200
|
+
// gate's own refusal (permission_forbidden, naming the atom) is the answer.
|
|
201
|
+
return gate(req, res, next);
|
|
202
|
+
}
|
|
143
203
|
if (verdict === 'unrequestable') {
|
|
144
204
|
// Two shapes land here for different reasons, so the message names which.
|
|
145
205
|
//
|
|
@@ -167,8 +227,8 @@ function requireAuthorityForShape(requirePermission) {
|
|
|
167
227
|
};
|
|
168
228
|
}
|
|
169
229
|
|
|
170
|
-
// Only the gate is exported. OPEN_SHAPES, UNREQUESTABLE_SHAPES and
|
|
171
|
-
// deliberately private: nothing outside this file needs to ask "what
|
|
172
|
-
// demand" separately from being gated on it, and an export nobody
|
|
173
|
-
// thing to keep true.
|
|
230
|
+
// Only the gate is exported. OPEN_SHAPES, ONE_FREE_SHAPES, UNREQUESTABLE_SHAPES and
|
|
231
|
+
// shapeAuthority are deliberately private: nothing outside this file needs to ask "what
|
|
232
|
+
// does this shape demand" separately from being gated on it, and an export nobody
|
|
233
|
+
// consumes is one more thing to keep true.
|
|
174
234
|
module.exports = { requireAuthorityForShape };
|
|
@@ -285,14 +285,13 @@ module.exports = function provisioningRoutes() {
|
|
|
285
285
|
});
|
|
286
286
|
|
|
287
287
|
// POST /provisioning/instances — request a new instance. rank: any authenticated
|
|
288
|
-
// builder (own resource) for
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
// since the platform enrolls its own row (../paid-shape-gate.js, task 1003370 / audit B4).
|
|
288
|
+
// builder (own resource) for `byo-host`, and for their FIRST live `cloud-host` (task
|
|
289
|
+
// 1004412, ADR 0354). A further cloud-host needs provisioning.fleet.manage, and
|
|
290
|
+
// `control-plane` / `co-tenant` are refused outright (../paid-shape-gate.js).
|
|
292
291
|
// A full shared box answers 409 box_full up front (../capacity-gate.js).
|
|
293
292
|
// ENQUEUES a 'provision' intent the runner (task P3) stands up. Idempotent on slug for the SAME
|
|
294
293
|
// owner (re-request returns the existing row); a slug owned by ANOTHER builder → 409.
|
|
295
|
-
// Body: { slug (required), target_ref?, hosting_shape? (
|
|
294
|
+
// Body: { slug (required), target_ref?, hosting_shape? (byo-host free; one cloud-host free),
|
|
296
295
|
// domain?, tier?, onboard_mode? (greenfield|adopt) }.
|
|
297
296
|
//
|
|
298
297
|
// onboard_mode is LOAD-BEARING, not a label (task 1002562): the control-plane runner
|
|
@@ -300,7 +299,7 @@ module.exports = function provisioningRoutes() {
|
|
|
300
299
|
// ALREADY has, so the runner must clone + layer onto its history instead of pushing a
|
|
301
300
|
// fresh tree at `main` (which an existing repo rejects as unrelated history). Absent →
|
|
302
301
|
// 'greenfield', the safe default.
|
|
303
|
-
router.post('/provisioning/instances', auth.requireBuilder, requireAuthorityForShape(auth.requirePermission), requireRoomOnBox(pool, log), async (req, res) => {
|
|
302
|
+
router.post('/provisioning/instances', auth.requireBuilder, requireAuthorityForShape(auth.requirePermission, pool, log), requireRoomOnBox(pool, log), async (req, res) => {
|
|
304
303
|
if (validateOrRespond(req, res, {
|
|
305
304
|
slug: { required: true, type: 'string', maxLength: 40 },
|
|
306
305
|
target_ref: { type: 'string', maxLength: 200 },
|
|
@@ -427,6 +426,7 @@ module.exports = function provisioningRoutes() {
|
|
|
427
426
|
// with the same standing: skipped, and resolved from the bundle on read.
|
|
428
427
|
modules: body.modules,
|
|
429
428
|
});
|
|
429
|
+
if (req.releaseFreePlace) await req.releaseFreePlace(); // the row exists: the one-free lock's work is done (../free-place-lock.js)
|
|
430
430
|
// Slug already taken by ANOTHER builder — refuse (never reassign ownership).
|
|
431
431
|
if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
|
|
432
432
|
return res.fail('slug_taken', { status: 409, message: 'That instance slug is already in use.' });
|
|
@@ -4868,6 +4868,13 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4868
4868
|
if (code === 'bad_domain') return 'The domain didn’t parse as a real hostname — use something like myproject.example.com.';
|
|
4869
4869
|
if (code === 'repo_scope_required') return 'GitHub hasn’t granted repository access for this session. Sign in again — it asks for repository access — and try once more.';
|
|
4870
4870
|
if (code === 'request_failed') return 'The platform couldn’t file the request. Nothing was created — try again in a moment.';
|
|
4871
|
+
/* the shape gate (task 1004412): an account hosts ONE project without the fleet
|
|
4872
|
+
permission, and this wizard only asks for a hosted project, so a refusal naming
|
|
4873
|
+
that permission means "you already have one". held is null when authority could
|
|
4874
|
+
not be checked at all: a blip, so it falls to the generic sentence instead. */
|
|
4875
|
+
var details = code ? err.data.error.details : null;
|
|
4876
|
+
if (code === 'permission_forbidden' && details && Array.isArray(details.held)) return 'Each account can host one project for free, and yours already has one. To start a new one, take the old one offline from its page under Your projects. Trusted builders can host more than one — ask the platform team if you need that.';
|
|
4877
|
+
if (code === 'box_full') return 'Our shared server is full right now, so there’s no room for a new project. Nothing was created — try again later.';
|
|
4871
4878
|
return '';
|
|
4872
4879
|
}
|
|
4873
4880
|
function createFailureMarkup(err) {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.25",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.25",
|
|
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.20.
|
|
3
|
+
"version": "1.20.25",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -8200,5 +8200,15 @@
|
|
|
8200
8200
|
"id": "1004428",
|
|
8201
8201
|
"text": "The plan for the new project-startup experience is agreed and written down. New projects begin with a demo-or-create choice and seven planet questions, and each project picks its own colours and logo. While a project is being"
|
|
8202
8202
|
}
|
|
8203
|
+
],
|
|
8204
|
+
"1.20.25": [
|
|
8205
|
+
{
|
|
8206
|
+
"id": "1004415",
|
|
8207
|
+
"text": "The project-startup design is now saved in the project as the official reference. It sets out screen by screen what the current builders hall keeps and what founding mode adds, so the new design never takes over the existing h"
|
|
8208
|
+
},
|
|
8209
|
+
{
|
|
8210
|
+
"id": "1004412",
|
|
8211
|
+
"text": "New users can create a project again. Since 19 September, anyone new who pressed \"Create my project\" was turned away with a vague error, because hosted projects had been limited to trusted builders. Now everyone who is signed"
|
|
8212
|
+
}
|
|
8203
8213
|
]
|
|
8204
8214
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.25'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -52,6 +52,8 @@ let healed = [];
|
|
|
52
52
|
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
53
53
|
provisioning.getInstanceBySlug = async (_db, slug) => (instance && instance.slug === slug ? { ...instance } : null);
|
|
54
54
|
provisioning.getInstanceByDomain = async () => null;
|
|
55
|
+
provisioning.listInstancesForOwner = async () => []; // the shape gate's count (task 1004412): no other hosted project
|
|
56
|
+
require('../modules/provisioning/free-place-lock.js').holdFreePlace = async () => async () => {}; // and its lock: always free here
|
|
55
57
|
provisioning.createInstance = async (_db, req) => {
|
|
56
58
|
instance = { ...BASE_ROW, slug: req.slug, domain: req.domain || null, description: req.description || null };
|
|
57
59
|
return { instance: { ...instance }, created: true };
|
|
@@ -508,7 +508,7 @@ test('an unmapped create failure gets a plain sentence with the raw text folded
|
|
|
508
508
|
const friendly = fn('friendlyCreateError');
|
|
509
509
|
assert.match(friendly, /return '';\n\s*\}/, 'unmapped codes return nothing — the caller folds them');
|
|
510
510
|
assert.doesNotMatch(friendly, /Couldn’t file the request: ' \+/, 'the raw message is no longer the headline');
|
|
511
|
-
for (const code of ['slug_taken', 'bad_slug', 'domain_reserved', 'domain_taken', 'bad_domain', 'repo_scope_required', 'request_failed']) {
|
|
511
|
+
for (const code of ['slug_taken', 'bad_slug', 'domain_reserved', 'domain_taken', 'bad_domain', 'repo_scope_required', 'request_failed', 'permission_forbidden', 'box_full']) {
|
|
512
512
|
assert.match(friendly, new RegExp(`code === '${code}'`), `${code} stays headline-only`);
|
|
513
513
|
}
|
|
514
514
|
assert.match(friendly, /belongs to the platform’s own domain/, 'domain_reserved covers both server branches');
|
|
@@ -48,6 +48,8 @@ capacity.fleetCapacity = async () => {
|
|
|
48
48
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
49
49
|
let existing = null, lastCreateArgs = null;
|
|
50
50
|
provisioning.getInstanceBySlug = async () => existing;
|
|
51
|
+
provisioning.listInstancesForOwner = async () => []; // the shape gate's count (task 1004412): no other hosted project
|
|
52
|
+
require('../modules/provisioning/free-place-lock.js').holdFreePlace = async () => async () => {}; // and its lock: always free here
|
|
51
53
|
provisioning.createInstance = async (_db, args) => {
|
|
52
54
|
lastCreateArgs = args;
|
|
53
55
|
if (existing) return { instance: existing, created: false };
|
|
@@ -118,6 +118,8 @@ provisioning.enqueueIntent = async (_db, id, action, requestedBy) => {
|
|
|
118
118
|
provisioning.getOpenIntent = async () => null;
|
|
119
119
|
provisioning.createInstance = async () => createResult;
|
|
120
120
|
provisioning.getInstanceBySlug = async () => null; // the task-1002704 birth-time pre-check
|
|
121
|
+
provisioning.listInstancesForOwner = async () => []; // the shape gate's count (task 1004412): no other hosted project
|
|
122
|
+
require('../modules/provisioning/free-place-lock.js').holdFreePlace = async () => async () => {}; // and its lock: always free here
|
|
121
123
|
provisioning.recordEvent = async () => {};
|
|
122
124
|
provisioning.createOAuthManifest = async (_db, row) => { manifestRows.push(row); return { id: 1, ...row }; };
|
|
123
125
|
provisioning.getLatestOAuthManifestForInstance = async () => null;
|
|
@@ -37,6 +37,13 @@
|
|
|
37
37
|
// So the set below is now the set that needs NO authority, and a shape added later is gated
|
|
38
38
|
// by omission instead of admitted by it.
|
|
39
39
|
//
|
|
40
|
+
// NARROWED FOR ONE PROJECT BY TASK 1004412 (ADR 0354). Gating cloud-host outright also
|
|
41
|
+
// refused every new user at the create wizard's last button, because the wizard only ever
|
|
42
|
+
// asks for cloud-host, and ADR 0345 made that shape the free front door. The owner's ruling:
|
|
43
|
+
// a builder's FIRST live hosted project needs no permission, and a further one needs
|
|
44
|
+
// provisioning.fleet.manage. The route-level proof with a real Xenos is
|
|
45
|
+
// tests/provisioning_wizard_create_xenos.mjs; the cases below hold the gate's side.
|
|
46
|
+
//
|
|
40
47
|
// DB-free: the pool is never reached (createInstance is stubbed), auth is faked. No network
|
|
41
48
|
// beyond a loopback listener.
|
|
42
49
|
//
|
|
@@ -65,6 +72,13 @@ provisioning.createInstance = async (_db, args) => {
|
|
|
65
72
|
provisioning.enqueueIntent = async () => ({ intent: { id: 1, state: 'pending' }, created: true });
|
|
66
73
|
provisioning.recordEvent = async () => {};
|
|
67
74
|
provisioning.getInstanceBySlug = async () => null;
|
|
75
|
+
// The caller's own projects, which the one-free count reads (task 1004412). Flipped per case.
|
|
76
|
+
let owned = [];
|
|
77
|
+
provisioning.listInstancesForOwner = async () => owned;
|
|
78
|
+
// …and the free-place lock beside it (free-place-lock.js), always free here: the race is
|
|
79
|
+
// proved in tests/provisioning_wizard_create_xenos.mjs.
|
|
80
|
+
require('../modules/provisioning/free-place-lock.js').holdFreePlace = async () => async () => {};
|
|
81
|
+
const LIVE_HOSTED = { id: 9, owner_builder_id: 7, slug: 'older', hosting_shape: 'cloud-host', status: 'active' };
|
|
68
82
|
|
|
69
83
|
const api = require('../src/module-api.js');
|
|
70
84
|
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
@@ -189,28 +203,42 @@ await ta('the refusal speaks WIRE vocabulary — no owner-facing label is transl
|
|
|
189
203
|
'a refusal that names no alternative is a dead end — the stored values are the alternative');
|
|
190
204
|
});
|
|
191
205
|
|
|
192
|
-
// ── cloud-host runs the caller's OWN code on the platform's box
|
|
206
|
+
// ── cloud-host runs the caller's OWN code on the platform's box ───────────────────
|
|
207
|
+
// Task 1003370 gated it outright; task 1004412 lets each builder host ONE project free.
|
|
193
208
|
|
|
194
|
-
await ta('cloud-host:
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
// repo on your control plane". The uid split (task 1003369) bounds the blast radius;
|
|
200
|
-
// it does not make the default acceptable.
|
|
209
|
+
await ta('cloud-host: a builder\'s FIRST live hosted project needs no permission (task 1004412)', async () => {
|
|
210
|
+
// The wizard's front door (ADR 0345). The trust question task 1003370 raised is still
|
|
211
|
+
// true — the unit's WorkingDirectory is the caller's own checkout, so the platform
|
|
212
|
+
// require()s their repo, bounded by the per-instance uid (task 1003369) — and the owner
|
|
213
|
+
// accepted it for one project per builder (ADR 0354).
|
|
201
214
|
held = [];
|
|
215
|
+
owned = [];
|
|
202
216
|
lastCreateArgs = null;
|
|
203
217
|
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
|
|
218
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
219
|
+
assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'cloud-host');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await ta('cloud-host: a SECOND live hosted project is REFUSED without provisioning.fleet.manage', async () => {
|
|
223
|
+
// The limit is what stops one person taking every place on the shared box (ADR 0285).
|
|
224
|
+
// The refusal is the kernel permission gate's own, naming the atom.
|
|
225
|
+
held = [];
|
|
226
|
+
owned = [LIVE_HOSTED];
|
|
227
|
+
lastCreateArgs = null;
|
|
228
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
|
|
229
|
+
owned = [];
|
|
204
230
|
assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
|
|
205
231
|
assert.equal(res.body.error.code, 'permission_forbidden');
|
|
206
232
|
assert.deepEqual(res.body.error.details.required, ['provisioning.fleet.manage']);
|
|
207
233
|
assert.equal(lastCreateArgs, null, 'no row is created when the shape is refused');
|
|
208
234
|
});
|
|
209
235
|
|
|
210
|
-
await ta('cloud-host: ADMITTED for a builder holding provisioning.fleet.manage', async () => {
|
|
236
|
+
await ta('cloud-host: ADMITTED past the limit for a builder holding provisioning.fleet.manage', async () => {
|
|
211
237
|
held = ['provisioning.fleet.manage'];
|
|
238
|
+
owned = [LIVE_HOSTED, { ...LIVE_HOSTED, slug: 'oldest' }];
|
|
212
239
|
lastCreateArgs = null;
|
|
213
240
|
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
|
|
241
|
+
owned = [];
|
|
214
242
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
215
243
|
assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'cloud-host');
|
|
216
244
|
});
|
|
@@ -271,8 +299,11 @@ await ta('the gate refuses BEFORE the row exists — a denied create leaves noth
|
|
|
271
299
|
// The denial must land ahead of createInstance, not after it: a refused-but-inserted row
|
|
272
300
|
// would still be drained by the runner, which reads the column, not the HTTP status.
|
|
273
301
|
held = [];
|
|
302
|
+
owned = [LIVE_HOSTED]; // a second hosted project, so the shape is refused
|
|
274
303
|
lastCreateArgs = null;
|
|
275
|
-
await post('/provisioning/instances', { slug: 'spendy', hosting_shape: 'cloud-host', no_address: true });
|
|
304
|
+
const res = await post('/provisioning/instances', { slug: 'spendy', hosting_shape: 'cloud-host', no_address: true });
|
|
305
|
+
owned = [];
|
|
306
|
+
assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
|
|
276
307
|
assert.equal(lastCreateArgs, null);
|
|
277
308
|
});
|
|
278
309
|
|
|
@@ -301,7 +332,8 @@ await ta('HOSTING_SHAPES is pinned, so a new shape forces a written decision abo
|
|
|
301
332
|
// POST for a second control plane until task 1003370.
|
|
302
333
|
//
|
|
303
334
|
// So the question this list now forces is the one that generalises. The gate is an
|
|
304
|
-
// allow-list: `byo-host` needs nothing, `
|
|
335
|
+
// allow-list: `byo-host` needs nothing, `cloud-host` needs nothing for a builder's first
|
|
336
|
+
// live project (task 1004412), `control-plane` and `co-tenant` are refused
|
|
305
337
|
// outright (for different reasons — never-yours vs retired), and
|
|
306
338
|
// EVERY other shape — including one added after this file was last read — demands
|
|
307
339
|
// provisioning.fleet.manage by omission. A fifth shape therefore arrives already
|
|
@@ -187,6 +187,8 @@ provisioning.enqueueIntent = async (_db, id, action, requestedBy) => {
|
|
|
187
187
|
provisioning.getOpenIntent = async () => null;
|
|
188
188
|
provisioning.createInstance = async () => createResult;
|
|
189
189
|
provisioning.getInstanceBySlug = async () => null; // the task-1002704 birth-time pre-check
|
|
190
|
+
provisioning.listInstancesForOwner = async () => []; // the shape gate's count (task 1004412): no other hosted project
|
|
191
|
+
require('../modules/provisioning/free-place-lock.js').holdFreePlace = async () => async () => {}; // and its lock: always free here
|
|
190
192
|
provisioning.recordEvent = async () => {};
|
|
191
193
|
|
|
192
194
|
const api = require('../src/module-api.js');
|