@bongos/core 1.19.744 → 1.19.745
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/module-api-changelog.md +2 -0
- package/modules/provisioning/migrations/provisioning_015_domain_unique.sql +46 -0
- package/modules/provisioning/provisioning.js +30 -1
- package/modules/provisioning/routes/provisioning.js +6 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/provisioning_domain_unique.mjs +274 -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.745",
|
|
6
|
+
"core_contract": "1.19.745",
|
|
7
|
+
"source_commit": "35769e9ee6c0bd162dcd30f6360cf22a69adc5e6",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-15T15:
|
|
9
|
+
"built_at": "2026-09-15T15:42:00.191Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 479,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2109,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2613,
|
|
20
|
+
"tree_sha256": "8ee268f754cacf633cc6358ef765b30b214928f4d38914cbbc0788b580835e83",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2492,7 +2492,7 @@
|
|
|
2492
2492
|
{
|
|
2493
2493
|
"path": "docs/module-api-changelog.md",
|
|
2494
2494
|
"mode": "0000644",
|
|
2495
|
-
"sha256": "
|
|
2495
|
+
"sha256": "68cb61e401d3e601932f1db71ef88346adad1f2474c862143a224ed354ba0615"
|
|
2496
2496
|
},
|
|
2497
2497
|
{
|
|
2498
2498
|
"path": "docs/modules-contract.md",
|
|
@@ -6229,6 +6229,11 @@
|
|
|
6229
6229
|
"mode": "0000644",
|
|
6230
6230
|
"sha256": "f4562bd90fdae26c5f7c51851bd2ad7752535bd0dcfb62dd567c11e76330c42b"
|
|
6231
6231
|
},
|
|
6232
|
+
{
|
|
6233
|
+
"path": "modules/provisioning/migrations/provisioning_015_domain_unique.sql",
|
|
6234
|
+
"mode": "0000644",
|
|
6235
|
+
"sha256": "136b0b7e0485c1602a403767863137da0536e058b8913228d3a3e5a6ad56f512"
|
|
6236
|
+
},
|
|
6232
6237
|
{
|
|
6233
6238
|
"path": "modules/provisioning/module.json",
|
|
6234
6239
|
"mode": "0000644",
|
|
@@ -6257,7 +6262,7 @@
|
|
|
6257
6262
|
{
|
|
6258
6263
|
"path": "modules/provisioning/provisioning.js",
|
|
6259
6264
|
"mode": "0000644",
|
|
6260
|
-
"sha256": "
|
|
6265
|
+
"sha256": "b3a1261dbdefd526a93d75c8f46a7b896f2331292b41a919a73dde1ffba45eed"
|
|
6261
6266
|
},
|
|
6262
6267
|
{
|
|
6263
6268
|
"path": "modules/provisioning/rate-limit.js",
|
|
@@ -6282,7 +6287,7 @@
|
|
|
6282
6287
|
{
|
|
6283
6288
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
6284
6289
|
"mode": "0000644",
|
|
6285
|
-
"sha256": "
|
|
6290
|
+
"sha256": "a3812af0cc12dfafff47c1b9b03edea6ef61f1f4728acead1628d0b1a1669432"
|
|
6286
6291
|
},
|
|
6287
6292
|
{
|
|
6288
6293
|
"path": "modules/provisioning/starter-bundles.js",
|
|
@@ -7542,12 +7547,12 @@
|
|
|
7542
7547
|
{
|
|
7543
7548
|
"path": "package-lock.json",
|
|
7544
7549
|
"mode": "0000644",
|
|
7545
|
-
"sha256": "
|
|
7550
|
+
"sha256": "c5eabd4e0de92c43d27602e996ffeaeef177349bb4ed045a208578785b8af8be"
|
|
7546
7551
|
},
|
|
7547
7552
|
{
|
|
7548
7553
|
"path": "package.json",
|
|
7549
7554
|
"mode": "0000644",
|
|
7550
|
-
"sha256": "
|
|
7555
|
+
"sha256": "d9ddaa7b2fa408cceb8cdc0540ad523f0d64da3ec91a9420f73058088585dee9"
|
|
7551
7556
|
},
|
|
7552
7557
|
{
|
|
7553
7558
|
"path": "public-docs/index.html",
|
|
@@ -9347,7 +9352,7 @@
|
|
|
9347
9352
|
{
|
|
9348
9353
|
"path": "src/module-api.js",
|
|
9349
9354
|
"mode": "0000644",
|
|
9350
|
-
"sha256": "
|
|
9355
|
+
"sha256": "96b3c84f00546eba7a57b31eba4a96acf0d6285c3715a1370d3527242e4d161b"
|
|
9351
9356
|
},
|
|
9352
9357
|
{
|
|
9353
9358
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12009,6 +12014,11 @@
|
|
|
12009
12014
|
"mode": "0000644",
|
|
12010
12015
|
"sha256": "62ea8ffc2c21c351289dc7f96cab38fcd5824afba1fed0df7a545cd5df9e396c"
|
|
12011
12016
|
},
|
|
12017
|
+
{
|
|
12018
|
+
"path": "tests/provisioning_domain_unique.mjs",
|
|
12019
|
+
"mode": "0000644",
|
|
12020
|
+
"sha256": "d1c9456ad442caf2485c5c38e5bad98ef6c834589e405eade59cbeffec534ea0"
|
|
12021
|
+
},
|
|
12012
12022
|
{
|
|
12013
12023
|
"path": "tests/provisioning_github_app_routes.mjs",
|
|
12014
12024
|
"mode": "0000644",
|
|
@@ -1962,5 +1962,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1962
1962
|
landed since 1.19.742 with no explicit bump. run 34984876076. (task 1002620)
|
|
1963
1963
|
1.19.744 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1964
1964
|
landed since 1.19.743 with no explicit bump. run 34985887902. (task 1002620)
|
|
1965
|
+
1.19.745 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1966
|
+
landed since 1.19.744 with no explicit bump. run 34990123950. (task 1002620)
|
|
1965
1967
|
---------------------------------------------------------------------------
|
|
1966
1968
|
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- provisioning_015_domain_unique.sql — database uniqueness on an instance's
|
|
2
|
+
-- ADDRESS, closing the read-then-write race both write paths still carry
|
|
3
|
+
-- (task 1002901; source idea 1000722, the task 1002704 review).
|
|
4
|
+
--
|
|
5
|
+
-- THE GAP. `slug` has carried `UNIQUE` since provisioning_001_tables.sql:35;
|
|
6
|
+
-- `domain` was declared plainly at :49 and never constrained. Both address write
|
|
7
|
+
-- paths enforce uniqueness in APPLICATION code with an unguarded read-then-write:
|
|
8
|
+
-- the create route calls getInstanceByDomain and then INSERTs, and the
|
|
9
|
+
-- domain-attach route calls getInstanceByDomain and then updateInstanceDomain.
|
|
10
|
+
-- Two concurrent requests interleaving between the read and the write both pass
|
|
11
|
+
-- their check, and one address ends up held by two instances.
|
|
12
|
+
--
|
|
13
|
+
-- WHY IT MATTERS MORE NOW. Task 1002704 put the domain write on the HOT PATH:
|
|
14
|
+
-- auto-assign stamps defaultDomainForSlug(slug, apex) on EVERY creation, so the
|
|
15
|
+
-- race is no longer an occasional bring-your-own step — it is every project
|
|
16
|
+
-- anyone creates. Working area 1's done-when is that any new user can create a
|
|
17
|
+
-- project; two projects answering at one hostname is that promise breaking under
|
|
18
|
+
-- exactly the concurrency a growing platform produces.
|
|
19
|
+
--
|
|
20
|
+
-- THE APPLICATION CHECKS STAY. This index is a BACKSTOP, not a replacement: the
|
|
21
|
+
-- read-then-write checks produce the good error message ("That address is
|
|
22
|
+
-- already held by another instance"), and this makes the guarantee real when two
|
|
23
|
+
-- requests interleave. The routes map this index's 23505 onto that same
|
|
24
|
+
-- `domain_taken` 409, so a caller cannot tell which layer caught it.
|
|
25
|
+
--
|
|
26
|
+
-- WHY PARTIAL — `status <> 'torn_down'` IS LOAD-BEARING, NOT A TIDY-UP.
|
|
27
|
+
-- A torn-down instance KEEPS its domain by design (ADR 0181 Decision 2: the
|
|
28
|
+
-- revive path re-uses that exact hostname, and task 1002897 automates only the
|
|
29
|
+
-- DNS-park and Caddy-removal legs while confirming that retention). An
|
|
30
|
+
-- UNCONDITIONAL unique index would therefore permanently block re-creating a
|
|
31
|
+
-- project at an address that was once torn down — a worse bug than the race it
|
|
32
|
+
-- closes. The partial form mirrors the ux_provisioning_intents_open precedent at
|
|
33
|
+
-- provisioning_001_tables.sql:129.
|
|
34
|
+
--
|
|
35
|
+
-- WHY lower(domain). It matches getInstanceByDomain's existing lookup
|
|
36
|
+
-- (`WHERE lower(domain) = $1`), so the index and the application check agree on
|
|
37
|
+
-- what "the same address" means. Without it, 'Mercury.cloudbongos.com' and
|
|
38
|
+
-- 'mercury.cloudbongos.com' would be two rows to the index and one address to
|
|
39
|
+
-- every reader.
|
|
40
|
+
--
|
|
41
|
+
-- SAFE TO CREATE. Re-verified live against the fleet at claim time (2026-09-15):
|
|
42
|
+
-- 23 instances, 6 rows fall under this predicate, all distinct, no case
|
|
43
|
+
-- variants. `IF NOT EXISTS` keeps the migration idempotent across re-runs.
|
|
44
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ux_provisioning_instances_domain
|
|
45
|
+
ON provisioning_instances (lower(domain))
|
|
46
|
+
WHERE domain IS NOT NULL AND status <> 'torn_down';
|
|
@@ -781,6 +781,23 @@ async function getInstanceBySlug(db, slug) {
|
|
|
781
781
|
return rows[0] || null;
|
|
782
782
|
}
|
|
783
783
|
|
|
784
|
+
// The partial unique index provisioning_015 puts on lower(domain) — the database
|
|
785
|
+
// backstop behind both address write paths' read-then-write check (task 1002901).
|
|
786
|
+
// Named once, here, so the migration that CREATES it and the predicate that
|
|
787
|
+
// RECOGNIZES it cannot drift: a rename in one and not the other would leave
|
|
788
|
+
// isDomainTaken() silently never matching. The test reads the .sql and pins both.
|
|
789
|
+
const DOMAIN_UNIQUE_INDEX = 'ux_provisioning_instances_domain';
|
|
790
|
+
|
|
791
|
+
// Did this error come from two requests racing for one address? The slug UNIQUE
|
|
792
|
+
// and the domain index share error code 23505, so the code alone cannot tell them
|
|
793
|
+
// apart — `constraint` is what names which one actually fired. Both address write
|
|
794
|
+
// paths check uniqueness in application code first and produce the good message;
|
|
795
|
+
// this predicate is how the clash that slips through that window still answers
|
|
796
|
+
// `domain_taken` instead of surfacing as a raw 500 (task 1002901).
|
|
797
|
+
function isDomainTaken(e) {
|
|
798
|
+
return !!e && e.code === '23505' && e.constraint === DOMAIN_UNIQUE_INDEX;
|
|
799
|
+
}
|
|
800
|
+
|
|
784
801
|
// The instance currently holding `domain` (case-insensitive), if any — backs the
|
|
785
802
|
// domain-attach route's uniqueness check so one owner's address can never silently
|
|
786
803
|
// shadow another's (task 1002692). Caller normalizes; matching is defensive anyway.
|
|
@@ -1073,11 +1090,21 @@ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShap
|
|
|
1073
1090
|
);
|
|
1074
1091
|
return { instance: rows[0], created: true };
|
|
1075
1092
|
} catch (e) {
|
|
1076
|
-
// 23505 = unique_violation
|
|
1093
|
+
// 23505 = unique_violation, and since provisioning_015 TWO indexes raise it here
|
|
1094
|
+
// — slug UNIQUE and ux_provisioning_instances_domain. Resolve by SLUG FIRST,
|
|
1095
|
+
// whichever one Postgres named. That order is load-bearing: an idempotent
|
|
1096
|
+
// re-request duplicates the slug AND the <slug>.<apex> address auto-assign
|
|
1097
|
+
// stamped on the first one (task 1002704), so it violates BOTH and Postgres
|
|
1098
|
+
// reports only the index it checked first. Branching on `e.constraint` would
|
|
1099
|
+
// turn that re-request into a 409 whenever the domain index won the race — a
|
|
1100
|
+
// coin-flip bug that appears only once an address is assigned.
|
|
1077
1101
|
if (e && e.code === '23505') {
|
|
1078
1102
|
const existing = await getInstanceBySlug(db, slug);
|
|
1079
1103
|
if (existing) return { instance: existing, created: false };
|
|
1080
1104
|
}
|
|
1105
|
+
// No row under this slug, so a 23505 can only be the domain index: a real race
|
|
1106
|
+
// for an address taken between the route's check and this INSERT. It propagates
|
|
1107
|
+
// as-is, and the route maps it via isDomainTaken() onto the same domain_taken 409.
|
|
1081
1108
|
throw e;
|
|
1082
1109
|
}
|
|
1083
1110
|
}
|
|
@@ -1453,6 +1480,8 @@ module.exports = {
|
|
|
1453
1480
|
normalizeDescription, normalizeProjectDetail, projectDetailPatch, projectDetail,
|
|
1454
1481
|
// pure — GitHub App Manifest flow (task 2080)
|
|
1455
1482
|
githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
|
|
1483
|
+
// pure — the address race's database backstop (task 1002901, provisioning_015)
|
|
1484
|
+
DOMAIN_UNIQUE_INDEX, isDomainTaken,
|
|
1456
1485
|
// data access
|
|
1457
1486
|
isProvisionedHostname,
|
|
1458
1487
|
getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
|
|
@@ -416,9 +416,7 @@ module.exports = function provisioningRoutes() {
|
|
|
416
416
|
// never silently shadow another's.
|
|
417
417
|
if (domain) {
|
|
418
418
|
const holder = await provisioning.getInstanceByDomain(pool, domain);
|
|
419
|
-
if (holder && holder.slug !== slug) {
|
|
420
|
-
return res.fail('domain_taken', { status: 409, message: 'That address is already held by another instance.' });
|
|
421
|
-
}
|
|
419
|
+
if (holder && holder.slug !== slug) return res.fail('domain_taken', { status: 409, message: 'That address is already held by another instance.' });
|
|
422
420
|
}
|
|
423
421
|
}
|
|
424
422
|
const { instance, created } = await provisioning.createInstance(pool, {
|
|
@@ -522,6 +520,8 @@ module.exports = function provisioningRoutes() {
|
|
|
522
520
|
...(modulesIgnored ? { modules_ignored: modulesIgnored } : {}),
|
|
523
521
|
});
|
|
524
522
|
} catch (err) {
|
|
523
|
+
// Another instance took the address between the check above and the INSERT (task 1002901).
|
|
524
|
+
if (provisioning.isDomainTaken(err)) return res.fail('domain_taken', { status: 409, message: 'That address is already held by another instance.' });
|
|
525
525
|
log.error('[provisioning] POST /provisioning/instances', err);
|
|
526
526
|
res.fail('request_failed', { status: 500, message: 'internal error' });
|
|
527
527
|
}
|
|
@@ -1170,9 +1170,7 @@ module.exports = function provisioningRoutes() {
|
|
|
1170
1170
|
const releasing = current && current !== assignedDefault
|
|
1171
1171
|
&& provisioning.DOMAIN_RELEASE_FROM.has(inst.status) ? current : null;
|
|
1172
1172
|
const holder = await provisioning.getInstanceByDomain(pool, domain);
|
|
1173
|
-
if (holder && String(holder.id) !== String(inst.id)) {
|
|
1174
|
-
return res.fail('domain_taken', { status: 409, message: 'That address is already in use by another instance.' });
|
|
1175
|
-
}
|
|
1173
|
+
if (holder && String(holder.id) !== String(inst.id)) return res.fail('domain_taken', { status: 409, message: 'That address is already in use by another instance.' });
|
|
1176
1174
|
if (!provisioning.DOMAIN_ATTACH_FROM.has(inst.status)) {
|
|
1177
1175
|
return res.fail('domain_not_attachable', { status: 409,
|
|
1178
1176
|
message: `Instance is '${inst.status}' — an address can be attached once it settles ('active') or before standup ('requested').`,
|
|
@@ -1229,6 +1227,8 @@ module.exports = function provisioningRoutes() {
|
|
|
1229
1227
|
res.json({ ok: true, changed: true, queued, domain, replaced: releasing,
|
|
1230
1228
|
message: MESSAGES[`${queued ? 'queued' : 'recorded'}:${releasing ? 'repoint' : 'first'}`] });
|
|
1231
1229
|
} catch (err) {
|
|
1230
|
+
// Same race on the attach side: the index refuses the UPDATE, so nothing is recorded and nothing is enqueued.
|
|
1231
|
+
if (provisioning.isDomainTaken(err)) return res.fail('domain_taken', { status: 409, message: 'That address is already in use by another instance.' });
|
|
1232
1232
|
log.error('[provisioning] POST /provisioning/instances/:id/domain', err);
|
|
1233
1233
|
res.fail('domain_attach_failed', { status: 500, message: 'internal error' });
|
|
1234
1234
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.745",
|
|
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.745",
|
|
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.745",
|
|
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.745'; // 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,274 @@
|
|
|
1
|
+
// tests/provisioning_domain_unique.mjs
|
|
2
|
+
//
|
|
3
|
+
// The database backstop behind an instance's ADDRESS (task 1002901; source idea
|
|
4
|
+
// 1000722, the task 1002704 review).
|
|
5
|
+
//
|
|
6
|
+
// Both address write paths enforced uniqueness in application code with an
|
|
7
|
+
// unguarded read-then-write — getInstanceByDomain, then INSERT (create) or
|
|
8
|
+
// updateInstanceDomain (attach). Two concurrent requests interleaving between the
|
|
9
|
+
// read and the write both passed, and one address ended up held by two instances.
|
|
10
|
+
// provisioning_015 adds the partial unique index that makes the guarantee real;
|
|
11
|
+
// these tests hold the four things that index changed.
|
|
12
|
+
//
|
|
13
|
+
// Covers:
|
|
14
|
+
// • the migration — partial on status <> 'torn_down' (ADR 0181 Decision 2 keeps
|
|
15
|
+
// a torn-down instance's address for the revive path, so an UNCONDITIONAL
|
|
16
|
+
// index would permanently block re-creating there), keyed on lower(domain) to
|
|
17
|
+
// match getInstanceByDomain, and idempotent
|
|
18
|
+
// • DOMAIN_UNIQUE_INDEX / isDomainTaken — the predicate that tells the domain
|
|
19
|
+
// index's 23505 apart from the slug UNIQUE's, which share the error code
|
|
20
|
+
// • createInstance's SLUG-FIRST resolution — the ordering that keeps an
|
|
21
|
+
// idempotent re-request idempotent when it violates BOTH indexes at once
|
|
22
|
+
// • both routes translating the index's refusal into the same domain_taken 409
|
|
23
|
+
// the read-then-write check produces
|
|
24
|
+
//
|
|
25
|
+
// DB-free: the service slice drives the REAL createInstance through a fake `db`
|
|
26
|
+
// that throws like Postgres; the routes run over the real mounted router with
|
|
27
|
+
// data-access fns stubbed (the provisioning_domain_attach.mjs pattern). No network.
|
|
28
|
+
//
|
|
29
|
+
// Run: node tests/provisioning_domain_unique.mjs
|
|
30
|
+
|
|
31
|
+
import { strict as assert } from 'node:assert';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import http from 'node:http';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
39
|
+
|
|
40
|
+
let passed = 0, failed = 0;
|
|
41
|
+
function t(name, fn) {
|
|
42
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
43
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
44
|
+
}
|
|
45
|
+
async function ta(name, fn) {
|
|
46
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
47
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A unique_violation shaped like the one `pg` raises: the code alone cannot say
|
|
51
|
+
// WHICH index fired, which is the whole reason isDomainTaken reads `constraint`.
|
|
52
|
+
function uniqueViolation(constraint) {
|
|
53
|
+
return Object.assign(new Error(`duplicate key value violates unique constraint "${constraint}"`),
|
|
54
|
+
{ code: '23505', constraint });
|
|
55
|
+
}
|
|
56
|
+
const DOMAIN_VIOLATION = () => uniqueViolation('ux_provisioning_instances_domain');
|
|
57
|
+
const SLUG_VIOLATION = () => uniqueViolation('provisioning_instances_slug_key');
|
|
58
|
+
|
|
59
|
+
console.log('\nthe migration — provisioning_015_domain_unique.sql:');
|
|
60
|
+
|
|
61
|
+
const MIGRATION = fs.readFileSync(
|
|
62
|
+
path.resolve('modules/provisioning/migrations/provisioning_015_domain_unique.sql'), 'utf8');
|
|
63
|
+
|
|
64
|
+
t('creates the index this module names in code — one spelling, not two', () => {
|
|
65
|
+
assert.match(MIGRATION, new RegExp(`CREATE UNIQUE INDEX IF NOT EXISTS ${provisioning.DOMAIN_UNIQUE_INDEX}\\b`),
|
|
66
|
+
'the migration and DOMAIN_UNIQUE_INDEX must agree, or isDomainTaken silently never matches');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
t('is PARTIAL on status <> torn_down — a torn-down address is kept for the revive path (ADR 0181 D2)', () => {
|
|
70
|
+
assert.match(MIGRATION, /WHERE\s+domain IS NOT NULL AND status <> 'torn_down'/,
|
|
71
|
+
'an unconditional index would permanently block re-creating a project at a torn-down address');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
t('is keyed on lower(domain) so the index and getInstanceByDomain mean the same address', () => {
|
|
75
|
+
assert.match(MIGRATION, /ON provisioning_instances \(lower\(domain\)\)/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
t('is idempotent — IF NOT EXISTS, so a re-run is a no-op', () => {
|
|
79
|
+
assert.match(MIGRATION, /IF NOT EXISTS/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
console.log('\nisDomainTaken — telling two 23505s apart:');
|
|
83
|
+
|
|
84
|
+
t('the domain index firing is a domain clash', () => {
|
|
85
|
+
assert.equal(provisioning.isDomainTaken(DOMAIN_VIOLATION()), true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
t('the SLUG unique firing is NOT — same error code, different index, different answer', () => {
|
|
89
|
+
assert.equal(provisioning.isDomainTaken(SLUG_VIOLATION()), false,
|
|
90
|
+
'mapping a slug clash to domain_taken would tell an owner the wrong thing is taken');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
t('a 23505 naming no constraint is not assumed to be ours', () => {
|
|
94
|
+
assert.equal(provisioning.isDomainTaken(Object.assign(new Error('x'), { code: '23505' })), false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
t('any other error code is not a clash — not even one naming our index', () => {
|
|
98
|
+
// A 23503 (foreign-key violation) can name this index in `constraint` too, so the
|
|
99
|
+
// predicate must read BOTH fields; matching on the name alone would map an
|
|
100
|
+
// unrelated database fault onto a 409 that tells the caller the wrong thing.
|
|
101
|
+
assert.equal(provisioning.isDomainTaken(
|
|
102
|
+
Object.assign(new Error('fk'), { code: '23503', constraint: 'ux_provisioning_instances_domain' })), false);
|
|
103
|
+
assert.equal(provisioning.isDomainTaken(new Error('boom')), false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
t('null / undefined never throw — the predicate runs inside a catch', () => {
|
|
107
|
+
assert.equal(provisioning.isDomainTaken(null), false);
|
|
108
|
+
assert.equal(provisioning.isDomainTaken(undefined), false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
console.log('\ncreateInstance — SLUG-FIRST resolution (the coin-flip bug this ordering prevents):');
|
|
112
|
+
|
|
113
|
+
// The real createInstance, driven through a fake db: the INSERT throws like
|
|
114
|
+
// Postgres, and the SELECT the catch falls back to answers for the slug.
|
|
115
|
+
function fakeDb({ insertThrows, slugRow }) {
|
|
116
|
+
const calls = [];
|
|
117
|
+
return {
|
|
118
|
+
calls,
|
|
119
|
+
query: async (sql, params) => {
|
|
120
|
+
calls.push(sql.trim().split('\n')[0]);
|
|
121
|
+
if (/^INSERT INTO provisioning_instances/.test(sql.trim())) throw insertThrows;
|
|
122
|
+
if (/SELECT \* FROM provisioning_instances WHERE slug = \$1/.test(sql)) {
|
|
123
|
+
return { rows: slugRow ? [{ ...slugRow, slug: params[0] }] : [] };
|
|
124
|
+
}
|
|
125
|
+
return { rows: [] };
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await ta('a re-request violating BOTH indexes stays idempotent when Postgres names the DOMAIN one', async () => {
|
|
131
|
+
// Auto-assign stamps <slug>.<apex> on every creation (task 1002704), so an
|
|
132
|
+
// idempotent re-request duplicates the slug AND its address. Postgres reports
|
|
133
|
+
// only the index it checked first; branching on e.constraint would turn the
|
|
134
|
+
// re-request into a 409 whenever the domain index won that race.
|
|
135
|
+
const db = fakeDb({ insertThrows: DOMAIN_VIOLATION(), slugRow: { id: 18, owner_builder_id: 7 } });
|
|
136
|
+
const out = await provisioning.createInstance(db, { ownerBuilderId: 7, slug: 'mercury', domain: 'mercury.example.com' });
|
|
137
|
+
assert.equal(out.created, false, 'a re-request is not a new project');
|
|
138
|
+
assert.equal(out.instance.id, 18, 'and it returns the EXISTING row, not an error');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await ta('the same re-request is idempotent when Postgres names the SLUG one (unchanged behaviour)', async () => {
|
|
142
|
+
const db = fakeDb({ insertThrows: SLUG_VIOLATION(), slugRow: { id: 18, owner_builder_id: 7 } });
|
|
143
|
+
const out = await provisioning.createInstance(db, { ownerBuilderId: 7, slug: 'mercury', domain: 'mercury.example.com' });
|
|
144
|
+
assert.equal(out.created, false);
|
|
145
|
+
assert.equal(out.instance.id, 18);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await ta('a REAL address race — domain taken, slug free — propagates for the route to map', async () => {
|
|
149
|
+
const db = fakeDb({ insertThrows: DOMAIN_VIOLATION(), slugRow: null });
|
|
150
|
+
await assert.rejects(
|
|
151
|
+
() => provisioning.createInstance(db, { ownerBuilderId: 7, slug: 'venus', domain: 'mercury.example.com' }),
|
|
152
|
+
(e) => provisioning.isDomainTaken(e),
|
|
153
|
+
'no row under this slug means the clash can only be the address');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await ta('an unrelated database error is never swallowed as a re-request', async () => {
|
|
157
|
+
const db = fakeDb({ insertThrows: Object.assign(new Error('connection terminated'), { code: '57P01' }), slugRow: null });
|
|
158
|
+
await assert.rejects(
|
|
159
|
+
() => provisioning.createInstance(db, { ownerBuilderId: 7, slug: 'venus' }),
|
|
160
|
+
/connection terminated/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── route slice ────────────────────────────────────────────────────────────
|
|
164
|
+
let instance = null;
|
|
165
|
+
let createThrows = null;
|
|
166
|
+
let updateThrows = null;
|
|
167
|
+
let updateCalls = [];
|
|
168
|
+
let enqueueCalls = [];
|
|
169
|
+
|
|
170
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
171
|
+
provisioning.getInstanceByDomain = async () => null; // the check PASSES — the race is after it
|
|
172
|
+
provisioning.getInstanceBySlug = async () => null;
|
|
173
|
+
provisioning.createInstance = async () => { if (createThrows) throw createThrows; return { instance: { id: 1 }, created: true }; };
|
|
174
|
+
provisioning.updateInstanceDomain = async (_db, id, domain, releasing = null) => {
|
|
175
|
+
updateCalls.push({ id, domain, releasing });
|
|
176
|
+
if (updateThrows) throw updateThrows;
|
|
177
|
+
return { ...instance, id, domain };
|
|
178
|
+
};
|
|
179
|
+
provisioning.enqueueIntent = async (_db, id, action) => { enqueueCalls.push({ id, action }); return { intent: { id: 9, action, state: 'pending' }, created: true }; };
|
|
180
|
+
provisioning.getOpenIntent = async () => null;
|
|
181
|
+
provisioning.recordEvent = async () => {};
|
|
182
|
+
|
|
183
|
+
const api = require('../src/module-api.js');
|
|
184
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
185
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
186
|
+
api.branding = () => ({
|
|
187
|
+
domains: { publicOrigin: 'https://cb.test', instanceBase: 'cb.test' },
|
|
188
|
+
identity: { productName: 'Cloud Bongos' },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Both routes are rate-limited per builder and every test here posts as builder
|
|
192
|
+
// 7, so the real cap would throttle the suite rather than the behaviour under
|
|
193
|
+
// test (the provisioning_domain_attach.mjs precedent).
|
|
194
|
+
const rateLimit = require('../modules/provisioning/rate-limit.js');
|
|
195
|
+
const realMakeAskRateLimiter = rateLimit.makeAskRateLimiter;
|
|
196
|
+
rateLimit.makeAskRateLimiter = (opts = {}) => realMakeAskRateLimiter({ ...opts, max: 10_000 });
|
|
197
|
+
|
|
198
|
+
const express = require('express');
|
|
199
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
200
|
+
const app = express();
|
|
201
|
+
app.use(express.json());
|
|
202
|
+
app.use((_req, res, next) => {
|
|
203
|
+
res.fail = (code, opts, details) => {
|
|
204
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
205
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
|
|
206
|
+
};
|
|
207
|
+
next();
|
|
208
|
+
});
|
|
209
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
210
|
+
const server = app.listen(0);
|
|
211
|
+
await new Promise((r) => server.once('listening', r));
|
|
212
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
213
|
+
|
|
214
|
+
function post(p, body = {}) {
|
|
215
|
+
return new Promise((resolve, reject) => {
|
|
216
|
+
const payload = JSON.stringify(body);
|
|
217
|
+
const req = http.request(`${base}${p}`, { method: 'POST',
|
|
218
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } }, (res) => {
|
|
219
|
+
let d = ''; res.on('data', (c) => { d += c; });
|
|
220
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
221
|
+
});
|
|
222
|
+
req.on('error', reject);
|
|
223
|
+
req.end(payload);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function reset() { instance = null; createThrows = null; updateThrows = null; updateCalls = []; enqueueCalls = []; }
|
|
228
|
+
|
|
229
|
+
console.log('\nPOST /provisioning/instances — the index refusing a raced creation:');
|
|
230
|
+
|
|
231
|
+
await ta('the index catching the race answers domain_taken 409, not a raw 500', async () => {
|
|
232
|
+
reset();
|
|
233
|
+
createThrows = DOMAIN_VIOLATION();
|
|
234
|
+
const res = await post('/provisioning/instances', { slug: 'venus', domain: 'mercury.example.com', no_address: false });
|
|
235
|
+
assert.equal(res.status, 409, res.raw);
|
|
236
|
+
assert.equal(res.body.error.code, 'domain_taken',
|
|
237
|
+
'the caller must not be able to tell which layer caught it — only the timing differs');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await ta('any OTHER failure is still the honest 500 — the mapping is narrow', async () => {
|
|
241
|
+
reset();
|
|
242
|
+
createThrows = Object.assign(new Error('connection terminated'), { code: '57P01' });
|
|
243
|
+
const res = await post('/provisioning/instances', { slug: 'venus', domain: 'mercury.example.com' });
|
|
244
|
+
assert.equal(res.status, 500, res.raw);
|
|
245
|
+
assert.equal(res.body.error.code, 'request_failed');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
console.log('\nPOST /provisioning/instances/:id/domain — the index refusing a raced attach:');
|
|
249
|
+
|
|
250
|
+
await ta('the index catching the race answers domain_taken 409, and nothing is queued', async () => {
|
|
251
|
+
reset();
|
|
252
|
+
instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: null };
|
|
253
|
+
updateThrows = DOMAIN_VIOLATION();
|
|
254
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'mercury.example.com' });
|
|
255
|
+
assert.equal(res.status, 409, res.raw);
|
|
256
|
+
assert.equal(res.body.error.code, 'domain_taken');
|
|
257
|
+
assert.equal(enqueueCalls.length, 0,
|
|
258
|
+
'the UPDATE was refused, so nothing was recorded — an attach intent would wire an address this instance does not hold');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await ta('any OTHER failure on attach is still the honest 500', async () => {
|
|
262
|
+
reset();
|
|
263
|
+
instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: null };
|
|
264
|
+
updateThrows = Object.assign(new Error('connection terminated'), { code: '57P01' });
|
|
265
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'mercury.example.com' });
|
|
266
|
+
assert.equal(res.status, 500, res.raw);
|
|
267
|
+
assert.equal(res.body.error.code, 'domain_attach_failed');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
server.close();
|
|
271
|
+
rateLimit.makeAskRateLimiter = realMakeAskRateLimiter;
|
|
272
|
+
|
|
273
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
274
|
+
process.exit(failed ? 1 : 0);
|