@bongos/core 1.19.581 → 1.19.583
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 +23 -13
- package/docs/api/openapi.json +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/paid-shape-gate.js +57 -0
- package/modules/provisioning/routes/provisioning.js +8 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/provisioning_paid_shape_gate.mjs +172 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
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.583",
|
|
6
|
+
"core_contract": "1.19.583",
|
|
7
|
+
"source_commit": "fb22777c0f055003ba879a32ce83f133c3bcf404",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-07T21:24:48.534Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 453,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2055,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2532,
|
|
20
|
+
"tree_sha256": "bd94f04eed1f7a4c89844c53856157cdb9a14e2281876e6025d511ca9c446b7a",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1832,7 +1832,7 @@
|
|
|
1832
1832
|
{
|
|
1833
1833
|
"path": "docs/api/openapi.json",
|
|
1834
1834
|
"mode": "0000644",
|
|
1835
|
-
"sha256": "
|
|
1835
|
+
"sha256": "75ba055326939ec1c827d01cdf088f564e94f372d9906759483fd1bad2328375"
|
|
1836
1836
|
},
|
|
1837
1837
|
{
|
|
1838
1838
|
"path": "docs/architecture.md",
|
|
@@ -2707,7 +2707,7 @@
|
|
|
2707
2707
|
{
|
|
2708
2708
|
"path": "docs/module-api-changelog.md",
|
|
2709
2709
|
"mode": "0000644",
|
|
2710
|
-
"sha256": "
|
|
2710
|
+
"sha256": "a73cb53670674b790e02461ef7c11e001a5ed0ebb38498ffa222034263058932"
|
|
2711
2711
|
},
|
|
2712
2712
|
{
|
|
2713
2713
|
"path": "docs/modules-contract.md",
|
|
@@ -6279,6 +6279,11 @@
|
|
|
6279
6279
|
"mode": "0000644",
|
|
6280
6280
|
"sha256": "31f14cbf3d649c25f1f17c508ae19be2f39d9e1991fde17cdce08cb15837ffa1"
|
|
6281
6281
|
},
|
|
6282
|
+
{
|
|
6283
|
+
"path": "modules/provisioning/paid-shape-gate.js",
|
|
6284
|
+
"mode": "0000644",
|
|
6285
|
+
"sha256": "a72f2961ba04739648b8622d86a85a75aef613d5d7c50fbb2f43ae8cf2c4adae"
|
|
6286
|
+
},
|
|
6282
6287
|
{
|
|
6283
6288
|
"path": "modules/provisioning/pollers/catalog-backfill.js",
|
|
6284
6289
|
"mode": "0000644",
|
|
@@ -6312,7 +6317,7 @@
|
|
|
6312
6317
|
{
|
|
6313
6318
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
6314
6319
|
"mode": "0000644",
|
|
6315
|
-
"sha256": "
|
|
6320
|
+
"sha256": "19dcadc78c74d7702a31fcb3d75d4c5fd45d08aab0e32c2a876668ec6dea85ec"
|
|
6316
6321
|
},
|
|
6317
6322
|
{
|
|
6318
6323
|
"path": "modules/provisioning/starter-bundles.js",
|
|
@@ -7572,12 +7577,12 @@
|
|
|
7572
7577
|
{
|
|
7573
7578
|
"path": "package-lock.json",
|
|
7574
7579
|
"mode": "0000644",
|
|
7575
|
-
"sha256": "
|
|
7580
|
+
"sha256": "b37aec87b322bad5e71296695b690719db6952a401e63afe00c277863e109082"
|
|
7576
7581
|
},
|
|
7577
7582
|
{
|
|
7578
7583
|
"path": "package.json",
|
|
7579
7584
|
"mode": "0000644",
|
|
7580
|
-
"sha256": "
|
|
7585
|
+
"sha256": "5ddfb97b2d19da1f5631fd5fcd81102c1fb3188a855b4ea9a2e640447dad2403"
|
|
7581
7586
|
},
|
|
7582
7587
|
{
|
|
7583
7588
|
"path": "public-docs/index.html",
|
|
@@ -9287,7 +9292,7 @@
|
|
|
9287
9292
|
{
|
|
9288
9293
|
"path": "src/module-api.js",
|
|
9289
9294
|
"mode": "0000644",
|
|
9290
|
-
"sha256": "
|
|
9295
|
+
"sha256": "4cae7c47e642670fbd78fa75a65b4cee629fbc43df0a9c34a69fc465c434330e"
|
|
9291
9296
|
},
|
|
9292
9297
|
{
|
|
9293
9298
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11754,6 +11759,11 @@
|
|
|
11754
11759
|
"mode": "0000644",
|
|
11755
11760
|
"sha256": "97d04ee39cc06b720d4a5b42f3e3cc614f64d9462b27471a1af7598b5e2929be"
|
|
11756
11761
|
},
|
|
11762
|
+
{
|
|
11763
|
+
"path": "tests/provisioning_paid_shape_gate.mjs",
|
|
11764
|
+
"mode": "0000644",
|
|
11765
|
+
"sha256": "a2c8a1761cf44bd0434766cb9e42c34dbc61d7133218440f046d82da802d8217"
|
|
11766
|
+
},
|
|
11757
11767
|
{
|
|
11758
11768
|
"path": "tests/provisioning_recommendations.mjs",
|
|
11759
11769
|
"mode": "0000644",
|
package/docs/api/openapi.json
CHANGED
|
@@ -11520,7 +11520,7 @@
|
|
|
11520
11520
|
"provisioning"
|
|
11521
11521
|
],
|
|
11522
11522
|
"summary": "POST /provisioning/instances",
|
|
11523
|
-
"description": "POST /provisioning/instances — request a new instance. rank: any authenticated builder (own resource). ENQUEUES a 'provision' intent + records the request; the runner (task P3) does the real standup. 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|dedicated), 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).",
|
|
11523
|
+
"description": "POST /provisioning/instances — request a new instance. rank: any authenticated builder (own resource); a `dedicated` (metered) shape additionally requires provisioning.fleet.manage — see requireAuthorityForMeteredShape above. ENQUEUES a 'provision' intent + records the request; the runner (task P3) does the real standup. 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|dedicated), 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).",
|
|
11524
11524
|
"x-rank": "any-builder",
|
|
11525
11525
|
"x-source": "modules/provisioning/routes/provisioning.js",
|
|
11526
11526
|
"requestBody": {
|
|
@@ -1611,5 +1611,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1611
1611
|
landed since 1.19.579 with no explicit bump. run 34153217035. (task 1002620)
|
|
1612
1612
|
1.19.581 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1613
1613
|
landed since 1.19.580 with no explicit bump. run 34158683008. (task 1002620)
|
|
1614
|
+
1.19.582 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1615
|
+
landed since 1.19.581 with no explicit bump. run 34160253741. (task 1002620)
|
|
1616
|
+
1.19.583 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1617
|
+
landed since 1.19.582 with no explicit bump. run 34162998083. (task 1002620)
|
|
1614
1618
|
---------------------------------------------------------------------------
|
|
1615
1619
|
```
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// modules/provisioning/paid-shape-gate.js — who may ask for METERED infrastructure
|
|
2
|
+
// (task 1003682).
|
|
3
|
+
//
|
|
4
|
+
// 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 one of the
|
|
6
|
+
// hosting shapes it accepts is not free. `dedicated` is the only shape whose runner leg
|
|
7
|
+
// allocates a real droplet (`scripts/gds/provision.js` — billed monthly, defaulting to
|
|
8
|
+
// s-2vcpu-4gb when no tier is named); every other shape co-tenants on infrastructure the
|
|
9
|
+
// platform already pays for, which is why `costEstimateUsd` returns 0 for all of them.
|
|
10
|
+
//
|
|
11
|
+
// So the authority question belongs to the SHAPE, not the route. Gating the whole route
|
|
12
|
+
// would make ordinary project creation require a rank — the wrong trade entirely, and the
|
|
13
|
+
// reason this is a middleware over one field rather than a floor on the endpoint.
|
|
14
|
+
//
|
|
15
|
+
// The atom is `provisioning.fleet.manage` (floor: metic), deliberately the same one that
|
|
16
|
+
// already guards the fleet cost-ledger and force-teardown. Spending on the fleet and
|
|
17
|
+
// managing the fleet are one authority, and before this the route that SPENDS money was
|
|
18
|
+
// less gated than the route that stops it.
|
|
19
|
+
//
|
|
20
|
+
// Split into its own file rather than inlined beside the route for two reasons, both
|
|
21
|
+
// mechanical: `routes/provisioning.js` sits at its 1500-line fitness ratchet (the same
|
|
22
|
+
// pressure that produced `callback-page.js` under task 1003570), and a bare `req.body`
|
|
23
|
+
// read placed between two route declarations is attributed by the ADR 0118 body-validation
|
|
24
|
+
// scanner to the PRECEDING route, which turns a correct gate into a false "unvalidated
|
|
25
|
+
// write route" failure on an unrelated endpoint.
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
// The shapes that cost money. Kept as a set of one rather than a bare `=== 'dedicated'`
|
|
29
|
+
// so a second metered shape is a one-line change here instead of a hunt through gates;
|
|
30
|
+
// `tests/provisioning_paid_shape_gate.mjs` pins HOSTING_SHAPES so adding one without
|
|
31
|
+
// revisiting this file fails loudly.
|
|
32
|
+
const METERED_SHAPES = Object.freeze(new Set(['dedicated']));
|
|
33
|
+
|
|
34
|
+
// isMeteredShape — does this hosting shape allocate infrastructure someone is billed for?
|
|
35
|
+
// Matches the exact wire value: the route's own validator refuses anything not in
|
|
36
|
+
// HOSTING_SHAPES, so a case or whitespace variant is rejected as a bad shape rather than
|
|
37
|
+
// admitted here as a cheap one.
|
|
38
|
+
function isMeteredShape(shape) {
|
|
39
|
+
return METERED_SHAPES.has(shape);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// requireAuthorityForMeteredShape — the create route's paid-shape gate. Mount it AFTER
|
|
43
|
+
// requireBuilder and BEFORE the handler, so a refusal leaves no row behind for the runner
|
|
44
|
+
// to drain later. `requirePermission` is injected (rather than required here) so the gate
|
|
45
|
+
// is exercisable without standing up the auth stack.
|
|
46
|
+
function requireAuthorityForMeteredShape(requirePermission) {
|
|
47
|
+
const gate = requirePermission('provisioning.fleet.manage');
|
|
48
|
+
return function meteredShapeGate(req, res, next) {
|
|
49
|
+
if (!isMeteredShape(req.body && req.body.hosting_shape)) return next();
|
|
50
|
+
return gate(req, res, next);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Only the gate is exported. METERED_SHAPES and isMeteredShape are deliberately private:
|
|
55
|
+
// nothing outside this file needs to ask "is this shape metered" separately from being
|
|
56
|
+
// gated on it, and an export nobody consumes is one more thing to keep true.
|
|
57
|
+
module.exports = { requireAuthorityForMeteredShape };
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
//
|
|
13
13
|
// own-scoped (requireBuilder — the caller's OWN instances):
|
|
14
14
|
// POST /provisioning/instances — request a new instance
|
|
15
|
+
// (a `dedicated` shape ALSO needs
|
|
16
|
+
// provisioning.fleet.manage — it is the
|
|
17
|
+
// only shape that bills; ../paid-shape-gate.js)
|
|
15
18
|
// GET /provisioning/instances — list the caller's instances
|
|
16
19
|
// GET /provisioning/instances/:id — read one of the caller's instances
|
|
17
20
|
// (also SELF-HEALS its catalog projection,
|
|
@@ -45,6 +48,7 @@ const { onboardPlan, stepsForShape } = require('../onboard-plan');
|
|
|
45
48
|
const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
|
|
46
49
|
const { recommendationsForDetails } = require('../recommendations');
|
|
47
50
|
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
51
|
+
const { requireAuthorityForMeteredShape } = require('../paid-shape-gate');
|
|
48
52
|
const catalogBridge = require('../catalog-bridge');
|
|
49
53
|
const { callbackPage } = require('./callback-page');
|
|
50
54
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
@@ -342,7 +346,9 @@ module.exports = function provisioningRoutes() {
|
|
|
342
346
|
});
|
|
343
347
|
|
|
344
348
|
// POST /provisioning/instances — request a new instance. rank: any authenticated
|
|
345
|
-
// builder (own resource)
|
|
349
|
+
// builder (own resource); a `dedicated` (metered) shape additionally requires
|
|
350
|
+
// provisioning.fleet.manage — see requireAuthorityForMeteredShape above.
|
|
351
|
+
// ENQUEUES a 'provision' intent + records the request;
|
|
346
352
|
// the runner (task P3) does the real standup. Idempotent on slug for the SAME
|
|
347
353
|
// owner (re-request returns the existing row); a slug owned by ANOTHER builder → 409.
|
|
348
354
|
// Body: { slug (required), target_ref?, hosting_shape? (co-tenant|dedicated),
|
|
@@ -353,7 +359,7 @@ module.exports = function provisioningRoutes() {
|
|
|
353
359
|
// ALREADY has, so the runner must clone + layer onto its history instead of pushing a
|
|
354
360
|
// fresh tree at `main` (which an existing repo rejects as unrelated history). Absent →
|
|
355
361
|
// 'greenfield', the safe default.
|
|
356
|
-
router.post('/provisioning/instances', auth.requireBuilder, async (req, res) => {
|
|
362
|
+
router.post('/provisioning/instances', auth.requireBuilder, requireAuthorityForMeteredShape(auth.requirePermission), async (req, res) => {
|
|
357
363
|
if (validateOrRespond(req, res, {
|
|
358
364
|
slug: { required: true, type: 'string', maxLength: 40 },
|
|
359
365
|
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.583",
|
|
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.583",
|
|
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.583",
|
|
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/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.583'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// tests/provisioning_paid_shape_gate.mjs
|
|
2
|
+
//
|
|
3
|
+
// The paid-shape gate on POST /provisioning/instances (task 1003682).
|
|
4
|
+
//
|
|
5
|
+
// Before this, the create route's only gate was requireBuilder, and `hosting_shape` was
|
|
6
|
+
// caller-supplied and validated only against the allowed set. `dedicated` is the ONE shape
|
|
7
|
+
// whose runner leg creates a real DigitalOcean droplet (scripts/gds/provision.js — billed
|
|
8
|
+
// monthly, defaulting to s-2vcpu-4gb), so any authenticated builder — a Xenos included —
|
|
9
|
+
// could queue recurring infrastructure spend the owner never approved. The route that
|
|
10
|
+
// SPENDS was less gated than force-teardown, which stops spending and sits behind
|
|
11
|
+
// provisioning.fleet.manage.
|
|
12
|
+
//
|
|
13
|
+
// The fix gates the SHAPE, not the route: every $0 shape (standalone, co-tenant) stays open
|
|
14
|
+
// to any authenticated builder, because ordinary project creation must not require a rank.
|
|
15
|
+
// Only `dedicated` demands provisioning.fleet.manage — deliberately the same atom that
|
|
16
|
+
// already guards the fleet cost-ledger and force-teardown, since spending on the fleet and
|
|
17
|
+
// managing the fleet are one authority.
|
|
18
|
+
//
|
|
19
|
+
// DB-free: the pool is never reached (createInstance is stubbed), auth is faked. No network
|
|
20
|
+
// beyond a loopback listener.
|
|
21
|
+
//
|
|
22
|
+
// Run: node tests/provisioning_paid_shape_gate.mjs
|
|
23
|
+
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import http from 'node:http';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
|
|
30
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
31
|
+
|
|
32
|
+
let passed = 0, failed = 0;
|
|
33
|
+
async function ta(name, fn) {
|
|
34
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
35
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── stubs: the route's data access + auth ────────────────────────────────────────
|
|
39
|
+
let lastCreateArgs = null;
|
|
40
|
+
provisioning.createInstance = async (_db, args) => {
|
|
41
|
+
lastCreateArgs = args;
|
|
42
|
+
return { instance: { id: 42, owner_builder_id: 7, slug: args.slug, hosting_shape: args.hostingShape }, created: true };
|
|
43
|
+
};
|
|
44
|
+
provisioning.enqueueIntent = async () => ({ intent: { id: 1, state: 'pending' }, created: true });
|
|
45
|
+
provisioning.recordEvent = async () => {};
|
|
46
|
+
provisioning.getInstanceBySlug = async () => null;
|
|
47
|
+
|
|
48
|
+
const api = require('../src/module-api.js');
|
|
49
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
50
|
+
|
|
51
|
+
// The permission gate, faked at the same seam the real one uses. `held` is flipped per
|
|
52
|
+
// case below so one mounted app covers both the denied and the admitted builder.
|
|
53
|
+
let held = [];
|
|
54
|
+
api.requirePermission = (...perms) => async (req, res, next) => {
|
|
55
|
+
if (perms.every((p) => held.includes(p))) return next();
|
|
56
|
+
return res.fail('permission_forbidden', {
|
|
57
|
+
status: 403,
|
|
58
|
+
message: `missing permission: ${perms.filter((p) => !held.includes(p)).join(', ')}`,
|
|
59
|
+
details: { required: perms, held: perms.filter((p) => held.includes(p)) },
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const express = require('express');
|
|
64
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
65
|
+
const app = express();
|
|
66
|
+
app.use(express.json());
|
|
67
|
+
app.use((_req, res, next) => {
|
|
68
|
+
res.fail = (code, opts, details) => {
|
|
69
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
70
|
+
res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
|
|
71
|
+
};
|
|
72
|
+
next();
|
|
73
|
+
});
|
|
74
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
75
|
+
|
|
76
|
+
const server = app.listen(0);
|
|
77
|
+
await new Promise((r) => server.once('listening', r));
|
|
78
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
79
|
+
|
|
80
|
+
function post(path, body) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const payload = JSON.stringify(body);
|
|
83
|
+
const req = http.request(`${base}${path}`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
86
|
+
}, (res) => {
|
|
87
|
+
let d = '';
|
|
88
|
+
res.on('data', (c) => { d += c; });
|
|
89
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
90
|
+
});
|
|
91
|
+
req.on('error', reject);
|
|
92
|
+
req.end(payload);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── the $0 shapes stay open — this is the half a careless fix would break ─────────
|
|
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
|
+
});
|
|
105
|
+
|
|
106
|
+
await ta('co-tenant (and an absent shape, which defaults to it) stays open with no permission', async () => {
|
|
107
|
+
for (const body of [
|
|
108
|
+
{ slug: 'demo', hosting_shape: 'co-tenant', no_address: true },
|
|
109
|
+
{ slug: 'demo', no_address: true },
|
|
110
|
+
]) {
|
|
111
|
+
held = [];
|
|
112
|
+
const res = await post('/provisioning/instances', body);
|
|
113
|
+
assert.equal(res.status, 201, `expected 201 for ${JSON.stringify(body)}, got ${res.status} ${res.raw}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── dedicated is the metered one ──────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
await ta('dedicated: REFUSED for a builder without provisioning.fleet.manage', async () => {
|
|
120
|
+
held = [];
|
|
121
|
+
lastCreateArgs = null;
|
|
122
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'dedicated', no_address: true });
|
|
123
|
+
assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
|
|
124
|
+
assert.equal(res.body.error.code, 'permission_forbidden');
|
|
125
|
+
// requirePermission reports the gate's literal list under `details` (the same envelope
|
|
126
|
+
// requireRank uses), so the denial names the atom a caller has to be granted.
|
|
127
|
+
assert.deepEqual(res.body.error.details.required, ['provisioning.fleet.manage']);
|
|
128
|
+
assert.equal(lastCreateArgs, null, 'no row is created when the shape is refused');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await ta('dedicated: ADMITTED for a builder holding provisioning.fleet.manage', async () => {
|
|
132
|
+
held = ['provisioning.fleet.manage'];
|
|
133
|
+
lastCreateArgs = null;
|
|
134
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'dedicated', no_address: true });
|
|
135
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
136
|
+
assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'dedicated');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await ta('the gate refuses BEFORE the row exists — a denied dedicated create leaves nothing behind', async () => {
|
|
140
|
+
// The denial must land ahead of createInstance, not after it: a refused-but-inserted row
|
|
141
|
+
// would still be drained by the runner, which reads the column, not the HTTP status.
|
|
142
|
+
held = [];
|
|
143
|
+
lastCreateArgs = null;
|
|
144
|
+
await post('/provisioning/instances', { slug: 'spendy', hosting_shape: 'dedicated', tier: 's-4vcpu-8gb' });
|
|
145
|
+
assert.equal(lastCreateArgs, null);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── the gate cannot be walked around ──────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
await ta('a case-variant shape does not slip past the gate INTO a dedicated row', async () => {
|
|
151
|
+
// The gate matches the exact wire value, and so does the shape validator — so a variant
|
|
152
|
+
// is refused as a bad shape rather than admitted as a cheap one and stored as dedicated.
|
|
153
|
+
// The property that matters is not WHICH error comes back, it is that no dedicated row
|
|
154
|
+
// is ever created without the permission.
|
|
155
|
+
for (const shape of ['DEDICATED', 'Dedicated', ' dedicated', 'dedicated ']) {
|
|
156
|
+
held = [];
|
|
157
|
+
lastCreateArgs = null;
|
|
158
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: shape, no_address: true });
|
|
159
|
+
assert.ok(res.status === 400 || res.status === 403, `${JSON.stringify(shape)} → expected refusal, got ${res.status} ${res.raw}`);
|
|
160
|
+
assert.equal(lastCreateArgs, null, `${JSON.stringify(shape)} must not create a row`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await ta('HOSTING_SHAPES still carries exactly the three shapes the gate reasons about', () => {
|
|
165
|
+
// If a fourth, also-metered shape is ever added, this test is the tripwire that says the
|
|
166
|
+
// gate's single-shape assumption needs revisiting.
|
|
167
|
+
assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(), ['co-tenant', 'dedicated', 'standalone']);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
server.close();
|
|
171
|
+
console.log(`\nprovisioning_paid_shape_gate: ${passed} passed, ${failed} failed`);
|
|
172
|
+
process.exit(failed ? 1 : 0);
|