@bongos/core 1.19.582 → 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 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.582",
6
- "core_contract": "1.19.582",
7
- "source_commit": "e08eecbc1ea83f6a28a974941d34596df6e8679c",
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-07T20:40:15.927Z",
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": 2053,
14
+ "functional_verbatim": 2055,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2530,
20
- "tree_sha256": "29108b672d011aaae1b40a09256ee01d2920dea6dc75314b191b4c4f2bf3d59c",
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": "c3a329219460b2508d51009ff8f77bdc4733f3a17ad2776553dc7bce483555c4"
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": "adf481b84f3a8f77597056fad9643d39362e5333ac86b0c1ca7b122456dfd2fa"
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": "60c583e1ae977c85575709abf74af7d0dc5147788509f11d1b26cad459d7b4cf"
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": "24d6748a6c6816752b86ef695eb75e27076d682ba1d3639763c6377389fcd17b"
7580
+ "sha256": "b37aec87b322bad5e71296695b690719db6952a401e63afe00c277863e109082"
7576
7581
  },
7577
7582
  {
7578
7583
  "path": "package.json",
7579
7584
  "mode": "0000644",
7580
- "sha256": "4dd5996574d3f875f81509651c5af05ff6313b45774b9f65737086658240c927"
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": "15e5aa2886df30346a374c4b4fa988ff471a73a113a10c13061ce174f1aacfde"
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",
@@ -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": {
@@ -1613,5 +1613,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1613
1613
  landed since 1.19.580 with no explicit bump. run 34158683008. (task 1002620)
1614
1614
  1.19.582 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1615
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)
1616
1618
  ---------------------------------------------------------------------------
1617
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). ENQUEUES a 'provision' intent + records the request;
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.582",
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.582",
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.582",
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.582'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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);