@bongos/core 1.19.886 → 1.19.888
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 +29 -19
- package/docs/api/openapi.json +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/agents/lib/validate.js +49 -2
- package/modules/agents/migrations/agents_005_cron_trigger.sql +78 -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/src/module-api.js +1 -1
- package/tests/agents_cron_trigger.mjs +133 -0
- package/tests/agents_validate.mjs +13 -5
- 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,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.888",
|
|
6
|
+
"core_contract": "1.19.888",
|
|
7
|
+
"source_commit": "7ccf0b2f4cc7f7bafb93762cbd5cfb9ed37cf546",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-19T20:
|
|
9
|
+
"built_at": "2026-09-19T20:57:53.725Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 497,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2266,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2789,
|
|
20
|
+
"tree_sha256": "42c331016e53b14ca5416a325790e2846ea0ee2c6a24177222b823a1d2977b9c",
|
|
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": "7f60dfa2c479fecbd5a81de040689b9ff6f9d2aa5a907f9a7fcf86b77b476fea"
|
|
2611
2611
|
},
|
|
2612
2612
|
{
|
|
2613
2613
|
"path": "docs/modules-contract.md",
|
|
@@ -3732,7 +3732,7 @@
|
|
|
3732
3732
|
{
|
|
3733
3733
|
"path": "modules/agents/lib/validate.js",
|
|
3734
3734
|
"mode": "0000644",
|
|
3735
|
-
"sha256": "
|
|
3735
|
+
"sha256": "e9b3c905539a323d74a55f98455204bd46daca2daf49c476324e85684ba7971f"
|
|
3736
3736
|
},
|
|
3737
3737
|
{
|
|
3738
3738
|
"path": "modules/agents/migrations/agents_001_definitions.sql",
|
|
@@ -3754,6 +3754,11 @@
|
|
|
3754
3754
|
"mode": "0000644",
|
|
3755
3755
|
"sha256": "31a5ac35536cecd264bc989907f4cf094e050db0b8de85e7e3e7831cf7c25a9d"
|
|
3756
3756
|
},
|
|
3757
|
+
{
|
|
3758
|
+
"path": "modules/agents/migrations/agents_005_cron_trigger.sql",
|
|
3759
|
+
"mode": "0000644",
|
|
3760
|
+
"sha256": "5d422998b1d53ee015f4a494ccede1adcb1eb90bd501bd28c1d88f9840b87367"
|
|
3761
|
+
},
|
|
3757
3762
|
{
|
|
3758
3763
|
"path": "modules/agents/module.json",
|
|
3759
3764
|
"mode": "0000644",
|
|
@@ -6312,7 +6317,7 @@
|
|
|
6312
6317
|
{
|
|
6313
6318
|
"path": "modules/provisioning/paid-shape-gate.js",
|
|
6314
6319
|
"mode": "0000644",
|
|
6315
|
-
"sha256": "
|
|
6320
|
+
"sha256": "7c08e627e228bec400e9e2dc204418a6995a4668eb6c1817ae59f20f77cbe71e"
|
|
6316
6321
|
},
|
|
6317
6322
|
{
|
|
6318
6323
|
"path": "modules/provisioning/pollers/catalog-backfill.js",
|
|
@@ -6367,7 +6372,7 @@
|
|
|
6367
6372
|
{
|
|
6368
6373
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
6369
6374
|
"mode": "0000644",
|
|
6370
|
-
"sha256": "
|
|
6375
|
+
"sha256": "63919aad35ff1f47c4981e5e5c1f1c08d79eb0de9588e0c3ba2c5ed488167211"
|
|
6371
6376
|
},
|
|
6372
6377
|
{
|
|
6373
6378
|
"path": "modules/provisioning/seams.js",
|
|
@@ -7692,12 +7697,12 @@
|
|
|
7692
7697
|
{
|
|
7693
7698
|
"path": "package-lock.json",
|
|
7694
7699
|
"mode": "0000644",
|
|
7695
|
-
"sha256": "
|
|
7700
|
+
"sha256": "d3b1b9f84e1713ff7815c19a9bd02b6c4de4dd3b44a5ede68ff56fbd5ad0cfcd"
|
|
7696
7701
|
},
|
|
7697
7702
|
{
|
|
7698
7703
|
"path": "package.json",
|
|
7699
7704
|
"mode": "0000644",
|
|
7700
|
-
"sha256": "
|
|
7705
|
+
"sha256": "4cd9682e13a9a2e1e358c7bd500286378e7b5b8ceb1bb284a925b91ab8de2a08"
|
|
7701
7706
|
},
|
|
7702
7707
|
{
|
|
7703
7708
|
"path": "public-docs/index.html",
|
|
@@ -9702,7 +9707,7 @@
|
|
|
9702
9707
|
{
|
|
9703
9708
|
"path": "src/module-api.js",
|
|
9704
9709
|
"mode": "0000644",
|
|
9705
|
-
"sha256": "
|
|
9710
|
+
"sha256": "ae3867dc81f944026ce67bae25ca927232b84accc4ed03d4ef45798526c72c46"
|
|
9706
9711
|
},
|
|
9707
9712
|
{
|
|
9708
9713
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9844,6 +9849,11 @@
|
|
|
9844
9849
|
"mode": "0000644",
|
|
9845
9850
|
"sha256": "eea8f9e0bdf2c84fabc81e16c30e4026a7a336c1e705d614e68b805957b9b74a"
|
|
9846
9851
|
},
|
|
9852
|
+
{
|
|
9853
|
+
"path": "tests/agents_cron_trigger.mjs",
|
|
9854
|
+
"mode": "0000644",
|
|
9855
|
+
"sha256": "be78fa37657214c39a4880d2e1b44a1647c8b509d63262ca9a4a334431d7bdde"
|
|
9856
|
+
},
|
|
9847
9857
|
{
|
|
9848
9858
|
"path": "tests/agents_event_router.mjs",
|
|
9849
9859
|
"mode": "0000644",
|
|
@@ -9877,7 +9887,7 @@
|
|
|
9877
9887
|
{
|
|
9878
9888
|
"path": "tests/agents_validate.mjs",
|
|
9879
9889
|
"mode": "0000644",
|
|
9880
|
-
"sha256": "
|
|
9890
|
+
"sha256": "164059d69fabbd5dd70a29b29c524b5359f0346b71091e73a5e4fafa5dbc0804"
|
|
9881
9891
|
},
|
|
9882
9892
|
{
|
|
9883
9893
|
"path": "tests/agents_write_routes.mjs",
|
|
@@ -12727,17 +12737,17 @@
|
|
|
12727
12737
|
{
|
|
12728
12738
|
"path": "tests/provisioning_module_selection.mjs",
|
|
12729
12739
|
"mode": "0000644",
|
|
12730
|
-
"sha256": "
|
|
12740
|
+
"sha256": "540e9b5b158a5623525924403bcef444f87fe0364591cd9c6b9e0a55f4f6295c"
|
|
12731
12741
|
},
|
|
12732
12742
|
{
|
|
12733
12743
|
"path": "tests/provisioning_onboard_mode.mjs",
|
|
12734
12744
|
"mode": "0000644",
|
|
12735
|
-
"sha256": "
|
|
12745
|
+
"sha256": "f7a01aa6ed190c1ad544d91312909b081446a7fc2b6ef339f3ba8fa4d59f0306"
|
|
12736
12746
|
},
|
|
12737
12747
|
{
|
|
12738
12748
|
"path": "tests/provisioning_paid_shape_gate.mjs",
|
|
12739
12749
|
"mode": "0000644",
|
|
12740
|
-
"sha256": "
|
|
12750
|
+
"sha256": "19961880545fe8f6c9eaf79095aa0d08f7862409109d581cac828f0f20b1be69"
|
|
12741
12751
|
},
|
|
12742
12752
|
{
|
|
12743
12753
|
"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": {
|
|
@@ -2246,5 +2246,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2246
2246
|
landed since 1.19.884 with no explicit bump. run 35464802278. (task 1002620)
|
|
2247
2247
|
1.19.886 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2248
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)
|
|
2251
|
+
1.19.888 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2252
|
+
landed since 1.19.887 with no explicit bump. run 35468948342. (task 1002620)
|
|
2249
2253
|
---------------------------------------------------------------------------
|
|
2250
2254
|
```
|
|
@@ -48,8 +48,31 @@
|
|
|
48
48
|
const EVENT_CATALOG = Object.freeze(['task.shipped', 'idea.graded.full']);
|
|
49
49
|
|
|
50
50
|
// Mirrors agents_definitions_trigger_type_chk. 'event' consumes the catalog above;
|
|
51
|
-
// 'on-demand' is the historian shape (a builder asks, the agent answers)
|
|
52
|
-
|
|
51
|
+
// 'on-demand' is the historian shape (a builder asks, the agent answers); 'cron'
|
|
52
|
+
// fires on a cadence and consumes CADENCES below (task 1004008, agents_005).
|
|
53
|
+
const TRIGGER_TYPES = Object.freeze(['event', 'on-demand', 'cron']);
|
|
54
|
+
|
|
55
|
+
// The cadence vocabulary a 'cron' trigger may declare, in trigger_spec.cadence.
|
|
56
|
+
//
|
|
57
|
+
// DUPLICATED FROM scripts/gds/routine-schedule.js ON PURPOSE, for the reason
|
|
58
|
+
// RANK_ORDER below is duplicated: a module may require only src/module-api.js
|
|
59
|
+
// (ADR 0083), so it cannot reach into scripts/. The two copies are kept honest by
|
|
60
|
+
// tests/agents_cron_trigger.mjs, which reads routine-schedule.js's own CADENCES +
|
|
61
|
+
// isUniformDivisor and asserts this file agrees — a drifting copy fails there
|
|
62
|
+
// rather than at some future tick.
|
|
63
|
+
//
|
|
64
|
+
// N MUST DIVIDE 60 for the sub-hourly form. A cron step restarts each hour, so
|
|
65
|
+
// only a divisor yields a uniform period: `*/15` fires :00 :15 :30 :45 always,
|
|
66
|
+
// but `*/7` fires :00…:56 then waits 4 — a "7min" cadence that is not one.
|
|
67
|
+
const CADENCES = Object.freeze(['hourly', 'daily', 'weekly', 'monthly']);
|
|
68
|
+
const SUBHOURLY_RE = /^([1-9]\d*)min$/;
|
|
69
|
+
function isCadence(name) {
|
|
70
|
+
if (CADENCES.includes(name)) return true;
|
|
71
|
+
const m = SUBHOURLY_RE.exec(String(name == null ? '' : name));
|
|
72
|
+
if (!m) return false;
|
|
73
|
+
const n = Number(m[1]);
|
|
74
|
+
return Number.isInteger(n) && n > 0 && n < 60 && 60 % n === 0;
|
|
75
|
+
}
|
|
53
76
|
|
|
54
77
|
// Mirrors agents_definitions_model_tier_chk. No 'main' tier by design — an agent
|
|
55
78
|
// is a subagent, never the main session.
|
|
@@ -222,6 +245,29 @@ function validateAgentDefinition(obj, opts = {}) {
|
|
|
222
245
|
}
|
|
223
246
|
}
|
|
224
247
|
|
|
248
|
+
// --- the cadence, fail-closed, the same shape as the event catalog above ----
|
|
249
|
+
// agents_005's CHECK pins that a cron definition HAS a non-empty cadence; the
|
|
250
|
+
// vocabulary is pinned here, because the sub-hourly rule is a predicate and a
|
|
251
|
+
// CHECK spelling it out would be a second copy free to drift.
|
|
252
|
+
//
|
|
253
|
+
// The reach is deliberately the EVENT BLOCK'S reach, no more: an UNWIRED
|
|
254
|
+
// cadence is refused wherever it appears, including on a non-cron definition
|
|
255
|
+
// where it is dead config. A VALID cadence on a non-cron definition is ACCEPTED,
|
|
256
|
+
// exactly as a wired `event:` on an on-demand definition is — harmless, unread,
|
|
257
|
+
// and not this validator's call to police. Saying otherwise in a comment would
|
|
258
|
+
// describe a guard that is not here.
|
|
259
|
+
if (spec) {
|
|
260
|
+
const cadence = safeGet(spec, 'cadence');
|
|
261
|
+
const hasCadenceKey = cadence !== undefined && cadence !== null;
|
|
262
|
+
if (triggerType === 'cron' && !isNonEmptyString(cadence)) {
|
|
263
|
+
E('trigger_cadence_missing', 'trigger_spec.cadence',
|
|
264
|
+
`trigger_spec.cadence: required for a 'cron' trigger — a definition with no cadence can never fire. Named cadences: ${CADENCES.join(', ')}; or a sub-hourly '<N>min' where N divides 60.`);
|
|
265
|
+
} else if (hasCadenceKey && !isCadence(cadence)) {
|
|
266
|
+
E('trigger_cadence_unwired', 'trigger_spec.cadence',
|
|
267
|
+
`trigger_spec.cadence: ${JSON.stringify(cadence)} is not a cadence. Named cadences: ${CADENCES.join(', ')}; or a sub-hourly '<N>min' where N divides 60 (so the declared period and the realized one are the same claim).`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
225
271
|
// --- model tier -----------------------------------------------------------
|
|
226
272
|
const rawTier = safeGet(obj, 'model_tier');
|
|
227
273
|
const modelTier = rawTier === undefined || rawTier === null ? 'default' : rawTier;
|
|
@@ -355,6 +401,7 @@ function validateAgentDefinition(obj, opts = {}) {
|
|
|
355
401
|
module.exports = {
|
|
356
402
|
validateAgentDefinition,
|
|
357
403
|
EVENT_CATALOG,
|
|
404
|
+
CADENCES, isCadence,
|
|
358
405
|
TRIGGER_TYPES,
|
|
359
406
|
MODEL_TIERS,
|
|
360
407
|
RANK_ORDER,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
-- agents_005_cron_trigger.sql — make 'cron' a trigger type that can exist (task 1004008).
|
|
2
|
+
--
|
|
3
|
+
-- Tasks 1002498 (the cron-router poller) and 1002501 (the cron roster) both ask
|
|
4
|
+
-- for a timer whose each tick SELECTS DUE CRON AGENTS. There are none and there
|
|
5
|
+
-- can be none: agents_001 pinned trigger_type to ('event','on-demand') on BOTH
|
|
6
|
+
-- agents_definitions and agents_runs, and lib/validate.js mirrors it. Build the
|
|
7
|
+
-- poller against that and you get a timer that is correct, tested, and selects
|
|
8
|
+
-- from an empty set by construction — green tests over an unreachable branch,
|
|
9
|
+
-- which is a false claim of coverage rather than coverage. This migration is the
|
|
10
|
+
-- enabling half; the poller is 1002498's and is deliberately NOT built here.
|
|
11
|
+
--
|
|
12
|
+
-- WIDENING A FAIL-CLOSED ENUM IS THE POINT, NOT A WORKAROUND. agents_001's header
|
|
13
|
+
-- calls these enums fail-closed on purpose: "an unwired trigger name is refused,
|
|
14
|
+
-- never silently stored". That rule is kept — 'cron' stops being unwired in the
|
|
15
|
+
-- same change that makes it storable, because lib/validate.js is widened and
|
|
16
|
+
-- constrained alongside. What is still true after this: a trigger name outside
|
|
17
|
+
-- the three is refused at both the validator and the constraint.
|
|
18
|
+
--
|
|
19
|
+
-- BOTH TABLES, AND WHY THE LEDGER MATTERS AS MUCH AS THE DEFINITION. agents_001
|
|
20
|
+
-- constrains trigger_type on agents_runs too. Widen only the definition and a
|
|
21
|
+
-- cron agent could be stored and enabled, and then fail at the moment it fires,
|
|
22
|
+
-- when the ledger insert violates its own CHECK — the worst shape available: a
|
|
23
|
+
-- definition that looks armed and cannot record a single run.
|
|
24
|
+
--
|
|
25
|
+
-- A CRON DEFINITION MUST SAY WHEN. agents_001 already refuses an event definition
|
|
26
|
+
-- with no event name ("a definition that can never fire"). A cron definition with
|
|
27
|
+
-- no cadence is the same defect wearing a different key, so it gets the same
|
|
28
|
+
-- treatment rather than being left for the poller to interpret at tick time.
|
|
29
|
+
-- The cadence VOCABULARY is validated in lib/validate.js, not here: the named set
|
|
30
|
+
-- ('hourly','daily','weekly','monthly') plus the open-ended sub-hourly form
|
|
31
|
+
-- ('15min') is a predicate, and a CHECK that tried to spell the sub-hourly rule —
|
|
32
|
+
-- N must divide 60 — would be a second, drifting copy of it. The constraint
|
|
33
|
+
-- pins the SHAPE (a non-empty cadence key is present); the validator pins the
|
|
34
|
+
-- VALUE. That is the same division agents_001 draws for `event`.
|
|
35
|
+
--
|
|
36
|
+
-- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
|
|
37
|
+
-- object is agents_-prefixed, there is no down-migration, and the filename is
|
|
38
|
+
-- agents_-prefixed (ADR 0108 §5). Idempotent: safe to re-run. No existing row
|
|
39
|
+
-- changes — widening a CHECK can only make rows MORE legal, so nothing that
|
|
40
|
+
-- validates today stops validating.
|
|
41
|
+
|
|
42
|
+
BEGIN;
|
|
43
|
+
|
|
44
|
+
DO $$
|
|
45
|
+
BEGIN
|
|
46
|
+
-- agents_definitions: the three WIRED trigger types.
|
|
47
|
+
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_definitions_trigger_type_chk') THEN
|
|
48
|
+
ALTER TABLE agents_definitions DROP CONSTRAINT agents_definitions_trigger_type_chk;
|
|
49
|
+
END IF;
|
|
50
|
+
ALTER TABLE agents_definitions ADD CONSTRAINT agents_definitions_trigger_type_chk
|
|
51
|
+
CHECK (trigger_type IN ('event', 'on-demand', 'cron'));
|
|
52
|
+
|
|
53
|
+
-- agents_runs: the same three, so a cron fire can be RECORDED and not only run.
|
|
54
|
+
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_runs_trigger_type_chk') THEN
|
|
55
|
+
ALTER TABLE agents_runs DROP CONSTRAINT agents_runs_trigger_type_chk;
|
|
56
|
+
END IF;
|
|
57
|
+
ALTER TABLE agents_runs ADD CONSTRAINT agents_runs_trigger_type_chk
|
|
58
|
+
CHECK (trigger_type IN ('event', 'on-demand', 'cron'));
|
|
59
|
+
|
|
60
|
+
-- The twin of agents_definitions_trigger_spec_event_chk: a cron definition
|
|
61
|
+
-- carries a non-empty cadence, or it is a definition that can never fire.
|
|
62
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_definitions_trigger_spec_cadence_chk') THEN
|
|
63
|
+
ALTER TABLE agents_definitions ADD CONSTRAINT agents_definitions_trigger_spec_cadence_chk
|
|
64
|
+
CHECK (trigger_type <> 'cron'
|
|
65
|
+
OR (trigger_spec ? 'cadence' AND length(trigger_spec ->> 'cadence') > 0));
|
|
66
|
+
END IF;
|
|
67
|
+
END $$;
|
|
68
|
+
|
|
69
|
+
-- agents_001 indexes (trigger_type, trigger_spec->>'event') for the event
|
|
70
|
+
-- router's "who wants this event" read. The cron router's read is a different
|
|
71
|
+
-- question — "which cron agents are due" — and it is answered by scanning the
|
|
72
|
+
-- cron rows, which are few. Adding a cadence index now would be indexing for a
|
|
73
|
+
-- query no committed code makes; 1002498 adds one if its poller needs it.
|
|
74
|
+
|
|
75
|
+
COMMENT ON CONSTRAINT agents_definitions_trigger_type_chk ON agents_definitions IS
|
|
76
|
+
'The WIRED trigger vocabulary (task 1004008 added cron). Mirrored by TRIGGER_TYPES in modules/agents/lib/validate.js — widen both together or the two disagree about what can exist.';
|
|
77
|
+
|
|
78
|
+
COMMIT;
|
|
@@ -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.888",
|
|
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.888",
|
|
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.888",
|
|
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
|
@@ -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.888'; // 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');
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// tests/agents_cron_trigger.mjs — 'cron' is a trigger type that can EXIST
|
|
2
|
+
// (task 1004008, goal 1000095).
|
|
3
|
+
//
|
|
4
|
+
// Tasks 1002498 (cron-router poller) and 1002501 (cron roster) both select "due
|
|
5
|
+
// cron agents" from a set that was empty by construction: agents_001 pinned
|
|
6
|
+
// trigger_type to ('event','on-demand') on BOTH agents_definitions and
|
|
7
|
+
// agents_runs, and lib/validate.js mirrored it. A poller built against that is
|
|
8
|
+
// correct, tested, and unreachable — green tests over a branch nothing can enter.
|
|
9
|
+
//
|
|
10
|
+
// What this locks:
|
|
11
|
+
// * the THREE places that must agree (validator, definitions CHECK, runs CHECK)
|
|
12
|
+
// — the ledger constraint is the one that is easy to forget, and forgetting it
|
|
13
|
+
// yields the worst shape available: an agent that looks armed, fires, and
|
|
14
|
+
// then violates a CHECK when it tries to record the run.
|
|
15
|
+
// * the cadence VOCABULARY, including the sub-hourly divisor rule.
|
|
16
|
+
// * that validate.js's duplicated CADENCES really match scripts/gds/
|
|
17
|
+
// routine-schedule.js's, which is the copy this one was made from.
|
|
18
|
+
// * that widening the enum did not loosen it — an unwired name is still refused.
|
|
19
|
+
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const v = require('../modules/agents/lib/validate.js');
|
|
30
|
+
const schedule = require('../scripts/gds/routine-schedule.js');
|
|
31
|
+
|
|
32
|
+
const sql = (f) => fs.readFileSync(path.join(ROOT, 'modules', 'agents', 'migrations', f), 'utf8');
|
|
33
|
+
const mig001 = sql('agents_001_definitions.sql');
|
|
34
|
+
const mig005 = sql('agents_005_cron_trigger.sql');
|
|
35
|
+
|
|
36
|
+
const def = (over = {}) => ({ name: 'ship-digest', persona: 'x'.repeat(40), ...over });
|
|
37
|
+
const cron = (cadence) => def({ trigger_type: 'cron', trigger_spec: { cadence } });
|
|
38
|
+
|
|
39
|
+
// ---- 1. the validator ------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
test("'cron' is a wired trigger type", () => {
|
|
42
|
+
assert.ok(v.TRIGGER_TYPES.includes('cron'));
|
|
43
|
+
// Widening is not loosening: the enum is still closed.
|
|
44
|
+
assert.equal(v.validateAgentDefinition(def({ trigger_type: 'webhook', trigger_spec: {} })).ok, false);
|
|
45
|
+
assert.deepEqual([...v.TRIGGER_TYPES].sort(), ['cron', 'event', 'on-demand']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a cron definition must say WHEN, or it can never fire', () => {
|
|
49
|
+
const r = v.validateAgentDefinition(def({ trigger_type: 'cron', trigger_spec: {} }));
|
|
50
|
+
assert.equal(r.ok, false);
|
|
51
|
+
assert.ok(r.errors.some((e) => e.code === 'trigger_cadence_missing'),
|
|
52
|
+
'the missing cadence is named as its own error, not folded into a generic spec failure');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('the cadence vocabulary: named, and sub-hourly where N divides 60', () => {
|
|
56
|
+
for (const good of ['hourly', 'daily', 'weekly', 'monthly', '15min', '5min', '30min', '1min']) {
|
|
57
|
+
assert.equal(v.validateAgentDefinition(cron(good)).ok, true, `${good} is a cadence`);
|
|
58
|
+
}
|
|
59
|
+
// 7 and 45 do not divide 60: `*/7` fires :00…:56 then waits 4, so the declared
|
|
60
|
+
// period and the realized period would be different claims.
|
|
61
|
+
for (const bad of ['7min', '45min', '0min', '60min', 'fortnightly', 'often', '']) {
|
|
62
|
+
assert.equal(v.validateAgentDefinition(cron(bad)).ok, false, `${bad} is not a cadence`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('an accepted cron definition keeps its cadence through normalization', () => {
|
|
67
|
+
// The bug class task 1004007 documents one axis over: validate accepted a field
|
|
68
|
+
// and then DROPPED it, so a definition looked configured and was not.
|
|
69
|
+
const r = v.validateAgentDefinition(cron('daily'));
|
|
70
|
+
assert.equal(r.ok, true);
|
|
71
|
+
assert.equal(r.value.trigger_spec.cadence, 'daily', 'the cadence survives into the normalized value');
|
|
72
|
+
assert.equal(r.value.trigger_type, 'cron');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the event path is untouched', () => {
|
|
76
|
+
assert.equal(v.validateAgentDefinition(def({ trigger_type: 'event', trigger_spec: { event: 'task.shipped' } })).ok, true);
|
|
77
|
+
assert.equal(v.validateAgentDefinition(def({ trigger_type: 'event', trigger_spec: {} })).ok, false);
|
|
78
|
+
assert.equal(v.validateAgentDefinition(def({ trigger_type: 'event', trigger_spec: { event: 'nope' } })).ok, false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a VALID cadence on a non-cron definition is accepted, as a wired event is', () => {
|
|
82
|
+
// Pinned because the reach is a judgement call and the comment beside the code
|
|
83
|
+
// claims exactly this. An UNWIRED cadence is still refused wherever it appears.
|
|
84
|
+
assert.equal(v.validateAgentDefinition(def({ trigger_type: 'on-demand', trigger_spec: { cadence: 'daily' } })).ok, true);
|
|
85
|
+
assert.equal(v.validateAgentDefinition(def({ trigger_type: 'on-demand', trigger_spec: { cadence: '7min' } })).ok, false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---- 2. the copy that is free to drift -------------------------------------
|
|
89
|
+
|
|
90
|
+
test('validate.js CADENCES agrees with routine-schedule.js, the copy it came from', () => {
|
|
91
|
+
// A module may require only src/module-api.js (ADR 0083), so validate.js cannot
|
|
92
|
+
// import this list — it holds a duplicate. This test is the thing that keeps the
|
|
93
|
+
// duplicate honest; without it the two are one edit away from disagreeing about
|
|
94
|
+
// what a cadence is, and nothing would say so until a tick.
|
|
95
|
+
assert.deepEqual([...v.CADENCES].sort(), [...schedule.CADENCES].sort());
|
|
96
|
+
for (const n of [1, 5, 7, 15, 30, 45, 60, 0]) {
|
|
97
|
+
assert.equal(v.isCadence(`${n}min`), schedule.isCadence(`${n}min`),
|
|
98
|
+
`the two copies agree on ${n}min`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---- 3. both CHECK constraints ---------------------------------------------
|
|
103
|
+
|
|
104
|
+
test('BOTH tables widen — the ledger as well as the definition', () => {
|
|
105
|
+
// agents_001 constrains trigger_type on agents_runs too. Widen only the
|
|
106
|
+
// definition and a cron agent stores, enables, fires, and THEN fails when the
|
|
107
|
+
// run insert violates its own CHECK.
|
|
108
|
+
for (const c of ['agents_definitions_trigger_type_chk', 'agents_runs_trigger_type_chk']) {
|
|
109
|
+
assert.ok(mig001.includes(c), `${c} exists in agents_001 — this test is about widening it`);
|
|
110
|
+
assert.match(mig005, new RegExp(`ADD CONSTRAINT ${c}[\\s\\S]{0,200}?'cron'`),
|
|
111
|
+
`${c} is re-added including 'cron'`);
|
|
112
|
+
assert.match(mig005, new RegExp(`DROP CONSTRAINT ${c}`),
|
|
113
|
+
`${c} is dropped before being re-added — ALTER cannot widen a CHECK in place`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('the migration pins the SHAPE of a cron spec, leaving the vocabulary to the validator', () => {
|
|
118
|
+
assert.match(mig005, /agents_definitions_trigger_spec_cadence_chk/);
|
|
119
|
+
assert.match(mig005, /trigger_type <> 'cron'/, 'the guard only binds cron rows');
|
|
120
|
+
assert.match(mig005, /trigger_spec \? 'cadence'/, 'and requires the key');
|
|
121
|
+
// The sub-hourly divisor rule must NOT be spelled in SQL — that would be a
|
|
122
|
+
// second copy of isCadence(), free to drift from the one the validator uses.
|
|
123
|
+
assert.ok(!/% *60|divid/i.test(mig005.replace(/^--.*$/gm, '')),
|
|
124
|
+
'no arithmetic cadence rule in the executable SQL — the predicate lives in one place');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('the migration is additive, namespaced and idempotent (ADR 0083 §5)', () => {
|
|
128
|
+
assert.match(mig005, /IF (NOT )?EXISTS/, 'guarded so a re-run is safe');
|
|
129
|
+
assert.ok(!/\bDROP TABLE\b|\bDROP COLUMN\b/i.test(mig005), 'nothing is destroyed');
|
|
130
|
+
for (const stmt of mig005.match(/(CREATE|ALTER)\s+TABLE\s+(\w+)/gi) || []) {
|
|
131
|
+
assert.match(stmt, /\s(agents_\w+)$/, `${stmt} touches only agents_-namespaced tables`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
@@ -164,12 +164,20 @@ await test('an on-demand definition carrying a WIRED event is accepted', () => {
|
|
|
164
164
|
|
|
165
165
|
// --- THE TRIGGER TYPE -------------------------------------------------------
|
|
166
166
|
|
|
167
|
-
await test('TRIGGER_TYPES is the
|
|
168
|
-
|
|
167
|
+
await test('TRIGGER_TYPES is the wired set the DB CHECK also pins', () => {
|
|
168
|
+
// Was the v1 PAIR; task 1004008 added 'cron' so tasks 1002498/1002501 select
|
|
169
|
+
// from a set that can be non-empty. Kept as an exact list on purpose: widening
|
|
170
|
+
// this vocabulary must be a deliberate edit here AND in agents_005's CHECK on
|
|
171
|
+
// both agents_definitions and agents_runs (tests/agents_cron_trigger.mjs pins
|
|
172
|
+
// that the three agree).
|
|
173
|
+
assert.deepEqual(TRIGGER_TYPES, ['event', 'on-demand', 'cron']);
|
|
169
174
|
});
|
|
170
175
|
|
|
171
176
|
await test('an unknown trigger type is refused', () => {
|
|
172
|
-
|
|
177
|
+
// 'cron' was this list's first entry until task 1004008 wired it. 'webhook' and
|
|
178
|
+
// 'schedule' are the plausible-but-unwired names now — the case exists to prove
|
|
179
|
+
// an unwired name is refused, so it needs names that are actually unwired.
|
|
180
|
+
for (const bad of ['webhook', 'schedule', 'Event', 'on demand', '', null, undefined, 7, {}]) {
|
|
173
181
|
const r = validateAgentDefinition({ ...OK_DEF, trigger_type: bad }, OPTS);
|
|
174
182
|
assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
|
|
175
183
|
assert.ok(hasCode(r, 'trigger_type_unknown'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
|
|
@@ -471,7 +479,7 @@ await test('a hostile getter cannot escape the validator', () => {
|
|
|
471
479
|
|
|
472
480
|
await test('EVERY error carries a code, a field and a human message', () => {
|
|
473
481
|
const r = validateAgentDefinition(
|
|
474
|
-
{ name: 'BAD NAME', trigger_type: '
|
|
482
|
+
{ name: 'BAD NAME', trigger_type: 'webhook', trigger_spec: 'nope', scope_modules: ['government'] },
|
|
475
483
|
{ authorRank: 'xenos', allowedModules: ['agents'] },
|
|
476
484
|
);
|
|
477
485
|
assert.equal(r.ok, false);
|
|
@@ -485,7 +493,7 @@ await test('EVERY error carries a code, a field and a human message', () => {
|
|
|
485
493
|
|
|
486
494
|
await test('validation reports ALL faults at once, not just the first', () => {
|
|
487
495
|
// An author fixing a definition file should not have to re-run four times.
|
|
488
|
-
const r = validateAgentDefinition({ name: 'X', persona: '', trigger_type: '
|
|
496
|
+
const r = validateAgentDefinition({ name: 'X', persona: '', trigger_type: 'webhook' }, OPTS);
|
|
489
497
|
for (const code of ['name_invalid', 'persona_missing', 'trigger_type_unknown']) {
|
|
490
498
|
assert.ok(hasCode(r, code), `${code} missing from ${JSON.stringify(codes(r))}`);
|
|
491
499
|
}
|
|
@@ -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();
|