@bongos/core 1.19.885 → 1.19.887
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 +16 -16
- package/docs/api/openapi.json +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/paid-shape-gate.js +106 -31
- package/modules/provisioning/routes/provisioning.js +12 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/gen-session-index.js +8 -1
- package/src/module-api.js +1 -1
- package/tests/provisioning_module_selection.mjs +9 -9
- package/tests/provisioning_onboard_mode.mjs +5 -5
- package/tests/provisioning_paid_shape_gate.mjs +101 -24
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.887",
|
|
6
|
+
"core_contract": "1.19.887",
|
|
7
|
+
"source_commit": "b874dffd8907b22a633526eddcb279c009c89b40",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-19T20:48:51.300Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 497,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 2787,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "920b5751e940860113c8a2a84ede022e547e1901077f62330213144c8612091b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2052,7 +2052,7 @@
|
|
|
2052
2052
|
{
|
|
2053
2053
|
"path": "docs/api/openapi.json",
|
|
2054
2054
|
"mode": "0000644",
|
|
2055
|
-
"sha256": "
|
|
2055
|
+
"sha256": "37732622e53cde0e4ee6bb0133ef6bade2a862f7d6337c40723b94fbf6ae6c22"
|
|
2056
2056
|
},
|
|
2057
2057
|
{
|
|
2058
2058
|
"path": "docs/architecture.md",
|
|
@@ -2607,7 +2607,7 @@
|
|
|
2607
2607
|
{
|
|
2608
2608
|
"path": "docs/module-api-changelog.md",
|
|
2609
2609
|
"mode": "0000644",
|
|
2610
|
-
"sha256": "
|
|
2610
|
+
"sha256": "31fdd25f81e095205720c92ce31100187f7d1c1c89eecc8b9c84f00936350352"
|
|
2611
2611
|
},
|
|
2612
2612
|
{
|
|
2613
2613
|
"path": "docs/modules-contract.md",
|
|
@@ -6312,7 +6312,7 @@
|
|
|
6312
6312
|
{
|
|
6313
6313
|
"path": "modules/provisioning/paid-shape-gate.js",
|
|
6314
6314
|
"mode": "0000644",
|
|
6315
|
-
"sha256": "
|
|
6315
|
+
"sha256": "7c08e627e228bec400e9e2dc204418a6995a4668eb6c1817ae59f20f77cbe71e"
|
|
6316
6316
|
},
|
|
6317
6317
|
{
|
|
6318
6318
|
"path": "modules/provisioning/pollers/catalog-backfill.js",
|
|
@@ -6367,7 +6367,7 @@
|
|
|
6367
6367
|
{
|
|
6368
6368
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
6369
6369
|
"mode": "0000644",
|
|
6370
|
-
"sha256": "
|
|
6370
|
+
"sha256": "63919aad35ff1f47c4981e5e5c1f1c08d79eb0de9588e0c3ba2c5ed488167211"
|
|
6371
6371
|
},
|
|
6372
6372
|
{
|
|
6373
6373
|
"path": "modules/provisioning/seams.js",
|
|
@@ -7692,12 +7692,12 @@
|
|
|
7692
7692
|
{
|
|
7693
7693
|
"path": "package-lock.json",
|
|
7694
7694
|
"mode": "0000644",
|
|
7695
|
-
"sha256": "
|
|
7695
|
+
"sha256": "356149fe3c3c69d00f16886662e5fee0ab51c4be066af64156a2b3bb7e14c69c"
|
|
7696
7696
|
},
|
|
7697
7697
|
{
|
|
7698
7698
|
"path": "package.json",
|
|
7699
7699
|
"mode": "0000644",
|
|
7700
|
-
"sha256": "
|
|
7700
|
+
"sha256": "9e2d057866feafc0e9606c36335013a47ad55190c03e7beed63d1e592f2fd23a"
|
|
7701
7701
|
},
|
|
7702
7702
|
{
|
|
7703
7703
|
"path": "public-docs/index.html",
|
|
@@ -8372,7 +8372,7 @@
|
|
|
8372
8372
|
{
|
|
8373
8373
|
"path": "scripts/gds/gen-session-index.js",
|
|
8374
8374
|
"mode": "0000644",
|
|
8375
|
-
"sha256": "
|
|
8375
|
+
"sha256": "8b379f5392999fdf9977e1f4d9a43d4f2d77c093075d9933773a66eb15228252"
|
|
8376
8376
|
},
|
|
8377
8377
|
{
|
|
8378
8378
|
"path": "scripts/gds/gen-sounds.js",
|
|
@@ -9702,7 +9702,7 @@
|
|
|
9702
9702
|
{
|
|
9703
9703
|
"path": "src/module-api.js",
|
|
9704
9704
|
"mode": "0000644",
|
|
9705
|
-
"sha256": "
|
|
9705
|
+
"sha256": "309fd0ddeb0185da023052aaab8967162dbdb833ade7226f49e1636440146e83"
|
|
9706
9706
|
},
|
|
9707
9707
|
{
|
|
9708
9708
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12727,17 +12727,17 @@
|
|
|
12727
12727
|
{
|
|
12728
12728
|
"path": "tests/provisioning_module_selection.mjs",
|
|
12729
12729
|
"mode": "0000644",
|
|
12730
|
-
"sha256": "
|
|
12730
|
+
"sha256": "540e9b5b158a5623525924403bcef444f87fe0364591cd9c6b9e0a55f4f6295c"
|
|
12731
12731
|
},
|
|
12732
12732
|
{
|
|
12733
12733
|
"path": "tests/provisioning_onboard_mode.mjs",
|
|
12734
12734
|
"mode": "0000644",
|
|
12735
|
-
"sha256": "
|
|
12735
|
+
"sha256": "f7a01aa6ed190c1ad544d91312909b081446a7fc2b6ef339f3ba8fa4d59f0306"
|
|
12736
12736
|
},
|
|
12737
12737
|
{
|
|
12738
12738
|
"path": "tests/provisioning_paid_shape_gate.mjs",
|
|
12739
12739
|
"mode": "0000644",
|
|
12740
|
-
"sha256": "
|
|
12740
|
+
"sha256": "19961880545fe8f6c9eaf79095aa0d08f7862409109d581cac828f0f20b1be69"
|
|
12741
12741
|
},
|
|
12742
12742
|
{
|
|
12743
12743
|
"path": "tests/provisioning_recommendations.mjs",
|
package/docs/api/openapi.json
CHANGED
|
@@ -12672,7 +12672,7 @@
|
|
|
12672
12672
|
"provisioning"
|
|
12673
12673
|
],
|
|
12674
12674
|
"summary": "POST /provisioning/instances",
|
|
12675
|
-
"description": "POST /provisioning/instances — request a new instance. rank: any authenticated builder (own resource)
|
|
12675
|
+
"description": "POST /provisioning/instances — request a new instance. rank: any authenticated builder (own resource) for the free `co-tenant` shape. Every other shape also needs provisioning.fleet.manage — `dedicated` because it bills, `standalone` because it runs the caller's own repo on the platform's box — and `control-plane` is refused outright, since the platform enrolls its own row (../paid-shape-gate.js, task 1003370 / audit B4). A full shared box answers 409 box_full up front (../capacity-gate.js). ENQUEUES a 'provision' intent the runner (task P3) stands up. Idempotent on slug for the SAME owner (re-request returns the existing row); a slug owned by ANOTHER builder → 409. Body: { slug (required), target_ref?, hosting_shape? (co-tenant free; others gated), domain?, tier?, onboard_mode? (greenfield|adopt) }. onboard_mode is LOAD-BEARING, not a label (task 1002562): the control-plane runner branches its repo scaffold on it. 'adopt' means target_ref is a repo the owner ALREADY has, so the runner must clone + layer onto its history instead of pushing a fresh tree at `main` (which an existing repo rejects as unrelated history). Absent → 'greenfield', the safe default.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
12676
12676
|
"x-rank": "any-builder",
|
|
12677
12677
|
"x-source": "modules/provisioning/routes/provisioning.js",
|
|
12678
12678
|
"requestBody": {
|
|
@@ -2244,5 +2244,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2244
2244
|
landed since 1.19.883 with no explicit bump. run 35464075741. (task 1002620)
|
|
2245
2245
|
1.19.885 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2246
2246
|
landed since 1.19.884 with no explicit bump. run 35464802278. (task 1002620)
|
|
2247
|
+
1.19.886 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2248
|
+
landed since 1.19.885 with no explicit bump. run 35467975408. (task 1002620)
|
|
2249
|
+
1.19.887 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2250
|
+
landed since 1.19.886 with no explicit bump. run 35468511941. (task 1002620)
|
|
2247
2251
|
---------------------------------------------------------------------------
|
|
2248
2252
|
```
|
|
@@ -1,21 +1,54 @@
|
|
|
1
|
-
// modules/provisioning/paid-shape-gate.js — who may ask for
|
|
2
|
-
// (task 1003682).
|
|
1
|
+
// modules/provisioning/paid-shape-gate.js — who may ask for which hosting SHAPE
|
|
2
|
+
// (task 1003682, widened by task 1003370 / audit B4).
|
|
3
|
+
//
|
|
4
|
+
// THE FILENAME IS NARROWER THAN THE FILE, DELIBERATELY. This started as the gate over
|
|
5
|
+
// METERED shapes alone. Task 1003370 widened it to every shape that is not the free
|
|
6
|
+
// front door, and the name was kept because ADR 0285 links to it by path and four
|
|
7
|
+
// sibling files cite it by name as the precedent for living in their own file. A rename
|
|
8
|
+
// would have broken those links to buy a better noun; this paragraph buys the same
|
|
9
|
+
// clarity for free.
|
|
3
10
|
//
|
|
4
11
|
// The create route is open to any authenticated builder, and it must stay that way:
|
|
5
|
-
// standing up a project is the platform's front door, not a privilege. But
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
12
|
+
// standing up a project is the platform's front door, not a privilege. But only ONE of
|
|
13
|
+
// the shapes it accepts is actually that front door. The others each hand the caller
|
|
14
|
+
// something the platform pays for or trusts:
|
|
15
|
+
//
|
|
16
|
+
// co-tenant — the front door. Co-tenants on infrastructure the platform already
|
|
17
|
+
// runs; `costEstimateUsd` is 0. No authority required, ever.
|
|
18
|
+
// dedicated — allocates a real droplet (`scripts/gds/provision.js`), billed
|
|
19
|
+
// monthly, defaulting to s-2vcpu-4gb when no tier is named.
|
|
20
|
+
// standalone — runs the caller's OWN repo on the platform's box. The unit's
|
|
21
|
+
// WorkingDirectory is that checkout (`scripts/gds/provision-units.js`)
|
|
22
|
+
// and the module loader discovers modules from `resolveInstanceRoot()`,
|
|
23
|
+
// which is process.cwd() (`src/module-loader/loader.js`). So the
|
|
24
|
+
// caller names a repo and the platform require()s its `modules/` —
|
|
25
|
+
// arbitrary code execution on the control plane's own machine. Task
|
|
26
|
+
// 1003369 gave each instance its own uid, which bounds the blast
|
|
27
|
+
// radius to that account; it does not make "any Xenos may run code
|
|
28
|
+
// here" an acceptable default.
|
|
29
|
+
// control-plane — the platform's OWN row (ADR 0293 D2, task 1003159), enrolled by
|
|
30
|
+
// `scripts/gds/enroll-control-plane.js` so the owner's deploy door has
|
|
31
|
+
// something to point at. Never a thing a builder asks for. See below.
|
|
10
32
|
//
|
|
11
33
|
// So the authority question belongs to the SHAPE, not the route. Gating the whole route
|
|
12
34
|
// would make ordinary project creation require a rank — the wrong trade entirely, and the
|
|
13
35
|
// reason this is a middleware over one field rather than a floor on the endpoint.
|
|
14
36
|
//
|
|
37
|
+
// AN ALLOW-LIST, NOT A DENY-LIST — the control-plane-guard.js rule, for the same reason.
|
|
38
|
+
// The set named below is the set that needs NO authority. Every other shape, including
|
|
39
|
+
// one added after this file was last read, requires the permission by omission. The
|
|
40
|
+
// previous shape of this gate listed the one shape that was gated and admitted the rest,
|
|
41
|
+
// and that is exactly how `standalone` and `control-plane` stayed open: `standalone`
|
|
42
|
+
// because it was reasoned about as "$0, therefore harmless" (true of the invoice, false
|
|
43
|
+
// of the trust), and `control-plane` because it did not exist yet when the gate was
|
|
44
|
+
// written. Permitted-by-omission is the failure mode this inversion removes.
|
|
45
|
+
//
|
|
15
46
|
// The atom is `provisioning.fleet.manage` (floor: metic), deliberately the same one that
|
|
16
47
|
// already guards the fleet cost-ledger and force-teardown. Spending on the fleet and
|
|
17
|
-
// managing the fleet are one authority, and before
|
|
18
|
-
// less gated than the route that stops it.
|
|
48
|
+
// managing the fleet are one authority, and before task 1003682 the route that SPENDS
|
|
49
|
+
// money was less gated than the route that stops it. Task 1003370 adds no new atom: the
|
|
50
|
+
// question "may this caller commit the platform's own resources" is the same question
|
|
51
|
+
// whether the resource is a droplet or the control plane's CPU.
|
|
19
52
|
//
|
|
20
53
|
// Split into its own file rather than inlined beside the route for two reasons, both
|
|
21
54
|
// mechanical: `routes/provisioning.js` sits at its 1500-line fitness ratchet (the same
|
|
@@ -25,33 +58,75 @@
|
|
|
25
58
|
// write route" failure on an unrelated endpoint.
|
|
26
59
|
'use strict';
|
|
27
60
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
61
|
+
const provisioning = require('./provisioning.js');
|
|
62
|
+
|
|
63
|
+
// The shapes any authenticated builder may request with no permission at all. This is
|
|
64
|
+
// the whole allow-list, and it is deliberately one entry long: co-tenant is the front
|
|
65
|
+
// door, and the front door is the only thing that should be free. `tests/
|
|
66
|
+
// provisioning_paid_shape_gate.mjs` pins HOSTING_SHAPES against this set so a new shape
|
|
67
|
+
// cannot be added without someone deciding, in writing, which side of the line it is on.
|
|
68
|
+
const OPEN_SHAPES = Object.freeze(new Set(['co-tenant']));
|
|
33
69
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
70
|
+
// Shapes that are not requestable through this route AT ALL — no permission admits them,
|
|
71
|
+
// because there is no rank at which "ask the API for a second control plane" is a
|
|
72
|
+
// coherent request. The control-plane row is the platform describing ITSELF; a builder
|
|
73
|
+
// -created duplicate is not a privilege escalation in the permission sense but something
|
|
74
|
+
// worse: `core-upgrade.js` selects the self row with
|
|
75
|
+
// `WHERE hosting_shape = 'control-plane' LIMIT 1`, so a second row makes "which machine
|
|
76
|
+
// is the control plane" a question with a caller-influenced answer. Refused by name, at
|
|
77
|
+
// the door, for the same reason control-plane-guard.js refuses by name.
|
|
78
|
+
const UNREQUESTABLE_SHAPES = Object.freeze(new Set(['control-plane']));
|
|
79
|
+
|
|
80
|
+
// shapeAuthority — what does this wire value demand? PURE. Returns one of:
|
|
81
|
+
// 'open' → admit with no permission
|
|
82
|
+
// 'unrequestable' → refuse outright, whatever the caller holds
|
|
83
|
+
// 'permission' → require provisioning.fleet.manage
|
|
84
|
+
// 'defer' → not a shape this gate recognises; let the route's own validator
|
|
85
|
+
// answer it (a 400 `bad_hosting_shape` naming the valid set is a far
|
|
86
|
+
// more useful reply to a typo than a 403 about a permission the
|
|
87
|
+
// caller would not have needed anyway).
|
|
88
|
+
//
|
|
89
|
+
// 'defer' is safe despite looking like a hole: the validator runs immediately after this
|
|
90
|
+
// middleware and refuses anything outside HOSTING_SHAPES, and `createInstance` coerces an
|
|
91
|
+
// unrecognised shape to 'co-tenant' rather than storing it. There is no path by which a
|
|
92
|
+
// deferred value becomes a privileged row.
|
|
93
|
+
//
|
|
94
|
+
// Matches the exact wire value — a case or whitespace variant of a privileged shape is
|
|
95
|
+
// not in HOSTING_SHAPES, so it defers here and is then refused as a bad shape, never
|
|
96
|
+
// admitted as a cheap one.
|
|
97
|
+
function shapeAuthority(rawShape) {
|
|
98
|
+
const shape = rawShape === undefined || rawShape === null || rawShape === ''
|
|
99
|
+
? 'co-tenant' // an absent shape is the documented default, and it is the open one
|
|
100
|
+
: rawShape;
|
|
101
|
+
if (typeof shape !== 'string') return 'defer';
|
|
102
|
+
if (!provisioning.HOSTING_SHAPES.includes(shape)) return 'defer';
|
|
103
|
+
if (UNREQUESTABLE_SHAPES.has(shape)) return 'unrequestable';
|
|
104
|
+
if (OPEN_SHAPES.has(shape)) return 'open';
|
|
105
|
+
return 'permission';
|
|
40
106
|
}
|
|
41
107
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
function
|
|
108
|
+
// requireAuthorityForShape — the create route's shape gate. Mount it AFTER requireBuilder
|
|
109
|
+
// and BEFORE the handler, so a refusal leaves no row behind for the runner to drain later.
|
|
110
|
+
// `requirePermission` is injected (rather than required here) so the gate is exercisable
|
|
111
|
+
// without standing up the auth stack.
|
|
112
|
+
function requireAuthorityForShape(requirePermission) {
|
|
47
113
|
const gate = requirePermission('provisioning.fleet.manage');
|
|
48
|
-
return function
|
|
49
|
-
|
|
114
|
+
return function shapeGate(req, res, next) {
|
|
115
|
+
const verdict = shapeAuthority(req.body && req.body.hosting_shape);
|
|
116
|
+
if (verdict === 'open' || verdict === 'defer') return next();
|
|
117
|
+
if (verdict === 'unrequestable') {
|
|
118
|
+
return res.fail('shape_not_requestable', {
|
|
119
|
+
status: 403,
|
|
120
|
+
message: 'The control plane enrolls its own row; it cannot be requested through this route.',
|
|
121
|
+
details: { hosting_shape: req.body.hosting_shape, requestable: [...provisioning.HOSTING_SHAPES].filter((s) => !UNREQUESTABLE_SHAPES.has(s)) },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
50
124
|
return gate(req, res, next);
|
|
51
125
|
};
|
|
52
126
|
}
|
|
53
127
|
|
|
54
|
-
// Only the gate is exported.
|
|
55
|
-
// nothing outside this file needs to ask "
|
|
56
|
-
// gated on it, and an export nobody consumes is one more
|
|
57
|
-
|
|
128
|
+
// Only the gate is exported. OPEN_SHAPES, UNREQUESTABLE_SHAPES and shapeAuthority are
|
|
129
|
+
// deliberately private: nothing outside this file needs to ask "what does this shape
|
|
130
|
+
// demand" separately from being gated on it, and an export nobody consumes is one more
|
|
131
|
+
// thing to keep true.
|
|
132
|
+
module.exports = { requireAuthorityForShape };
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
//
|
|
13
13
|
// own-scoped (requireBuilder — the caller's OWN instances):
|
|
14
14
|
// POST /provisioning/instances — request a new instance
|
|
15
|
-
// (
|
|
16
|
-
// provisioning.fleet.manage
|
|
17
|
-
//
|
|
15
|
+
// (only `co-tenant` is free; every other
|
|
16
|
+
// shape ALSO needs provisioning.fleet.manage,
|
|
17
|
+
// and `control-plane` is not requestable at
|
|
18
|
+
// all; ../paid-shape-gate.js.
|
|
18
19
|
// An `adopt` ALSO passes the credential
|
|
19
20
|
// preflight — ../credential-preflight.js,
|
|
20
21
|
// task 1002909; greenfield is exempt; a full box → 409, ../capacity-gate.js)
|
|
@@ -50,7 +51,7 @@ const { onboardPlan, stepsForShape } = require('../onboard-plan');
|
|
|
50
51
|
const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
|
|
51
52
|
const { recommendationsForDetails } = require('../recommendations');
|
|
52
53
|
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
53
|
-
const {
|
|
54
|
+
const { requireAuthorityForShape } = require('../paid-shape-gate');
|
|
54
55
|
const { requireRoomOnBox } = require('../capacity-gate'); // a full box refused up front (task 1003938)
|
|
55
56
|
const { adoptCredentialFault } = require('../credential-preflight');
|
|
56
57
|
const catalogBridge = require('../catalog-bridge');
|
|
@@ -280,11 +281,14 @@ module.exports = function provisioningRoutes() {
|
|
|
280
281
|
});
|
|
281
282
|
|
|
282
283
|
// POST /provisioning/instances — request a new instance. rank: any authenticated
|
|
283
|
-
// builder (own resource)
|
|
284
|
-
//
|
|
284
|
+
// builder (own resource) for the free `co-tenant` shape. Every other shape also needs
|
|
285
|
+
// provisioning.fleet.manage — `dedicated` because it bills, `standalone` because it runs
|
|
286
|
+
// the caller's own repo on the platform's box — and `control-plane` is refused outright,
|
|
287
|
+
// since the platform enrolls its own row (../paid-shape-gate.js, task 1003370 / audit B4).
|
|
288
|
+
// A full shared box answers 409 box_full up front (../capacity-gate.js).
|
|
285
289
|
// ENQUEUES a 'provision' intent the runner (task P3) stands up. Idempotent on slug for the SAME
|
|
286
290
|
// owner (re-request returns the existing row); a slug owned by ANOTHER builder → 409.
|
|
287
|
-
// Body: { slug (required), target_ref?, hosting_shape? (co-tenant
|
|
291
|
+
// Body: { slug (required), target_ref?, hosting_shape? (co-tenant free; others gated),
|
|
288
292
|
// domain?, tier?, onboard_mode? (greenfield|adopt) }.
|
|
289
293
|
//
|
|
290
294
|
// onboard_mode is LOAD-BEARING, not a label (task 1002562): the control-plane runner
|
|
@@ -292,7 +296,7 @@ module.exports = function provisioningRoutes() {
|
|
|
292
296
|
// ALREADY has, so the runner must clone + layer onto its history instead of pushing a
|
|
293
297
|
// fresh tree at `main` (which an existing repo rejects as unrelated history). Absent →
|
|
294
298
|
// 'greenfield', the safe default.
|
|
295
|
-
router.post('/provisioning/instances', auth.requireBuilder,
|
|
299
|
+
router.post('/provisioning/instances', auth.requireBuilder, requireAuthorityForShape(auth.requirePermission), requireRoomOnBox(pool, log), async (req, res) => {
|
|
296
300
|
if (validateOrRespond(req, res, {
|
|
297
301
|
slug: { required: true, type: 'string', maxLength: 40 },
|
|
298
302
|
target_ref: { type: 'string', maxLength: 200 },
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.887",
|
|
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.887",
|
|
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.887",
|
|
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",
|
|
@@ -49,7 +49,14 @@ const CLAUDE_PATH = path.join(REPO_ROOT, 'CLAUDE.md');
|
|
|
49
49
|
|
|
50
50
|
// How many of the most-recent sessions the always-loaded CLAUDE.md snippet carries. The rest live
|
|
51
51
|
// in the generated full index. Bounded on purpose (ADR 0062 §8) — the context file must stay lean.
|
|
52
|
-
|
|
52
|
+
//
|
|
53
|
+
// 10 -> 3 (task 1003688). At 10 this block was 1,795 chars, 8.4% of a CLAUDE.md that loads IN FULL
|
|
54
|
+
// in every session, of every craft, whether or not the reader cares what shipped last week. Its job
|
|
55
|
+
// is to answer "what just happened here" — and the 3 newest answer that; entries 4-10 were history,
|
|
56
|
+
// which is what docs/session-log-index.md and docs/session-logs/ are for. This is the one number to
|
|
57
|
+
// change if that balance ever needs revisiting: the header text, the fitness gate and
|
|
58
|
+
// tests/session_index.mjs all read it rather than restating it.
|
|
59
|
+
const SNIPPET_COUNT = 3;
|
|
53
60
|
|
|
54
61
|
// Only dated session-log files are entries — a date-prefixed slug is the naming contract (§12).
|
|
55
62
|
// This excludes any stray README / the index itself / non-session markdown in the directory.
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.887'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -235,7 +235,7 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
|
|
|
235
235
|
|
|
236
236
|
// Act — the body the wizard files
|
|
237
237
|
const res = await post('/provisioning/instances', {
|
|
238
|
-
slug: 'mercury', hosting_shape: '
|
|
238
|
+
slug: 'mercury', hosting_shape: 'co-tenant', type: 'game', modules: ['agents', 'discord'],
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
// Assert
|
|
@@ -245,7 +245,7 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
|
|
|
245
245
|
|
|
246
246
|
await ta('POST /instances: an omitted modules key is still a valid create — skipping is supported, not an error', async () => {
|
|
247
247
|
lastCreateArgs = null; existingRow = null;
|
|
248
|
-
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: '
|
|
248
|
+
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', type: 'game' });
|
|
249
249
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
250
250
|
assert.equal(lastCreateArgs.modules, undefined, 'nothing is invented on the owner\'s behalf');
|
|
251
251
|
// …and the receipt already carries the set they will actually get.
|
|
@@ -255,7 +255,7 @@ await ta('POST /instances: an omitted modules key is still a valid create — sk
|
|
|
255
255
|
|
|
256
256
|
await ta('POST /instances: an explicit empty selection is accepted as an answer', async () => {
|
|
257
257
|
lastCreateArgs = null; existingRow = null;
|
|
258
|
-
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: '
|
|
258
|
+
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', type: 'game', modules: [] });
|
|
259
259
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
260
260
|
assert.deepEqual(lastCreateArgs.modules, []);
|
|
261
261
|
});
|
|
@@ -266,7 +266,7 @@ await ta('POST /instances: an unknown module is REFUSED and named, never silentl
|
|
|
266
266
|
// prevent, and the reason badProjectDetail was written beside this.
|
|
267
267
|
existingRow = null;
|
|
268
268
|
const res = await post('/provisioning/instances', {
|
|
269
|
-
slug: 'mercury', hosting_shape: '
|
|
269
|
+
slug: 'mercury', hosting_shape: 'co-tenant', modules: ['discrod'],
|
|
270
270
|
});
|
|
271
271
|
assert.equal(res.status, 400, res.raw);
|
|
272
272
|
assert.equal(res.body.error.code, 'bad_modules');
|
|
@@ -277,7 +277,7 @@ await ta('POST /instances: an unknown module is REFUSED and named, never silentl
|
|
|
277
277
|
await ta('POST /instances: naming an always-on module is refused with the reason, not the generic miss', async () => {
|
|
278
278
|
existingRow = null;
|
|
279
279
|
const res = await post('/provisioning/instances', {
|
|
280
|
-
slug: 'mercury', hosting_shape: '
|
|
280
|
+
slug: 'mercury', hosting_shape: 'co-tenant', modules: ['memory'],
|
|
281
281
|
});
|
|
282
282
|
assert.equal(res.status, 400, res.raw);
|
|
283
283
|
assert.equal(res.body.error.code, 'bad_modules');
|
|
@@ -286,11 +286,11 @@ await ta('POST /instances: naming an always-on module is refused with the reason
|
|
|
286
286
|
|
|
287
287
|
await ta('POST /instances: modules must be an array of strings', async () => {
|
|
288
288
|
existingRow = null;
|
|
289
|
-
const notArray = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: '
|
|
289
|
+
const notArray = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', modules: 'discord' });
|
|
290
290
|
assert.equal(notArray.status, 400, notArray.raw);
|
|
291
291
|
assert.equal(notArray.body.error.code, 'validation_failed');
|
|
292
292
|
|
|
293
|
-
const notStrings = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: '
|
|
293
|
+
const notStrings = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', modules: [7] });
|
|
294
294
|
assert.equal(notStrings.status, 400, notStrings.raw);
|
|
295
295
|
assert.equal(notStrings.body.error.code, 'validation_failed');
|
|
296
296
|
});
|
|
@@ -301,7 +301,7 @@ await ta('POST /instances: a re-request never changes an existing project\'s mod
|
|
|
301
301
|
// to hunt for one.
|
|
302
302
|
existingRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['agents'], detail: {} };
|
|
303
303
|
const res = await post('/provisioning/instances', {
|
|
304
|
-
slug: 'mercury', hosting_shape: '
|
|
304
|
+
slug: 'mercury', hosting_shape: 'co-tenant', modules: ['discord'],
|
|
305
305
|
});
|
|
306
306
|
assert.equal(res.status, 200, res.raw);
|
|
307
307
|
assert.ok(res.body.modules_ignored, 'the dropped answer is reported, not swallowed');
|
|
@@ -589,7 +589,7 @@ await ta('POST /instances: a null answer at CREATION is accepted and stores noth
|
|
|
589
589
|
lastCreateArgs = null; existingRow = null;
|
|
590
590
|
|
|
591
591
|
const res = await post('/provisioning/instances', {
|
|
592
|
-
slug: 'mercury', hosting_shape: '
|
|
592
|
+
slug: 'mercury', hosting_shape: 'co-tenant', type: 'game', detail: { team_shape: null },
|
|
593
593
|
});
|
|
594
594
|
|
|
595
595
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
@@ -150,7 +150,7 @@ function post(path, body) {
|
|
|
150
150
|
await ta('POST /instances: onboard_mode=adopt is threaded through to createInstance', async () => {
|
|
151
151
|
lastCreateArgs = null;
|
|
152
152
|
const res = await post('/provisioning/instances', {
|
|
153
|
-
slug: 'demo', hosting_shape: '
|
|
153
|
+
slug: 'demo', hosting_shape: 'co-tenant', target_ref: 'example-owner/demo', onboard_mode: 'adopt',
|
|
154
154
|
});
|
|
155
155
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
156
156
|
assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'adopt');
|
|
@@ -158,7 +158,7 @@ await ta('POST /instances: onboard_mode=adopt is threaded through to createInsta
|
|
|
158
158
|
|
|
159
159
|
await ta('POST /instances: onboard_mode absent → greenfield (unchanged behavior for every existing caller)', async () => {
|
|
160
160
|
lastCreateArgs = null;
|
|
161
|
-
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: '
|
|
161
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenant' });
|
|
162
162
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
163
163
|
assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'greenfield');
|
|
164
164
|
});
|
|
@@ -167,7 +167,7 @@ await ta('POST /instances: an unknown onboard_mode is REFUSED (never silently co
|
|
|
167
167
|
// Coercing here would be worse than refusing: the caller believes it asked for one thing
|
|
168
168
|
// and the runner does another. The route names the valid set so the client can fix itself.
|
|
169
169
|
const res = await post('/provisioning/instances', {
|
|
170
|
-
slug: 'demo', hosting_shape: '
|
|
170
|
+
slug: 'demo', hosting_shape: 'co-tenant', target_ref: 'a/b', onboard_mode: 'brownfield',
|
|
171
171
|
});
|
|
172
172
|
assert.equal(res.status, 400);
|
|
173
173
|
assert.equal(res.body.error.code, 'bad_onboard_mode');
|
|
@@ -204,8 +204,8 @@ await ta('POST /instances: adopt WITHOUT target_ref is refused — there is no r
|
|
|
204
204
|
// Without target_ref the runner would fall back to greenfield behavior on a row labelled
|
|
205
205
|
// adopt. Refuse at the door rather than store a lie.
|
|
206
206
|
for (const body of [
|
|
207
|
-
{ slug: 'demo', hosting_shape: '
|
|
208
|
-
{ slug: 'demo', hosting_shape: '
|
|
207
|
+
{ slug: 'demo', hosting_shape: 'co-tenant', onboard_mode: 'adopt' },
|
|
208
|
+
{ slug: 'demo', hosting_shape: 'co-tenant', onboard_mode: 'adopt', target_ref: ' ' },
|
|
209
209
|
]) {
|
|
210
210
|
const res = await post('/provisioning/instances', body);
|
|
211
211
|
assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`);
|
|
@@ -10,11 +10,26 @@
|
|
|
10
10
|
// SPENDS was less gated than force-teardown, which stops spending and sits behind
|
|
11
11
|
// provisioning.fleet.manage.
|
|
12
12
|
//
|
|
13
|
-
// The fix gates the SHAPE, not the route:
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
13
|
+
// The fix gates the SHAPE, not the route: ordinary project creation must not require a rank,
|
|
14
|
+
// so `co-tenant` — the front door — stays open to any authenticated builder. Everything else
|
|
15
|
+
// demands provisioning.fleet.manage, deliberately the same atom that already guards the fleet
|
|
16
|
+
// cost-ledger and force-teardown, since spending on the fleet and managing the fleet are one
|
|
17
|
+
// authority.
|
|
18
|
+
//
|
|
19
|
+
// WIDENED BY TASK 1003370 (audit B4), which is why this file's name is narrower than its
|
|
20
|
+
// subject. The original gate named the one shape it refused and admitted the rest, and two
|
|
21
|
+
// shapes stayed open underneath that reasoning:
|
|
22
|
+
//
|
|
23
|
+
// standalone — judged harmless because it costs $0. True of the invoice; false of the
|
|
24
|
+
// trust. The unit's WorkingDirectory is the caller's own checkout and the
|
|
25
|
+
// loader discovers modules from process.cwd(), so the platform require()s
|
|
26
|
+
// a repo the caller named.
|
|
27
|
+
// control-plane — did not exist when the gate was written, and was permitted by omission
|
|
28
|
+
// the moment task 1003159 added it. A probe returned 201 and a second
|
|
29
|
+
// control-plane row, while this file's own comment asserted it could not.
|
|
30
|
+
//
|
|
31
|
+
// So the set below is now the set that needs NO authority, and a shape added later is gated
|
|
32
|
+
// by omission instead of admitted by it.
|
|
18
33
|
//
|
|
19
34
|
// DB-free: the pool is never reached (createInstance is stubbed), auth is faked. No network
|
|
20
35
|
// beyond a loopback listener.
|
|
@@ -93,15 +108,7 @@ function post(path, body) {
|
|
|
93
108
|
});
|
|
94
109
|
}
|
|
95
110
|
|
|
96
|
-
// ── the
|
|
97
|
-
|
|
98
|
-
await ta('standalone: a builder with NO fleet permission still creates (the live wizard path)', async () => {
|
|
99
|
-
held = [];
|
|
100
|
-
lastCreateArgs = null;
|
|
101
|
-
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone', no_address: true });
|
|
102
|
-
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
103
|
-
assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'standalone');
|
|
104
|
-
});
|
|
111
|
+
// ── the front door stays open — this is the half a careless fix would break ───────
|
|
105
112
|
|
|
106
113
|
await ta('co-tenant (and an absent shape, which defaults to it) stays open with no permission', async () => {
|
|
107
114
|
for (const body of [
|
|
@@ -114,6 +121,61 @@ await ta('co-tenant (and an absent shape, which defaults to it) stays open with
|
|
|
114
121
|
}
|
|
115
122
|
});
|
|
116
123
|
|
|
124
|
+
// ── standalone runs the caller's OWN code on the platform's box (task 1003370) ────
|
|
125
|
+
|
|
126
|
+
await ta('standalone: REFUSED for a builder without provisioning.fleet.manage', async () => {
|
|
127
|
+
// Task 1003682 left this shape open, reasoning that a $0 shape hands the caller
|
|
128
|
+
// nothing. That is true of the invoice and false of the trust: the systemd unit's
|
|
129
|
+
// WorkingDirectory is the caller's own checkout and the module loader discovers
|
|
130
|
+
// `modules/` from process.cwd(), so a standalone request is "please require() this
|
|
131
|
+
// repo on your control plane". The uid split (task 1003369) bounds the blast radius;
|
|
132
|
+
// it does not make the default acceptable.
|
|
133
|
+
held = [];
|
|
134
|
+
lastCreateArgs = null;
|
|
135
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone', no_address: true });
|
|
136
|
+
assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
|
|
137
|
+
assert.equal(res.body.error.code, 'permission_forbidden');
|
|
138
|
+
assert.deepEqual(res.body.error.details.required, ['provisioning.fleet.manage']);
|
|
139
|
+
assert.equal(lastCreateArgs, null, 'no row is created when the shape is refused');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await ta('standalone: ADMITTED for a builder holding provisioning.fleet.manage', async () => {
|
|
143
|
+
held = ['provisioning.fleet.manage'];
|
|
144
|
+
lastCreateArgs = null;
|
|
145
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone', no_address: true });
|
|
146
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
147
|
+
assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'standalone');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── control-plane is not requestable AT ALL (task 1003370) ────────────────────────
|
|
151
|
+
|
|
152
|
+
await ta('control-plane: refused for an unprivileged builder, and the refusal is not a permission', async () => {
|
|
153
|
+
// Until task 1003370 this returned 201 and created a SECOND control-plane row. The
|
|
154
|
+
// previous version of this file asserted, in a comment, that the shape "is not
|
|
155
|
+
// creatable through the create route at all" — a claim nothing tested, and which a
|
|
156
|
+
// probe falsified. The comment was true of intent and false of the code.
|
|
157
|
+
held = [];
|
|
158
|
+
lastCreateArgs = null;
|
|
159
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'control-plane', no_address: true });
|
|
160
|
+
assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
|
|
161
|
+
assert.equal(res.body.error.code, 'shape_not_requestable');
|
|
162
|
+
assert.equal(lastCreateArgs, null, 'no row is created');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await ta('control-plane: still refused for a builder holding EVERY permission', async () => {
|
|
166
|
+
// The distinguishing property. A permission gate would admit an Archon here, and
|
|
167
|
+
// `core-upgrade.js` picks the self row with `WHERE hosting_shape = 'control-plane'
|
|
168
|
+
// LIMIT 1` — so a second row makes "which machine is the control plane" a question
|
|
169
|
+
// with a caller-influenced answer. No rank makes that a coherent request; the self
|
|
170
|
+
// row is enrolled by scripts/gds/enroll-control-plane.js.
|
|
171
|
+
held = ['provisioning.fleet.manage'];
|
|
172
|
+
lastCreateArgs = null;
|
|
173
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'control-plane', no_address: true });
|
|
174
|
+
assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
|
|
175
|
+
assert.equal(res.body.error.code, 'shape_not_requestable');
|
|
176
|
+
assert.equal(lastCreateArgs, null, 'no row is created');
|
|
177
|
+
});
|
|
178
|
+
|
|
117
179
|
// ── dedicated is the metered one ──────────────────────────────────────────────────
|
|
118
180
|
|
|
119
181
|
await ta('dedicated: REFUSED for a builder without provisioning.fleet.manage', async () => {
|
|
@@ -161,20 +223,35 @@ await ta('a case-variant shape does not slip past the gate INTO a dedicated row'
|
|
|
161
223
|
}
|
|
162
224
|
});
|
|
163
225
|
|
|
164
|
-
await ta('HOSTING_SHAPES
|
|
226
|
+
await ta('HOSTING_SHAPES is pinned, so a new shape forces a written decision about its authority', () => {
|
|
165
227
|
// This tripwire fired for the first time at task 1003159, which added a fourth
|
|
166
228
|
// shape — 'control-plane', the platform's own box, enrolled so the owner's deploy
|
|
167
|
-
// door has a row to point at (ADR 0293 D2). Its question was "is the new
|
|
168
|
-
// also metered?"
|
|
169
|
-
//
|
|
170
|
-
// for
|
|
171
|
-
//
|
|
172
|
-
//
|
|
229
|
+
// door has a row to point at (ADR 0293 D2). Its question then was "is the new
|
|
230
|
+
// shape also metered?" and the answer recorded was no — correct, and the wrong
|
|
231
|
+
// question. Nobody asked whether it was REQUESTABLE, and it was: a Xenos could
|
|
232
|
+
// POST for a second control plane until task 1003370.
|
|
233
|
+
//
|
|
234
|
+
// So the question this list now forces is the one that generalises. The gate is an
|
|
235
|
+
// allow-list: `co-tenant` needs nothing, `control-plane` is refused outright, and
|
|
236
|
+
// EVERY other shape — including one added after this file was last read — demands
|
|
237
|
+
// provisioning.fleet.manage by omission. A fifth shape therefore arrives already
|
|
238
|
+
// gated, and this assertion is the prompt to confirm that is what was wanted
|
|
239
|
+
// rather than the thing that has to catch the hole.
|
|
173
240
|
assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(),
|
|
174
241
|
['co-tenant', 'control-plane', 'dedicated', 'standalone']);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await ta('an unknown shape is answered by the validator, not by the gate', () => {
|
|
245
|
+
// The gate DEFERS anything outside HOSTING_SHAPES so a typo gets `bad_hosting_shape`
|
|
246
|
+
// naming the valid set, rather than a 403 about a permission the caller would not
|
|
247
|
+
// have needed. Deferring is safe: the validator refuses it microseconds later, and
|
|
248
|
+
// createInstance coerces an unrecognised shape to co-tenant rather than storing it,
|
|
249
|
+
// so no deferred value can become a privileged row.
|
|
250
|
+
return post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenamt', no_address: true })
|
|
251
|
+
.then((res) => {
|
|
252
|
+
assert.equal(res.status, 400, `expected 400, got ${res.status} ${res.raw}`);
|
|
253
|
+
assert.equal(res.body.error.code, 'bad_hosting_shape');
|
|
254
|
+
});
|
|
178
255
|
});
|
|
179
256
|
|
|
180
257
|
server.close();
|