@bongos/core 1.19.537 → 1.19.538

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.537",
6
- "core_contract": "1.19.537",
7
- "source_commit": "171115727a6362835dd549dbed353a8bc817e4ee",
5
+ "core_version": "1.19.538",
6
+ "core_contract": "1.19.538",
7
+ "source_commit": "27d5678834bfd261666f02a704b7840851b767e0",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-04T15:20:04.870Z",
9
+ "built_at": "2026-09-04T15:34:15.511Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 439,
12
+ "docs_redacted": 440,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 1995,
14
+ "functional_verbatim": 1997,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2458,
20
- "tree_sha256": "2db763cfb805a63a9dd4a04cacedb717acd4aab6b9a8f3ce795a57f1bc49a0d4",
19
+ "file_count": 2461,
20
+ "tree_sha256": "4707649225bfa5206e00215584d7af14215d63884dea6d8e45213d98dda405af",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1749,20 +1749,25 @@
1749
1749
  "mode": "0000644",
1750
1750
  "sha256": "f46ee74d7b83ad4422e1832fdba68a8b9ea0deccb425ab3083d4c8fbfd06eb02"
1751
1751
  },
1752
+ {
1753
+ "path": "docs/adr/0248-a-hub-project-s-owner-is-the-provisioning-row-carried-on-a-port.md",
1754
+ "mode": "0000644",
1755
+ "sha256": "cbcac548fc3b683fd7219e312ab1542768c973aca5112d8cbc9ad0b2d09da650"
1756
+ },
1752
1757
  {
1753
1758
  "path": "docs/adr/README.md",
1754
1759
  "mode": "0000644",
1755
- "sha256": "d1bb4772b43b56cf1efa16fa99f255535bd736910c4c0ead94249f19862c7152"
1760
+ "sha256": "d5043a4e032d28341a3619a0cf6a6ce54b01ba83af24f2a7b28298da73ff769c"
1756
1761
  },
1757
1762
  {
1758
1763
  "path": "docs/api-reference.md",
1759
1764
  "mode": "0000644",
1760
- "sha256": "88fb38cb095713380c1c70187816c33c930cf962d9e4641ec4bb740da56ba90b"
1765
+ "sha256": "aef38c3ecd8942f0a38c56cbb390fd3590e785634d24e95660900a5140636b5f"
1761
1766
  },
1762
1767
  {
1763
1768
  "path": "docs/api/openapi.json",
1764
1769
  "mode": "0000644",
1765
- "sha256": "8ae264d8c9983ff2841a8309c141b6fb57b395bb4b52894a4b49f58280d8fb1b"
1770
+ "sha256": "923202b89890cc048769c8b05087181fa06ff126aa80aeaa4d9f669f4d96c27e"
1766
1771
  },
1767
1772
  {
1768
1773
  "path": "docs/architecture.md",
@@ -2637,7 +2642,7 @@
2637
2642
  {
2638
2643
  "path": "docs/module-api-changelog.md",
2639
2644
  "mode": "0000644",
2640
- "sha256": "1b719e47366fff0090cc4f3a18743953191d612e150e59a6c68e5d27c7b71624"
2645
+ "sha256": "a5362e7ae082e4377ed2bf228a99d7ad0df0e1d059488a819595ef646dd1f1ae"
2641
2646
  },
2642
2647
  {
2643
2648
  "path": "docs/modules-contract.md",
@@ -5824,6 +5829,11 @@
5824
5829
  "mode": "0000644",
5825
5830
  "sha256": "01d30570d828a1f06ccac693cc5c268d00fb091a13412a3f3890f10828a05203"
5826
5831
  },
5832
+ {
5833
+ "path": "modules/platform-identity/invite-authz.js",
5834
+ "mode": "0000644",
5835
+ "sha256": "2eb5577450d3bc1d67b7eab9a13256889aca945c6ac6a6a0ca919e195952b7a1"
5836
+ },
5827
5837
  {
5828
5838
  "path": "modules/platform-identity/migrations/platform_identity_001_tables.sql",
5829
5839
  "mode": "0000644",
@@ -5977,7 +5987,7 @@
5977
5987
  {
5978
5988
  "path": "modules/platform-identity/routes/projects.js",
5979
5989
  "mode": "0000644",
5980
- "sha256": "f90fbe23065310a09769bc46fdb0703e460f402d5a4e0cc490eba70cf67965ea"
5990
+ "sha256": "dac39b920025d696ba34e20aa4a9fbfb70d1cbc9ffe8f3b0da823f333d6a8d7a"
5981
5991
  },
5982
5992
  {
5983
5993
  "path": "modules/platform-identity/routes/public-profile.js",
@@ -6097,7 +6107,7 @@
6097
6107
  {
6098
6108
  "path": "modules/provisioning/module.json",
6099
6109
  "mode": "0000644",
6100
- "sha256": "26a6d752e610ac46f8306aa707f0229bf83ec9b0cb6c128ec723b7cc55b2b977"
6110
+ "sha256": "c451d94736219198d10d1bb8837ff9aecd6d4f90f8bf46790deba38f0d2168ec"
6101
6111
  },
6102
6112
  {
6103
6113
  "path": "modules/provisioning/onboard-plan.js",
@@ -6112,7 +6122,7 @@
6112
6122
  {
6113
6123
  "path": "modules/provisioning/provisioning.js",
6114
6124
  "mode": "0000644",
6115
- "sha256": "b073e5c362797e221f5e0f9ad322ee8e1c3206ea44ed98c527b3c1c7b696879c"
6125
+ "sha256": "39635718015b8fcb0cf5d21f3769e292e129bfe312b5802d073ef63b0d8b7b1e"
6116
6126
  },
6117
6127
  {
6118
6128
  "path": "modules/provisioning/rate-limit.js",
@@ -6132,7 +6142,7 @@
6132
6142
  {
6133
6143
  "path": "modules/provisioning/routes/provisioning.js",
6134
6144
  "mode": "0000644",
6135
- "sha256": "f9231eb18211a2586d2ee1919ff817ac5fa14f1ea5ad1906ad5c58437f6238f6"
6145
+ "sha256": "00585a2de10a05c814fb0c1ad3cd6b564096b70b7729695b54953ab98c75601b"
6136
6146
  },
6137
6147
  {
6138
6148
  "path": "modules/provisioning/starter-bundles.js",
@@ -7392,12 +7402,12 @@
7392
7402
  {
7393
7403
  "path": "package-lock.json",
7394
7404
  "mode": "0000644",
7395
- "sha256": "b52e9fc284a5e725c64604313074cc5b72653d3158a38ed20556a7a79d6649e8"
7405
+ "sha256": "8043c81f64ede9c38cdae0f527ba8fc6d3ce04f9c3ca99846dbb1b88e5b67aa6"
7396
7406
  },
7397
7407
  {
7398
7408
  "path": "package.json",
7399
7409
  "mode": "0000644",
7400
- "sha256": "a035e8e7a14e9ebce98dada6fd2bad96f3f9f0028707412394714c9edd538b77"
7410
+ "sha256": "9845a25af2217c1713641a4ae22c09c5686af460ee7e55ec269a733dbc573ca7"
7401
7411
  },
7402
7412
  {
7403
7413
  "path": "public-docs/index.html",
@@ -9082,7 +9092,7 @@
9082
9092
  {
9083
9093
  "path": "src/module-api.js",
9084
9094
  "mode": "0000644",
9085
- "sha256": "bbd37b696a4b36e1e5a572719a92e7a187b8653fcf360a658de83c43ef4e8331"
9095
+ "sha256": "2327888e4ad6a89296b1e8aab4237bd90d301debb313576c109850b450903eb0"
9086
9096
  },
9087
9097
  {
9088
9098
  "path": "src/module-loader/catalog.js",
@@ -11307,13 +11317,18 @@
11307
11317
  {
11308
11318
  "path": "tests/project_catalog_routes.mjs",
11309
11319
  "mode": "0000644",
11310
- "sha256": "5918d1ce7c106e5d7baa9cb989d52f3e82ac2aaf45a89f9a9bb154066106dccc"
11320
+ "sha256": "afef4620252bc212b4326863abb021e8cd6033e258345e44cf78e97fc3b28b63"
11311
11321
  },
11312
11322
  {
11313
11323
  "path": "tests/project_door_ui.mjs",
11314
11324
  "mode": "0000644",
11315
11325
  "sha256": "29f59ab64b231a7ec5d17a3e784d451f75d80c19efd4cb6a523d263e8f88b581"
11316
11326
  },
11327
+ {
11328
+ "path": "tests/project_invite_ownership.mjs",
11329
+ "mode": "0000644",
11330
+ "sha256": "743436b1191353bf687d8fb553d423a6003707a25a548367644928478569f171"
11331
+ },
11317
11332
  {
11318
11333
  "path": "tests/project_join_door.mjs",
11319
11334
  "mode": "0000644",
@@ -0,0 +1,144 @@
1
+ # ADR 0248 — A hub project's owner is the provisioning row, carried on a port and bound to both halves of the catalog projection
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-04
5
+ - **Task:** [task 1003580](https://cloudbongos.com/builders#/task/1003580) (BONGOS-V1, goal 1000046 — *Project creation*, criterion `proj-invite-builders` / C6), GAP 2
6
+ - **Deciders:** Claude, under the Archon's standing scope
7
+ - **Implements:** [ADR 0246](<redacted>.md)'s named remainder — *"what remains there is purely the ownership branch… the ownership proof it should carry is `provisioning_instances.owner_builder_id`"*
8
+ - **Related:** [ADR 0210](<redacted>.md) (owner-or-rank as one middleware; the atom stays spelled in the route's own list) · [ADR 0238](<redacted>.md) (the hub's `owner_github_id` columns are attribution, never authority) · [ADR 0083](<redacted>.md) (the one-way module rule — why this travels on a port) · [ADR 0182](<redacted>.md) D2a (a stealth project is anonymous, so a refusal may not identify it) · [ADR 0016](<redacted>.md) (fail-closed authority)
9
+
10
+ ---
11
+
12
+ ## Context
13
+
14
+ `POST /projects/invite` is gated `requirePermission('project.curate')`, floor `metic`
15
+ ([ADR 0157](<redacted>.md)). It is a **curation** gate:
16
+ `requirePermission` is purely grant/rank-based and has no own-scope branch. The creation
17
+ wizard's audience is any signed-in GitHub user — on the hub, typically a `xenos` holding
18
+ nothing at all. So the person who created a project got **403 on inviting anyone into their
19
+ own project**, and BV1.R27 ([task 1002346](https://cloudbongos.com/builders#/task/1002346)),
20
+ R28, R29 and the C6 proof all sat behind it.
21
+
22
+ ADR 0246 closed the other half (the project is now a resolvable catalog row) and named this
23
+ one as its own decision, with the constraint it has to answer to: **ADR 0238 says the hub's
24
+ ownership columns are attribution, not authority.** Lowering `project.curate`'s floor is not
25
+ available either — the same reason ADR 0210 gives: a floor is a rank, ownership is a
26
+ relationship, and no floor low enough to reach a project's registrant excludes anybody.
27
+
28
+ ## Decision
29
+
30
+ **Admit a `project.curate` holder OR the project's own owner, where "owner" means
31
+ `provisioning_instances.owner_builder_id` — asked of provisioning through a kernel port, and
32
+ bound to BOTH halves of the ADR 0246 projection.** Three parts:
33
+
34
+ 1. **The proof is the provisioning row.** That column is the authority `instanceAccessDenied`
35
+ already enforces on every owner-facing provisioning route (the manage page, both settings
36
+ routes, the detail answers). The hub's `sso_clients.owner_github_id` and
37
+ `platform_identity_projects.owner_github_id` stay what their migration comment and ADR 0238
38
+ say they are: attribution. Nothing here promotes them.
39
+
40
+ 2. **It travels on a NEW port, in the direction ADR 0246 did not open.** `project.catalog`
41
+ carries a write hub-ward (provisioning → platform-identity); this question runs the other
42
+ way, so provisioning provides **`provisioning.projectOwnedBy` ({ slug, host, builderId }) →
43
+ boolean**, registered beside `provisioning.instanceState`. platform-identity resolves it
44
+ with `resolveOptional` and does **not** declare it in `consumes` (the
45
+ `provisioning.projectPlanets` precedent): provisioning is `default: false`, and a hub
46
+ without it simply has no control-plane project to own. **A boolean, not the owner's id** —
47
+ the comparison is authority and belongs on the plane holding the row, so no consumer ever
48
+ handles an ownership fact it could mis-compare.
49
+
50
+ 3. **The question names both halves of the projection.** ADR 0246 puts a project on the map as
51
+ `client_id = slug` at `origin = https://<domain>`, and the port answers only when the
52
+ caller owns the instance carrying **both**, in one statement. Matching the slug alone would
53
+ let whoever registers an instance *named after* a client the control plane never created —
54
+ a hand-seeded row, whose origin the catalog's own claim rule (ADR 0246 rule 1) already
55
+ refuses to overwrite — speak for that project. The invite arm answers to the same binding
56
+ the catalog write does.
57
+
58
+ ### The OR is one middleware, and the atom stays spelled in the route
59
+
60
+ `modules/platform-identity/invite-authz.js` is `scouting-authz.js`'s shape, and it is that
61
+ shape for ADR 0210's reasons, restated because they are load-bearing rather than stylistic:
62
+
63
+ - **`requirePermission('project.curate')` is built in the route's own middleware list** and
64
+ handed to the helper. A gate hidden inside a helper is invisible to `route-rank-check.js`
65
+ and `gen-api-docs.js`, which classify a route from its middleware — this route would have
66
+ been recorded `any-builder`, wrong in the dangerous direction, and the published API
67
+ reference would have said so. It still classifies `metic+archon` with
68
+ `x-permissions: ["project.curate"]`; the "or owner" lives in the description, which is the
69
+ same over-strict-but-safe answer ADR 0210 accepted.
70
+ - **Ownership is asked FIRST, and a refusal falls through to the UNMODIFIED gate.** Asking the
71
+ permission gate first is not available: a middleware that has denied has already *sent* its
72
+ response, so there is nothing to fall through from. Paying one extra lookup on the curator
73
+ path is what makes the 403 byte-identical — same code, same `required`/`held`, same floors
74
+ map. **The widening can only ever ADMIT; it cannot change, soften, or swallow a refusal.**
75
+
76
+ ### The refusal is not an existence oracle — in body OR in latency
77
+
78
+ This is why the gate runs **before** the target is resolved rather than after. Resolving first
79
+ and then authorizing would split the answer into 404-for-absent and 403-for-present, handing
80
+ any signed-in stranger a membership oracle over every private project on the platform —
81
+ exactly what ADR 0182 D2a's server-side anonymisation exists to prevent. Because the gate
82
+ delegates to the untouched permission middleware, a stranger's refusal is identical for a real
83
+ project, a stealth project, a project that does not exist, and a malformed body; the gate runs
84
+ before the body validator, exactly as `requirePermission` always did.
85
+
86
+ **A body-identical refusal that is measurably faster is still an oracle**, so every path does
87
+ the same two reads. An unknown `client_id` is asked about anyway, with the empty host, and
88
+ `projectOwnedBy` **queries** it rather than short-circuiting: an empty host names no address
89
+ and matches nothing (`domain` is a hostname or NULL, and `lower(NULL) = ''` is NULL), so it is
90
+ a real round-trip with a guaranteed-empty result rather than a dummy delay. That is the one
91
+ place in this design where the cheap-looking early return is the wrong call, and it is written
92
+ into the function's own doc-comment so a later "optimisation" cannot quietly reopen the
93
+ channel. Equalising the lookup must not DUPLICATE it, so the resolved row rides forward on
94
+ `req.inviteTargetClient` (the `req.builder` / `req.rawPermissionKeys` precedent) and the
95
+ handler reads it instead of fetching the same row again.
96
+
97
+ ## Consequences
98
+
99
+ - **[task 1002346](https://cloudbongos.com/builders#/task/1002346) (BV1.R27) is unblocked**, and
100
+ is the pure wizard-step task it was scoped as. The route path, body and responses are
101
+ unchanged: `POST /api/bongos/projects/invite` with `{ client_id, github_login }` → `201
102
+ { invited: true, row }` on a fresh invite, `200 { invited: false, alreadyMember: true }` when
103
+ the membership already exists.
104
+ - **Owning a project buys exactly one act on the hub** — pre-seeding a `pending` membership on
105
+ your own project. No rank, no curation, no read of anyone else's project, and nothing else
106
+ consults it. The reverse also holds: this reads ownership and grants none.
107
+ - **Fail-closed in three directions.** No provisioning module → the old door, unchanged. A
108
+ throwing ownership check → the old door, unchanged (never a 500, never an admit). A
109
+ non-string `client_id` → not a claim, and neither the port nor the client registry is asked.
110
+ - **Every caller now costs two indexed reads before the permission gate runs**, including one
111
+ who will be refused. That is the deliberate price of equal latency on a cold, authenticated
112
+ route; it is bounded (two primary-key/`LIMIT 1` lookups) and it is why the equalisation is
113
+ affordable here and would not be on a hot public path.
114
+ - **A re-pointed co-tenant project can lose the own arm.** The catalog's claim rule 1 refuses
115
+ to rewrite a *credentialed* client's origin, so a federated project that moves address keeps
116
+ a stale `origin` while its provisioning row carries the new domain, and the binding stops
117
+ matching. That degrades to curator-only — the safe direction — and is named here so the next
118
+ reader recognises it rather than rediscovering it as a bug.
119
+ - **The port is a general capability with one consumer.** Any future hub surface that wants to
120
+ own-scope a control-plane project asks the same question rather than inventing a second
121
+ answer — which is what keeps a hub ownership column from being promoted to authority the
122
+ next time this comes up.
123
+
124
+ ## Alternatives rejected
125
+
126
+ - **Read `sso_clients.owner_github_id` (candidate B as originally scouted).** Rejected: it is
127
+ the exact thing its own migration comment and ADR 0238 forbid — a hub catalog column with no
128
+ instance behind it, writable by hub curation, and NULL on the hand-seeded rows.
129
+ - **Lower `project.curate`'s floor to `xenos`.** Rejected on ADR 0210's grounds: it would admit
130
+ every stranger who ever signed in, to every project on the platform.
131
+ - **Mint a `project.invite.own` ownership-scoped atom** and use `authorizeOwnedOrAny`.
132
+ Rejected: the ownership-scoped axis resolves the *rank* fact from a catalog floor and the
133
+ *owner* fact from a row the handler loaded, and here the owner fact lives on another module's
134
+ plane entirely — so the atom would still need this port, plus a catalog-floor change this
135
+ task is not authorised to make. The recruiting wave's `project.recruit`
136
+ ([spike 1002786](https://cloudbongos.com/builders#/task/1002786) R3) is where a new atom is
137
+ defined; when it lands it replaces the `project.curate` half of this OR and the ownership
138
+ half is unaffected — the same seam ADR 0210 left.
139
+ - **Key the ownership question on the slug alone.** Rejected: see decision 3 — it makes a
140
+ hand-seeded catalog row's name a squattable claim on that project's membership directory.
141
+ - **Reuse the `membership_kind = 'owner'` proof `scouting-authz.js` uses.** Rejected: that row
142
+ is written by a federated instance's `POST /sso/membership/check-in`, which a standalone
143
+ wizard-made project never calls — it would be empty for exactly the audience this task
144
+ exists to admit.
@@ -334,6 +334,7 @@ This keeps the decision history honest and traceable.
334
334
  | 0245 | [**The way back into an optional step is that step’s own answer route** ([task 1002342](https://cloudbongos.com/builders#/task/1002342) · goal 1000046, criterion `proj-module-select`). R20 ([ADR 0240](<redacted>.md)) made the create wizard’s module picker skippable and left a project’s module set write-once — the route file said so out loud (“there is no mutation route yet”). R23 closes it WITHOUT a new endpoint: `modules` joins `description` and `detail` on `PATCH /provisioning/instances/:id/detail`, because the wizard’s two optional steps are the same kind of thing — asked optionally at creation, stored on the platform row, reaching no running instance — and that route already carried the ownership gate, the `api:self`/`api:archon:<id>` actor rule, the 404-no-existence-leak shape, and (since [ADR 0243](<redacted>.md)) an `effectiveModules` answer it could report but not set. Deliberately NOT the settings channel: a setting is a policy the running instance obeys and would queue a restart to deliver a value the instance does not read. Only an ARRAY is a change — an omitted or null key leaves the column alone — so the three-valued contract’s `NULL` (“never asked”) stays unreachable by a later edit, and `modules` REPLACES where `detail` merges, because a merging write would make removal impossible. The surface is the manage view’s extras band (`/projects?view=manage&id=N`), where a toggle IS the save and the band repaints from the platform’s answer rather than from what was sent.](<redacted>.md) | provisioning / project creation |
335
335
  | 0244 | [**A catalog search is a view over the rows, never an edit to the selection** ([task 1002340](https://cloudbongos.com/builders#/task/1002340) · goal 1000046, criterion `proj-module-select`). The creation picker ([ADR 0240](<redacted>.md)) paints one toggle per optional module, and the catalog is meant to grow — so R21 adds a keyword search over it. A search over a list of links is trivial; a search over a list of TOGGLES is not, because the list is also an answer. Two failure modes follow from letting a row be hidden: the filter **edits** the answer (narrow the rendered array and the read-back array together, and typing `discord` then pressing Next files one module instead of three — silently, with every screen looking right), or the filter **lies** about it (a switched-on module off screen reads as switched off, which is [ADR 0237](<redacted>.md)’s "a preselect they have to audit" in its harsher form — one they cannot see at all). **Decision: the search is a VIEW and `state.modules` is the answer; the view may not write to it.** `moduleQuery` is a plain variable, never part of the draft, so `saveDraft` is not called from the input handler and a search can never turn an untouched picker into an answered one (ADR 0240’s `null` survives); `visibleModules()` derives the shown rows per render while `pickedModules()`, `modulesForCreate()` and `extrasRow()` keep reading the whole catalog; and the panel STATES what the filter is hiding — one line under the field counting what is **on**, not what is visible. Matching is `moduleMatches(mod, terms)`, a pure function: lower-cased **label + summary + key** as one haystack, every whitespace-separated term an AND’d substring. AND because adding a word means the list is still too long; substring because a filter that shows nothing until you finish typing reads as broken; the key because that is what a returning owner names a module by. No ranking (reordering toggles under a typing hand is worse than not), and deliberately **not semantic** — by-meaning search is deferred with the rest of the creation AI layer (idea 1000552). Rejected: filtering the read-back array (failure mode 1), CSS-hiding non-matches (leaves invisible `aria-pressed` toggles in the a11y tree), pinning switched-on rows above the query (a filter that lies the other way), persisting the query in the draft, and debouncing a pass over already-loaded rows.](<redacted>.md) |
336
336
  | 0247 | [**The join door is ONE composed answer, and dark matter has no live verdict to compose with yet** ([task 1002331](https://cloudbongos.com/builders#/task/1002331) · goal 1000046, criterion `proj-join-policy`). [ADR 0182](<redacted>.md) D6 wrote the rule — visibility is the ceiling, join policy may only narrow it — and nothing implemented it: the public `POST /access-requests` read `joinability` alone, so an owner could set their project private or stealth on the map and the application endpoint behaved exactly as before. R12 adds the third knob (`join_grant` = view | apply | full, one line of settings vocabulary, default `full` so nothing existing changes) and composes all three in one pure kernel file, `src/bongos/project-door.js`, published through the doorway so no module can re-open a door the others closed. Three things worth carrying: the door vocabulary is a list ordered widest→strictest, so “the stricter wins” is the data structure rather than a branch; the refusal never names WHICH knob closed the door, because a stealth project that could be identified by its 403 gives back exactly what R06’s server-side redaction protects; and `locked` (dark matter) is specified, tested and deliberately UNWIRED — the instance cannot derive publishability (no description in its pack, and a pushed verdict goes stale the moment the owner writes one), while the hub’s cached copy says every live project is dark matter today, so enforcing off it would refuse every join on the platform.](<redacted>.md) | onboarding / provisioning / project creation |
337
+ | 0248 | [**A hub project’s owner is the provisioning row, carried on a port and bound to both halves of the catalog projection** ([task 1003580](https://cloudbongos.com/builders#/task/1003580) · goal 1000046, criterion `proj-invite-builders`). [ADR 0246](<redacted>.md) made a wizard-made project resolvable by `POST /projects/invite` and named the remainder: the route is gated `project.curate` (floor metic) with no own-scope arm, so the project’s own owner — a `xenos` on the hub — got 403 on their own project, blocking BV1.R27–R29 and the C6 proof. The arm admits a `project.curate` holder **OR** the owner, where owner means `provisioning_instances.owner_builder_id` — never the hub’s `owner_github_id` columns, which [ADR 0238](<redacted>.md) fixes as attribution. Three things worth carrying: the fact travels on a NEW port `provisioning.projectOwnedBy` answering a **boolean**, so the comparison stays on the plane that holds the row and no consumer handles an ownership id it could mis-compare; the port is asked about **both halves of the ADR 0246 projection** (slug AND origin), because a hand-seeded catalog row’s name is otherwise a squattable claim on that project’s membership directory; and ownership is asked BEFORE the target is resolved, so the refusal stays the untouched `requirePermission` 403 and cannot become an existence oracle over stealth projects ([ADR 0182](<redacted>.md) D2a). The atom stays spelled in the route’s own middleware list ([ADR 0210](<redacted>.md)’s rule), so the route still classifies `metic+archon`.](<redacted>.md) | onboarding / provisioning / project creation |
337
338
 
338
339
  | 0246 | [**Catalog membership is a projection of the provisioning row, not a side effect of federation** ([task 1003576](https://cloudbongos.com/builders#/task/1003576) · goal 1000046, criterion `proj-detail-interview` / C3). The BV1.R18 proof ([task 1002337](https://cloudbongos.com/builders#/task/1002337)) demonstrated C3 against live and found its last clause — “then it publishes” — structurally false: the owner’s verdict reads `provisioning_instances` while the public map reads `platform_identity_projects`, and the ONLY bridge was `federateInstance → registerClient → upsertProject`, which `shouldFederate` refuses for anything but `co-tenant` (the wizard hard-codes `standalone`) and which runs ONCE, at provision time, through a COALESCE. So a wizard-made project was never catalogued at all and a co-tenant one that answered later never updated — `publishable: true`, `{"projects": []}`, forever. The same missing row is [task 1003580](https://cloudbongos.com/builders#/task/1003580)’s GAP 1: `POST /projects/invite` resolves against `platform_identity_sso_clients`, whose only writer is that same `registerClient`. Underneath is one conflation — federation mints CREDENTIALS and was incidentally the only thing that minted MEMBERSHIP, while [ADR 0182](<redacted>.md) D4 already fixed the map’s gate at IDENTITY, which has nothing to do with hosting topology. **Decision: the catalog row is a PROJECTION of the provisioning row, re-derived at every write that can change its inputs** — two halves, neither sufficient alone. (1) THE ROW SHAPE: every control-plane project gets both rows whatever its hosting shape, and the client row carries NO credential (`secret_hash = ''`, `redirect_uris = '{}'`), so **the shape is the enforcement** rather than a flag a caller could forget — `redirectUriAllowed` refuses `/sso/authorize`, `clientSecretValid` refuses `/sso/token`, `/sso/membership/check-in` and `/sso/activity/rollup`, and the one leg that checked NEITHER (`/sso/device/start`, which resolves a client from a user-typed origin) grew an explicit `clientIsCredentialed` guard. (2) THE TIMING: re-taken on create, on `PATCH …/detail`, on `PATCH …/settings` when the door changes, and on `POST …/domain` (which re-keys the row, retiring the abandoned origin — [ADR 0181](0181-teardown-state-gate-and-address-retention.md) §2’s leak, in the catalog instead of DNS). **NO MIGRATION** — `secret_hash` is `NOT NULL` and `''` satisfies it while hashing to nothing. `description` and `visibility` are ASSIGNED, not COALESCEd (clearing a description must UN-publish); `featured`/`tagline`/`art_url`/`template` are curation and stay untouched, so [ADR 0182](<redacted>.md) D3 holds. It grants nothing — the row is attribution per [ADR 0238](<redacted>.md), ownership stays `provisioning_instances.owner_builder_id` — and because it writes rows keyed on owner-supplied values (a slug, a BYO domain) each write carries its **claim rule in the ON CONFLICT action’s own WHERE**: the client is refreshed only `WHERE secret_hash = ''` (a real client’s origin, which binds the CLI mint audience, can never be rewritten) and the project only `WHERE owner_github_id = EXCLUDED.owner_github_id` (`NULL = anything` is not true, so the hand-seeded rows cannot be claimed). Writing `visibility` does not contradict [ADR 0182](<redacted>.md) D5: the hub authorizes nothing — the authority already ran on the provisioning plane ([ADR 0238](<redacted>.md)’s own-scoped, audited PATCH), and a project the control plane does NOT own has no provisioning row, so D5’s instance report (R08) stays the only channel for those. `tearing_down`/`torn_down` project `hidden`; a `no_address` instance is not catalogued at all. Travels on the kernel port `project.catalog`, the `project.publishGate` seam in the other direction ([ADR 0083](<redacted>.md) forbids the import). Rejected: extending `shouldFederate` to every shape (hands a real secret to instances whose web.env the platform does not write, and keeps the once-at-standup bug); bridging only at the publish moment (leaves the invite with nothing to resolve); registering only at standup (the bug itself); a `catalog_only` boolean column (needs a migration and is weaker than the shape); letting the bridge write `featured`; and backfilling the three existing catalog rows from code.](<redacted>.md) | provisioning / project creation |
339
340
 
@@ -11162,7 +11162,7 @@
11162
11162
  "projects"
11163
11163
  ],
11164
11164
  "summary": "POST /projects/invite",
11165
- "description": "invite a builder into a project (ADR 0141 §5, task #1002115). Pre-seeds a 'pending' membership the invitee accepts from My Projects. V1 invites by github_login and requires the invitee to already have a platform account (they build on some project); inviting someone entirely new to the platform (GitHub-id resolution) is a follow-up.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `project.curate` (all required).",
11165
+ "description": "own-or-metic in effect: invite a builder into a project (ADR 0141 §5, task #1002115), called by a `project.curate` holder OR the project's OWN OWNER (task 1003580; the ownership proof is `provisioning_instances.owner_builder_id`, reached through a port — see invite-authz.js). The wizard's audience is any signed-in GitHub user, typically a xenos who used to get 403 on their own project. A caller who is neither gets the unchanged permission refusal, identical whether or not the project exists. Pre-seeds a 'pending' membership the invitee accepts from My Projects. V1 invites by github_login and requires the invitee to already have a platform account (they build on some project); inviting someone entirely new to the platform (GitHub-id resolution) is a follow-up.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `project.curate` (all required).",
11166
11166
  "x-rank": "metic+archon",
11167
11167
  "x-source": "modules/platform-identity/routes/projects.js",
11168
11168
  "x-permissions": [
@@ -457,7 +457,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
457
457
  | POST | `/api/bongos/projects` | `metic+archon` | `origin`, `name`, `tagline`, `description`, `art_url`, `type`, `template` | create / update a catalog entry (manual seed for hand-wired instances; federated projects auto-catalogue at registration). |
458
458
  | PATCH | `/api/bongos/projects/:id/featured` | `metic+archon` | `featured` | the feature / unfeature control (the ADR 0141 §5 curation gate), BOUNDED BY THE PUBLISH GATE (ADR 0182 D3, task 1002335). |
459
459
  | GET | `/api/bongos/projects/featured` | `public` | — | the public map of projects; no auth by design. |
460
- | POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | invite a builder into a project (ADR 0141 §5, task #1002115). |
460
+ | POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | own-or-metic in effect: invite a builder into a project (ADR 0141 §5, task #1002115), called by a `project.curate` holder OR the project'… |
461
461
 
462
462
  ## `provisioning` (21)
463
463
 
@@ -1523,5 +1523,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1523
1523
  landed since 1.19.535 with no explicit bump. run 33888048777. (task 1002620)
1524
1524
  1.19.537 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1525
1525
  landed since 1.19.536 with no explicit bump. run 33888812605. (task 1002620)
1526
+ 1.19.538 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1527
+ landed since 1.19.537 with no explicit bump. run 33890191596. (task 1002620)
1526
1528
  ---------------------------------------------------------------------------
1527
1529
  ```
@@ -0,0 +1,117 @@
1
+ // modules/platform-identity/invite-authz.js — WHO may invite a builder into a project
2
+ // (POST /projects/invite). Task 1003580 GAP 2, goal 1000046 criterion C6, unblocking the
3
+ // wizard's invite step (BV1.R27, task 1002346).
4
+ //
5
+ // TWO DOORS, DELIBERATELY DIFFERENT KINDS — the scouting-authz.js shape (ADR 0210), which is
6
+ // this codebase's design of record for owner-or-rank:
7
+ //
8
+ // RANK — `project.curate`, floor metic (ADR 0157). The hub CURATOR's door, unchanged:
9
+ // the atom, the 403 body, the floors map. Nothing about it moves here.
10
+ // OWNERSHIP — the project's own owner, for their own project. The whole of what this adds.
11
+ //
12
+ // WHY THE RANK DOOR ALONE WAS A DEAD END. The creation wizard's audience is any signed-in
13
+ // GitHub user — typically a `xenos` on the hub — and `requirePermission` is purely
14
+ // grant/rank-based with no own-scope branch. So the person who created a project got 403 on
15
+ // inviting anyone to it, and no floor low enough to admit them excludes anybody at all.
16
+ //
17
+ // WHERE THE OWNERSHIP PROOF COMES FROM, AND WHERE IT MUST NOT. `platform_identity_sso_clients
18
+ // .owner_github_id` and `platform_identity_projects.owner_github_id` are ATTRIBUTION, not
19
+ // authority — their own migration says so and ADR 0238 restates it. The authority is
20
+ // `provisioning_instances.owner_builder_id`, the fact the manage view already trusts and
21
+ // `instanceAccessDenied` already enforces. This module may not import provisioning (ADR 0083's
22
+ // one-way rule), so the fact travels on the kernel port `provisioning.projectOwnedBy` —
23
+ // resolveOptional and NOT declared in `consumes` (the `provisioning.projectPlanets`
24
+ // precedent): provisioning is default-off, and a hub running without it simply has no
25
+ // control-plane project to own.
26
+ //
27
+ // THE REFUSAL IS NOT AN ORACLE, IN BODY OR IN LATENCY. Ownership is asked before the target is
28
+ // resolved and before the body validator, and every path does the same two reads, so a
29
+ // stranger's 403 is identical — and identically slow — for a real project, a stealth one
30
+ // (ADR 0182 D2a), one that does not exist, and a malformed body.
31
+ //
32
+ // THE PORT IS ASKED ABOUT BOTH HALVES OF THE PROJECTION. ADR 0246 puts a control-plane project
33
+ // on the map as `client_id = slug` at `origin = https://<domain>`, so the question is "do you
34
+ // own the instance carrying BOTH", never "do you own the slug". A hand-seeded catalog row's
35
+ // name is squattable in `provisioning_instances` — the catalog's own claim rule already
36
+ // refuses to overwrite such a row, and this arm answers to the same binding rather than
37
+ // handing its owner's name to whoever registers an instance called that.
38
+ //
39
+ // IT GRANTS NOTHING ELSE (ADR 0016). Owning a project buys exactly one act on the hub:
40
+ // pre-seeding a `pending` membership on your own project. It confers no rank, no curation, no
41
+ // read of anyone else's project, and nothing else consults it.
42
+ // Proof: ../../tests/project_invite_ownership.mjs
43
+ 'use strict';
44
+
45
+ const api = require('../../src/module-api');
46
+ const pi = require('./platform-identity');
47
+
48
+ // The kernel port the ownership fact travels on. Resolved BY NAME at call time through the
49
+ // doorway, never imported — provisioning is a sibling module (ADR 0083).
50
+ const OWNERSHIP_PORT = 'provisioning.projectOwnedBy';
51
+
52
+ // The host a catalog origin names, which is the `provisioning_instances.domain` the projection
53
+ // was keyed on. An unparseable origin has no host and therefore no owner. PURE.
54
+ function hostOfOrigin(origin) {
55
+ try { return new URL(String(origin)).hostname.toLowerCase(); } catch { return ''; }
56
+ }
57
+
58
+ // callerOwnsProject(req, { pool }) — is this caller the owner of the control-plane project the
59
+ // requested catalog row was projected from? Fail-closed on every unhappy path: a non-string
60
+ // key (only a string names a project — `String(['x'])` must not become a claim), an
61
+ // unauthenticated caller, no provisioning module.
62
+ //
63
+ // SAME WORK ON EVERY PATH, which is the timing half of "the refusal is not an oracle". An
64
+ // unknown client is asked about anyway — `hostOfOrigin(null)` is the empty host, which
65
+ // `projectOwnedBy` still queries and which matches nothing — so a stranger cannot separate
66
+ // "this project exists" from "it does not" by measuring how long the identical 403 took to
67
+ // arrive. A body-identical refusal that is measurably faster is still an oracle.
68
+ //
69
+ // The resolved row rides forward on `req.inviteTargetClient` (the `req.builder` /
70
+ // `req.rawPermissionKeys` precedent) so equalising the lookup does not duplicate it: the
71
+ // handler needs this same row, and re-reading it would be pure waste on every admitted call.
72
+ async function callerOwnsProject(req, { pool }) {
73
+ const clientId = req.body && req.body.client_id;
74
+ if (typeof clientId !== 'string' || clientId === '') return false;
75
+ const builderId = req.builder && req.builder.id;
76
+ if (!builderId) return false;
77
+ const ownedBy = api.resolveOptional(OWNERSHIP_PORT);
78
+ if (typeof ownedBy !== 'function') return false;
79
+ const client = await pi.getClient(clientId, { pool });
80
+ if (client) req.inviteTargetClient = client;
81
+ return (await ownedBy({ slug: clientId, host: hostOfOrigin(client && client.origin), builderId })) === true;
82
+ }
83
+
84
+ // requireProjectInviter({ curateGate, pool, log }) — the OR gate as one middleware, mounted
85
+ // after requireBuilder. `curateGate` is the ALREADY-BUILT `requirePermission('project.curate')`
86
+ // middleware: the atom stays spelled in the route's own middleware list, where
87
+ // route-rank-check.js and gen-api-docs.js can see it, so widening the door cannot make the
88
+ // route's recorded rank read looser than it is.
89
+ //
90
+ // ORDER IS THE DESIGN, and it is what keeps the refusal from becoming an existence oracle.
91
+ // Ownership is asked FIRST; when it answers yes the request proceeds and the permission gate
92
+ // never runs. When it answers no — for ANY reason, including "no such project" — the request
93
+ // falls through to the UNMODIFIED permission gate, so a caller who is neither owner nor
94
+ // curator gets exactly the 403 this route has always produced: same code, same required/held,
95
+ // same floors map. A stranger therefore cannot tell a stealth project (ADR 0182 D2a) from one
96
+ // that does not exist, and cannot tell either from a malformed body — the gate still runs
97
+ // before the body validator, exactly as `requirePermission` always did. The widening can only
98
+ // ever ADMIT; it cannot change, soften, or swallow a refusal.
99
+ //
100
+ // FAIL-CLOSED ON THE OWNERSHIP QUESTION ITSELF. A database blip while asking "is this your
101
+ // project" must not admit, and must not 500 either: it falls to the permission gate, which
102
+ // denies unless the caller holds the atom. The failure mode of the new door is the old door.
103
+ function requireProjectInviter({ curateGate, pool, log }) {
104
+ return async function projectInviterGate(req, res, next) {
105
+ try {
106
+ if (await callerOwnsProject(req, { pool })) return next();
107
+ } catch (err) {
108
+ // Message only — a query error can carry row data (the requirePermission precedent).
109
+ log.error({ err: err && err.message }, 'project invite owner check failed — falling back to the permission gate');
110
+ }
111
+ return curateGate(req, res, next);
112
+ };
113
+ }
114
+
115
+ module.exports = {
116
+ requireProjectInviter,
117
+ };
@@ -12,6 +12,8 @@
12
12
  // GET /projects — Archon: full curation list (+ featured flags, the stored
13
13
  // join door, and the DERIVED publish verdict — ADR 0182)
14
14
  // POST /projects — Archon: create / update a catalog entry
15
+ // POST /projects/invite — a `project.curate` holder OR the project's OWN owner:
16
+ // pre-seed a 'pending' membership (invite-authz.js)
15
17
  // PATCH /projects/:id/featured — Archon: the feature / unfeature control
16
18
  //
17
19
  // Reaches core only through the doorway (ADR 0083). Archon routes declared first so
@@ -23,6 +25,7 @@ const api = require('../../../src/module-api');
23
25
  const pi = require('../platform-identity');
24
26
  const projectCatalog = require('../project-catalog');
25
27
  const projectStats = require('../project-stats');
28
+ const inviteAuthz = require('../invite-authz');
26
29
 
27
30
  const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet } = api;
28
31
  const log = api.logger('platform-identity');
@@ -105,28 +108,52 @@ module.exports = function projectsRoutes() {
105
108
  }
106
109
  });
107
110
 
108
- // rank: archon — invite a builder into a project (ADR 0141 §5, task #1002115).
109
- // Pre-seeds a 'pending' membership the invitee accepts from My Projects. V1 invites
110
- // by github_login and requires the invitee to already have a platform account (they
111
- // build on some project); inviting someone entirely new to the platform (GitHub-id
112
- // resolution) is a follow-up.
113
- router.post('/projects/invite', requireBuilder, requirePermission('project.curate'), async (req, res) => {
114
- if (validateOrRespond(req, res, {
115
- client_id: { required: true, type: 'string', maxLength: 200 },
116
- github_login: { required: true, type: 'string', maxLength: 200 },
117
- })) return;
118
- try {
119
- const client = await pi.getClient(req.body.client_id, { pool });
120
- if (!client || client.status !== 'active') return res.fail('unknown_project', 404);
121
- const account = await pi.getAccountByLogin(req.body.github_login, { pool });
122
- if (!account) return res.fail('account_not_found', { status: 404, message: 'That builder has no platform account yet — they must sign in somewhere once before they can be invited.' });
123
- const out = await pi.inviteToProject({ clientId: req.body.client_id, githubId: account.github_id }, { pool });
124
- res.status(out.invited ? 201 : 200).json(out);
125
- } catch (err) {
126
- console.error('[platform-identity] POST /projects/invite', err);
127
- res.fail('invite_failed', { status: 500, message: 'internal error' });
128
- }
129
- });
111
+ // The atom is spelled in the middleware list below, not hidden inside
112
+ // requireProjectInviter, so route-rank-check.js and gen-api-docs.js classify this
113
+ // route from its real gate. A helper-hidden gate would record a privileged route
114
+ // as any-builder — wrong in the dangerous direction (the ADR 0210 rule).
115
+
116
+ // rank: metic+archon — own-or-metic in effect: invite a builder into a project
117
+ // (ADR 0141 §5, task #1002115), called by a `project.curate` holder OR the project's OWN
118
+ // OWNER (task 1003580; the ownership proof is `provisioning_instances.owner_builder_id`,
119
+ // reached through a port — see invite-authz.js). The wizard's audience is any signed-in
120
+ // GitHub user, typically a xenos who used to get 403 on their own project. A caller who is
121
+ // neither gets the unchanged permission refusal, identical whether or not the project
122
+ // exists. Pre-seeds a 'pending' membership the invitee accepts from My Projects. V1 invites
123
+ // by github_login and requires the invitee to already have a platform account (they build
124
+ // on some project); inviting someone entirely new to the platform (GitHub-id resolution) is
125
+ // a follow-up.
126
+ router.post(
127
+ '/projects/invite',
128
+ requireBuilder,
129
+ // Ownership is asked FIRST — one client read plus one indexed LIMIT-1 lookup, the SAME two
130
+ // on every path so the refusal is identical in latency as well as in body. Asking the
131
+ // permission gate first is not available: a middleware that has denied has already SENT
132
+ // its response, so there is nothing to fall through from. Paying the round-trip is what
133
+ // keeps the 403 byte-identical to the one this route has always produced (ADR 0210 D2).
134
+ // The gate leaves the resolved client on `req.inviteTargetClient` for the handler below.
135
+ inviteAuthz.requireProjectInviter({ curateGate: api.requirePermission('project.curate'), pool, log }),
136
+ async (req, res) => {
137
+ if (validateOrRespond(req, res, {
138
+ client_id: { required: true, type: 'string', maxLength: 200 },
139
+ github_login: { required: true, type: 'string', maxLength: 200 },
140
+ })) return;
141
+ try {
142
+ // The gate already resolved this exact row (it must, to answer the ownership
143
+ // question); reusing it keeps the equalised lookup from becoming a duplicated one.
144
+ // It is only ever absent when the gate found nothing, so the fallback still reads.
145
+ const client = req.inviteTargetClient || await pi.getClient(req.body.client_id, { pool });
146
+ if (!client || client.status !== 'active') return res.fail('unknown_project', 404);
147
+ const account = await pi.getAccountByLogin(req.body.github_login, { pool });
148
+ if (!account) return res.fail('account_not_found', { status: 404, message: 'That builder has no platform account yet — they must sign in somewhere once before they can be invited.' });
149
+ const out = await pi.inviteToProject({ clientId: req.body.client_id, githubId: account.github_id }, { pool });
150
+ res.status(out.invited ? 201 : 200).json(out);
151
+ } catch (err) {
152
+ console.error('[platform-identity] POST /projects/invite', err);
153
+ res.fail('invite_failed', { status: 500, message: 'internal error' });
154
+ }
155
+ },
156
+ );
130
157
 
131
158
  // rank: archon — the feature / unfeature control (the ADR 0141 §5 curation gate),
132
159
  // BOUNDED BY THE PUBLISH GATE (ADR 0182 D3, task 1002335). Featuring ranks among
@@ -14,6 +14,6 @@
14
14
  "migrations": true,
15
15
  "pollers": ["liveness-sweep"]
16
16
  },
17
- "provides": ["provisioning.instanceState", "provisioning.projectPlanets"],
17
+ "provides": ["provisioning.instanceState", "provisioning.projectPlanets", "provisioning.projectOwnedBy"],
18
18
  "consumes": []
19
19
  }
@@ -1180,6 +1180,43 @@ async function instanceStateForSlug(db, slug) {
1180
1180
  };
1181
1181
  }
1182
1182
 
1183
+ // The `provisioning.projectOwnedBy` port (task 1003580): is THIS builder the owner of the
1184
+ // control-plane project a hub catalog row names? Lets a hub surface own-scope a project it
1185
+ // did not create — without importing this module (ADR 0083) and without promoting the hub's
1186
+ // own `owner_github_id` columns, which are attribution and never authority (ADR 0238). The
1187
+ // fact returned is the one `instanceAccessDenied` already enforces on every owner-facing
1188
+ // route here: `provisioning_instances.owner_builder_id`.
1189
+ //
1190
+ // BOTH HALVES OF THE PROJECTION MUST AGREE, which is why this takes a slug AND a host rather
1191
+ // than a slug alone. ADR 0246 projects an instance onto the map as `client_id = slug` at
1192
+ // `origin = https://<domain>`, so a caller speaks for a catalog row only when they own the
1193
+ // instance carrying BOTH. Matching the slug by itself would let someone who registers an
1194
+ // instance NAMED after a client the control plane never created — a hand-seeded row, whose
1195
+ // origin the catalog's own claim rule already refuses to overwrite — speak for that project.
1196
+ //
1197
+ // A BOOLEAN, not the owner's id: the comparison is authority and belongs on the plane that
1198
+ // holds the row, so no consumer ever handles an ownership fact it could mis-compare. Read-only
1199
+ // and fail-closed on an unidentifiable key or builder id.
1200
+ //
1201
+ // AN ABSENT HOST IS STILL ASKED, and matches nothing — `domain` is never the empty string (it
1202
+ // is a hostname or NULL, and `lower(NULL) = ''` is NULL, i.e. not true). Short-circuiting it
1203
+ // would make the caller's work depend on whether the catalog row exists, and a refusal that is
1204
+ // byte-identical but measurably FASTER is still an existence oracle. The query is the cheap
1205
+ // half of keeping every caller's path the same shape.
1206
+ async function projectOwnedBy(db, { slug, host, builderId } = {}) {
1207
+ const wantSlug = typeof slug === 'string' ? slug : '';
1208
+ const wantHost = typeof host === 'string' ? host.toLowerCase() : '';
1209
+ const owner = Number(builderId);
1210
+ if (!wantSlug || !Number.isInteger(owner) || owner <= 0) return false;
1211
+ const { rows } = await db.query(
1212
+ `SELECT 1 FROM provisioning_instances
1213
+ WHERE slug = $1 AND lower(domain) = $2 AND owner_builder_id = $3
1214
+ LIMIT 1`,
1215
+ [wantSlug, wantHost, owner],
1216
+ );
1217
+ return rows.length > 0;
1218
+ }
1219
+
1183
1220
  // The `provisioning.projectPlanets` port (task 1003347): the owners' planet
1184
1221
  // choices for a set of HOSTS (the catalog's origins, host-matched — the join the
1185
1222
  // hub's sky makes), so platform-identity's public featured feed can carry them
@@ -1252,7 +1289,7 @@ module.exports = {
1252
1289
  createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
1253
1290
  createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
1254
1291
  enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
1255
- recordEvent, instanceStateForSlug, planetChoicesForHosts, costLedger,
1292
+ recordEvent, instanceStateForSlug, planetChoicesForHosts, projectOwnedBy, costLedger,
1256
1293
  // data access — GitHub App Manifest attempts (task 2080)
1257
1294
  createOAuthManifest, getOAuthManifestByState, attachManifestCode,
1258
1295
  getLatestOAuthManifestForInstance, claimNextOAuthManifest, resolveOAuthManifest,
@@ -159,10 +159,16 @@ function badModuleSelection(modules) {
159
159
  // • PROVIDES port `provisioning.projectPlanets` (hosts[]) → { host: { planet_template,
160
160
  // planet_accent } } — the owners' planet choices (task 1003347), so the hub's
161
161
  // public featured feed can carry them without importing this module.
162
+ // • PROVIDES port `provisioning.projectOwnedBy` ({ slug, host, builderId }) → boolean —
163
+ // the OWNERSHIP fact behind a hub catalog row (task 1003580), so a hub surface can
164
+ // own-scope a project without importing this module and without treating the hub's
165
+ // `owner_github_id` columns as authority (ADR 0238). See projectOwnedBy for why the
166
+ // question names both halves of the ADR 0246 projection.
162
167
  function registerProvisioningSeams() {
163
168
  if (api.hasProvider('provisioning.instanceState')) return;
164
169
  api.registerProvider('provisioning.instanceState', (slug) => provisioning.instanceStateForSlug(pool, slug));
165
170
  api.registerProvider('provisioning.projectPlanets', (hosts) => provisioning.planetChoicesForHosts(pool, hosts));
171
+ api.registerProvider('provisioning.projectOwnedBy', (target) => provisioning.projectOwnedBy(pool, target));
166
172
  }
167
173
 
168
174
  // Is this project on the public map, and if not, what is it still missing?
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.537",
3
+ "version": "1.19.538",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.537",
9
+ "version": "1.19.538",
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.537",
3
+ "version": "1.19.538",
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
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.537'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.538'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -211,3 +211,25 @@ test('a catalog hiccup can never turn a saved change into a failure', async () =
211
211
  catalogBridge.publishInstanceToCatalog = saved;
212
212
  }
213
213
  });
214
+
215
+ test('mounting the routes registers the `provisioning.projectOwnedBy` port (task 1003580)', async () => {
216
+ // The OTHER direction of the same seam: the catalog write travels provisioning → hub, and
217
+ // this ownership read travels hub → provisioning, so the hub's invite door can own-scope a
218
+ // project without importing this module (ADR 0083) and without treating the hub's own
219
+ // `owner_github_id` columns as authority (ADR 0238). Registered here at mount, so a hub
220
+ // running with provisioning enabled always has it.
221
+ assert.equal(api.hasProvider('provisioning.projectOwnedBy'), true);
222
+ const ownedBy = api.resolve('provisioning.projectOwnedBy');
223
+ assert.equal(typeof ownedBy, 'function');
224
+ // It reaches the module's own read, bound to the module's pool — the caller never supplies
225
+ // a database handle, so no consumer can point this question at another connection.
226
+ const seen = [];
227
+ const saved = provisioning.projectOwnedBy;
228
+ provisioning.projectOwnedBy = async (_db, target) => { seen.push(target); return true; };
229
+ try {
230
+ assert.equal(await ownedBy({ slug: 'spike-harbor', host: 'spike-harbor.cloudbongos.test', builderId: 7 }), true);
231
+ assert.deepEqual(seen, [{ slug: 'spike-harbor', host: 'spike-harbor.cloudbongos.test', builderId: 7 }]);
232
+ } finally {
233
+ provisioning.projectOwnedBy = saved;
234
+ }
235
+ });
@@ -0,0 +1,366 @@
1
+ // tests/project_invite_ownership.mjs — A PROJECT'S OWNER MAY INVITE INTO THEIR OWN PROJECT
2
+ // (task 1003580, GAP 2; the ADR 0210 owner-or-rank shape applied to POST /projects/invite).
3
+ //
4
+ // Task 1003576 / ADR 0246 gave every control-plane project a catalog row, so the invite route
5
+ // stopped 404ing for a wizard-made project. What was left is WHO may call it: the route is
6
+ // gated `project.curate`, floor metic, and the wizard's audience is any signed-in GitHub user
7
+ // — typically a `xenos` who gets 403 on their OWN project. This file is the proof of the arm
8
+ // that fixes it, and of the four things the arm must NOT do.
9
+ //
10
+ // THE OWNERSHIP PROOF IS THE PROVISIONING ROW. `provisioning_instances.owner_builder_id` is
11
+ // the authority the manage view already trusts; the hub's `owner_github_id` columns are
12
+ // attribution, never authority (ADR 0238). platform-identity may not import provisioning
13
+ // (ADR 0083), so the fact travels on the `provisioning.projectOwnedBy` port — resolved
14
+ // OPTIONALLY, because the hub must boot with no provisioning module at all.
15
+ //
16
+ // DB-free: the real router driven over loopback, data access stubbed on the cached module
17
+ // objects and the port stubbed on the doorway (the catalog_only_client.mjs pattern).
18
+ //
19
+ // Run: node --test --test-reporter=tap tests/project_invite_ownership.mjs
20
+
21
+ import assert from 'node:assert/strict';
22
+ import { test, after, beforeEach } from 'node:test';
23
+ import { createRequire } from 'node:module';
24
+ import http from 'node:http';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const pi = require('../modules/platform-identity/platform-identity.js');
28
+ const api = require('../src/module-api.js');
29
+
30
+ // ── the world ────────────────────────────────────────────────────────────────
31
+
32
+ // A wizard-made standalone project: catalog-only client (no secret), owned by builder 7 whose
33
+ // provisioning row is slug `spike-harbor` at spike-harbor.cloudbongos.test.
34
+ const OWNED = Object.freeze({
35
+ client_id: 'spike-harbor', secret_hash: '', name: 'spike-harbor',
36
+ redirect_uris: [], origin: 'https://spike-harbor.cloudbongos.test',
37
+ owner_github_id: 9001, status: 'active',
38
+ });
39
+ // Someone ELSE's project, and a STEALTH one: the refusal a stranger gets must not tell them
40
+ // this row exists (ADR 0182 D2a — a stealth project is on the map anonymously or not at all).
41
+ const STEALTH = Object.freeze({
42
+ ...OWNED, client_id: 'quiet-cove', name: 'quiet-cove', origin: 'https://quiet-cove.cloudbongos.test',
43
+ owner_github_id: 9002,
44
+ });
45
+ // A client the control plane never created — the hand-seeded catalog row shape (ADR 0246's
46
+ // "unclaimable" case). Its slug is squattable in provisioning, which is why the port is asked
47
+ // about the ORIGIN as well as the slug.
48
+ const HAND_SEEDED = Object.freeze({
49
+ ...OWNED, client_id: 'legacy-port', name: 'legacy-port', origin: 'https://legacy-port.example',
50
+ secret_hash: 'a'.repeat(64), owner_github_id: 9003,
51
+ });
52
+ const CLIENTS = new Map([OWNED, STEALTH, HAND_SEEDED].map((c) => [c.client_id, c]));
53
+
54
+ // The provisioning rows the port answers from: (slug, host) → owner builder id.
55
+ const INSTANCES = [
56
+ { slug: 'spike-harbor', host: 'spike-harbor.cloudbongos.test', owner: 7 },
57
+ { slug: 'quiet-cove', host: 'quiet-cove.cloudbongos.test', owner: 9 },
58
+ // The SQUAT: builder 42 registered an instance named after the hand-seeded client, at their
59
+ // own address. Same slug, different origin — so it is not that project.
60
+ { slug: 'legacy-port', host: 'legacy-port.squatter.test', owner: 42 },
61
+ ];
62
+
63
+ const OWNER = Object.freeze({ id: 7, github_id: 9001, github_login: 'owner', rank: 'xenos', curate: false });
64
+ const STRANGER = Object.freeze({ id: 42, github_id: 4242, github_login: 'stranger', rank: 'xenos', curate: false });
65
+ const CURATOR = Object.freeze({ id: 3, github_id: 3003, github_login: 'curator', rank: 'metic', curate: true });
66
+
67
+ let caller = OWNER;
68
+ // 'ok' | 'throws' | 'absent' — the port's health, so the fail-closed paths are executed.
69
+ let portMode = 'ok';
70
+ let invites = [];
71
+ let portCalls = [];
72
+ let clientReads = [];
73
+
74
+ // ── the doorway, stubbed before the router is required ───────────────────────
75
+
76
+ // Destructured at route-module load, so these must be in place first.
77
+ api.requireBuilder = (req, _res, next) => { req.builder = caller; next(); };
78
+ // A faithful stand-in for auth.requirePermission: it admits a holder and otherwise sends the
79
+ // SAME 403 body every caller of this route has always received. The arm under test delegates
80
+ // to this untouched middleware, so "the refusal is unchanged" is true by construction — what
81
+ // these tests pin is that the delegation happens, and that it happens identically whether or
82
+ // not the project exists.
83
+ api.requirePermission = (...required) => (req, res, next) => {
84
+ if (req.builder && req.builder.curate) return next();
85
+ return res.fail('permission_forbidden', {
86
+ status: 403,
87
+ message: `missing permission${required.length === 1 ? '' : 's'}: ${required.join(', ')} (floor: metic)`,
88
+ details: { required, held: [], floors: { [required[0]]: 'metic' } },
89
+ });
90
+ };
91
+ // Silenced: the fallback path logs, and a pino line on stdout would land inside the TAP stream.
92
+ api.logger = () => ({ error: () => {}, warn: () => {}, info: () => {} });
93
+
94
+ const realResolveOptional = api.resolveOptional;
95
+ api.resolveOptional = (name) => {
96
+ if (name !== 'provisioning.projectOwnedBy') return realResolveOptional(name);
97
+ if (portMode === 'absent') return null;
98
+ return async ({ slug, host, builderId }) => {
99
+ portCalls.push({ slug, host, builderId });
100
+ if (portMode === 'throws') throw new Error('provisioning is down');
101
+ return INSTANCES.some((i) => i.slug === slug && i.host === host && String(i.owner) === String(builderId));
102
+ };
103
+ };
104
+
105
+ pi.getClient = async (id) => { clientReads.push(id); return CLIENTS.get(id) || null; };
106
+ pi.getAccountByLogin = async (login) => (login === 'ghost' ? null : { github_id: 5150, github_login: login });
107
+ pi.inviteToProject = async ({ clientId, githubId }) => {
108
+ invites.push({ clientId, githubId });
109
+ return { invited: true, clientId, githubId };
110
+ };
111
+
112
+ const express = require('express');
113
+ const projectsRoutes = require('../modules/platform-identity/routes/projects.js');
114
+
115
+ const app = express();
116
+ app.use(express.json());
117
+ app.use((_req, res, next) => {
118
+ res.fail = (code, opts, details) => {
119
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
120
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
121
+ };
122
+ next();
123
+ });
124
+ app.use('/api/bongos', projectsRoutes());
125
+
126
+ const server = app.listen(0);
127
+ await new Promise((r) => server.once('listening', r));
128
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
129
+ after(() => server.close());
130
+
131
+ function invite(body) {
132
+ return new Promise((resolve, reject) => {
133
+ const payload = JSON.stringify(body === undefined ? {} : body);
134
+ const req = http.request(`${base}/projects/invite`, {
135
+ method: 'POST',
136
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
137
+ }, (res) => {
138
+ let d = '';
139
+ res.on('data', (c) => { d += c; });
140
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j }); });
141
+ });
142
+ req.on('error', reject);
143
+ req.write(payload);
144
+ req.end();
145
+ });
146
+ }
147
+
148
+ beforeEach(() => { caller = OWNER; portMode = 'ok'; invites = []; portCalls = []; clientReads = []; });
149
+
150
+ // ── the arm ──────────────────────────────────────────────────────────────────
151
+
152
+ test('the OWNER of a wizard-made project invites into it, holding no permission at all', async () => {
153
+ // The whole point of the task: the wizard's audience is any signed-in GitHub user, and a
154
+ // `xenos` owner used to get 403 on their own project before the target was even resolved.
155
+ caller = OWNER;
156
+ const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
157
+ assert.equal(res.status, 201);
158
+ assert.equal(res.body.invited, true);
159
+ assert.deepEqual(invites, [{ clientId: 'spike-harbor', githubId: 5150 }]);
160
+ // The ownership question was asked of the provisioning row, keyed on BOTH halves of the
161
+ // catalog projection (ADR 0246: client_id = slug, origin = https://domain).
162
+ assert.deepEqual(portCalls, [{ slug: 'spike-harbor', host: 'spike-harbor.cloudbongos.test', builderId: 7 }]);
163
+ // …and the row the gate resolved to ask it rides forward to the handler: equalising the
164
+ // lookup across callers must not turn into reading the same row twice.
165
+ assert.deepEqual(clientReads, ['spike-harbor'], 'the client row is read exactly once per request');
166
+ });
167
+
168
+ test('a METIC curator still invites into a project they do not own — the old door is untouched', async () => {
169
+ caller = CURATOR;
170
+ const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
171
+ assert.equal(res.status, 201);
172
+ assert.equal(res.body.invited, true);
173
+ assert.equal(invites.length, 1);
174
+ });
175
+
176
+ test('a DIFFERENT builder is refused with the unchanged shape, and writes nothing', async () => {
177
+ caller = STRANGER;
178
+ const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
179
+ assert.equal(res.status, 403);
180
+ assert.equal(res.body.error.code, 'permission_forbidden');
181
+ assert.deepEqual(res.body.error.details.required, ['project.curate']);
182
+ assert.deepEqual(invites, [], 'a refused invite writes no membership row');
183
+ });
184
+
185
+ // ── the refusal is not an existence oracle ───────────────────────────────────
186
+
187
+ test('a stranger cannot tell a STEALTH project from one that does not exist', async () => {
188
+ // ADR 0182 D2a: a stealth project is anonymous on the map. Turning this route's 403 into a
189
+ // 404-or-403 split would hand any signed-in stranger a membership oracle over every private
190
+ // project on the platform — the refusal must be byte-identical.
191
+ caller = STRANGER;
192
+ const stealth = await invite({ client_id: 'quiet-cove', github_login: 'someone' });
193
+ const nothing = await invite({ client_id: 'never-provisioned', github_login: 'someone' });
194
+ assert.equal(stealth.status, 403);
195
+ assert.deepEqual(stealth, nothing);
196
+ assert.deepEqual(invites, []);
197
+ // The LATENCY half of the same rule: both requests do the same work. A body-identical
198
+ // refusal that is measurably faster for a client_id nobody has taken is still an oracle, so
199
+ // the unknown client is asked about anyway — with the empty host, which matches nothing.
200
+ assert.deepEqual(clientReads, ['quiet-cove', 'never-provisioned']);
201
+ assert.deepEqual(portCalls, [
202
+ { slug: 'quiet-cove', host: 'quiet-cove.cloudbongos.test', builderId: 42 },
203
+ { slug: 'never-provisioned', host: '', builderId: 42 },
204
+ ], 'the same two lookups on both paths — the refusal is identical in latency, not only in body');
205
+ });
206
+
207
+ test('a stranger cannot tell a real project from a malformed request either', async () => {
208
+ // The gate runs BEFORE the body validator, exactly as requirePermission always did, so a
209
+ // caller who may not invite never learns whether their body was well-formed.
210
+ caller = STRANGER;
211
+ const good = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
212
+ const empty = await invite({});
213
+ assert.equal(good.status, 403);
214
+ assert.deepEqual(good, empty);
215
+ });
216
+
217
+ // ── the arm cannot be widened by squatting a name ────────────────────────────
218
+
219
+ test('owning an instance that merely SHARES a client_id grants nothing', async () => {
220
+ // Builder 42 registered `legacy-port` at their own address; the catalog row of that name is
221
+ // a hand-seeded client at a different origin, which ADR 0246's claim rule already refuses to
222
+ // overwrite. The ownership arm answers to the same binding: both halves of the projection
223
+ // (slug AND origin) must be the caller's, or this is simply not their project.
224
+ caller = STRANGER;
225
+ const res = await invite({ client_id: 'legacy-port', github_login: 'someone' });
226
+ assert.equal(res.status, 403);
227
+ assert.equal(res.body.error.code, 'permission_forbidden');
228
+ assert.deepEqual(invites, []);
229
+ assert.deepEqual(portCalls, [{ slug: 'legacy-port', host: 'legacy-port.example', builderId: 42 }],
230
+ 'the port is asked about the CATALOG row\'s origin, never the caller\'s own instance');
231
+ });
232
+
233
+ // ── fail-closed on the ownership question itself ─────────────────────────────
234
+
235
+ test('a missing provisioning module leaves the old door, unchanged', async () => {
236
+ // platform-identity is the hub; provisioning is a separate default-off module. The hub must
237
+ // boot without it, and an unresolvable port must deny rather than admit (ADR 0016).
238
+ portMode = 'absent';
239
+ caller = OWNER;
240
+ const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
241
+ assert.equal(res.status, 403);
242
+ assert.deepEqual(invites, []);
243
+ });
244
+
245
+ test('a THROWING ownership check denies — it never 500s and never admits', async () => {
246
+ portMode = 'throws';
247
+ caller = OWNER;
248
+ const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
249
+ assert.equal(res.status, 403);
250
+ assert.equal(res.body.error.code, 'permission_forbidden');
251
+ assert.deepEqual(invites, []);
252
+ // …while the CURATOR still gets through the same broken path: the failure mode of the new
253
+ // door is the old door, not an outage.
254
+ caller = CURATOR;
255
+ const asCurator = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
256
+ assert.equal(asCurator.status, 201);
257
+ });
258
+
259
+ test('a non-string client_id is not an ownership claim', async () => {
260
+ // The gate runs before the body validator, so it validates its own input rather than
261
+ // trusting it: only a string names a project, and String(['spike-harbor']) must not.
262
+ caller = OWNER;
263
+ for (const client_id of [['spike-harbor'], { toString: () => 'spike-harbor' }, 7, null]) {
264
+ const res = await invite({ client_id, github_login: 'someone' });
265
+ assert.equal(res.status, 403, `client_id ${JSON.stringify(client_id)} must not pass the ownership arm`);
266
+ }
267
+ assert.deepEqual(portCalls, [], 'a non-string never even reaches the port');
268
+ assert.deepEqual(clientReads, [], 'nor the client registry — there is no project named here');
269
+ });
270
+
271
+ // ── the resolved target is still the catalog row's job ───────────────────────
272
+
273
+ test('an owner asking for a project that has no catalog row still gets the plain 404', async () => {
274
+ // Ownership admits; it does not conjure a target. `never-provisioned` has no client row, so
275
+ // the handler's own `unknown_project` is what answers — unchanged from today.
276
+ caller = CURATOR;
277
+ const res = await invite({ client_id: 'never-provisioned', github_login: 'someone' });
278
+ assert.equal(res.status, 404);
279
+ assert.equal(res.body.error.code, 'unknown_project');
280
+ });
281
+
282
+ test('SUSPENDING the client still shuts the door on its own owner', async () => {
283
+ // The operator's revocation lever (`setClientStatus`) is what stops a rogue or compromised
284
+ // project signing anyone in; a widened door that outlived it would be a widening with no way
285
+ // back. Ownership admits at the gate, and the handler's own status check answers — the same
286
+ // `unknown_project` a stranger would get, so suspension is not disclosed either.
287
+ const suspended = { ...OWNED, status: 'suspended' };
288
+ CLIENTS.set(suspended.client_id, suspended);
289
+ try {
290
+ caller = OWNER;
291
+ const res = await invite({ client_id: 'spike-harbor', github_login: 'someone' });
292
+ assert.equal(res.status, 404);
293
+ assert.equal(res.body.error.code, 'unknown_project');
294
+ assert.deepEqual(invites, []);
295
+ } finally {
296
+ CLIENTS.set(OWNED.client_id, OWNED);
297
+ }
298
+ });
299
+
300
+ // ── the authority query itself ───────────────────────────────────────────────
301
+
302
+ test('projectOwnedBy asks the provisioning row for all three facts at once', async () => {
303
+ // The authority half of the seam, on the plane that holds the row: slug AND host AND owner
304
+ // in ONE statement, so there is no read-then-compare window and no consumer ever handles an
305
+ // ownership id it could mis-compare (ADR 0238 — the hub's own columns are attribution).
306
+ const provisioning = require('../modules/provisioning/provisioning.js');
307
+ const seen = [];
308
+ const db = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [{ '?column?': 1 }] }; } };
309
+ assert.equal(await provisioning.projectOwnedBy(db, {
310
+ slug: 'spike-harbor', host: 'Spike-Harbor.CloudBongos.test', builderId: '7',
311
+ }), true, 'a bigint id arriving as a string still matches, and the host is compared case-folded');
312
+ assert.equal(seen.length, 1);
313
+ assert.match(seen[0].sql, /FROM provisioning_instances/);
314
+ assert.match(seen[0].sql, /slug = \$1/);
315
+ assert.match(seen[0].sql, /lower\(domain\) = \$2/);
316
+ assert.match(seen[0].sql, /owner_builder_id = \$3/);
317
+ assert.deepEqual(seen[0].params, ['spike-harbor', 'spike-harbor.cloudbongos.test', 7]);
318
+ });
319
+
320
+ test('projectOwnedBy fails closed before touching the database on anything unidentifiable', async () => {
321
+ const provisioning = require('../modules/provisioning/provisioning.js');
322
+ const db = { query: async () => { throw new Error('the query must not run'); } };
323
+ const bad = [
324
+ undefined,
325
+ { slug: '', host: 'h.test', builderId: 7 },
326
+ { slug: ['s'], host: 'h.test', builderId: 7 },
327
+ { slug: 's', host: 'h.test', builderId: 'not-a-number' },
328
+ { slug: 's', host: 'h.test', builderId: 0 },
329
+ { slug: 's', host: 'h.test', builderId: -1 },
330
+ { slug: 's', host: 'h.test', builderId: null },
331
+ ];
332
+ for (const target of bad) {
333
+ assert.equal(await provisioning.projectOwnedBy(db, target), false, JSON.stringify(target));
334
+ }
335
+ });
336
+
337
+ test('projectOwnedBy QUERIES an empty host rather than short-circuiting it', async () => {
338
+ // The timing-equalising half, on the plane that runs the statement. An empty host names no
339
+ // address and matches nothing — `domain` is a hostname or NULL, and `lower(NULL) = ''` is
340
+ // NULL — so this is a real round-trip with a guaranteed-empty result, not a dummy delay.
341
+ const provisioning = require('../modules/provisioning/provisioning.js');
342
+ const seen = [];
343
+ const db = { query: async (sql, params) => { seen.push(params); return { rows: [] }; } };
344
+ assert.equal(await provisioning.projectOwnedBy(db, { slug: 's', host: '', builderId: 7 }), false);
345
+ assert.deepEqual(seen, [['s', '', 7]], 'the query still ran — the caller pays the same cost either way');
346
+ });
347
+
348
+ test('projectOwnedBy answers false, not a row, when nothing matches', async () => {
349
+ const provisioning = require('../modules/provisioning/provisioning.js');
350
+ const db = { query: async () => ({ rows: [] }) };
351
+ assert.equal(await provisioning.projectOwnedBy(db, { slug: 's', host: 'h.test', builderId: 7 }), false);
352
+ });
353
+
354
+ // ── the seam ─────────────────────────────────────────────────────────────────
355
+
356
+ test('provisioning DECLARES the ownership port, and platform-identity does not require it', async () => {
357
+ // verifyPortsSatisfied reads the manifests at boot, not the registry: an undeclared provider
358
+ // is invisible to it, and a port named in `consumes` the hub would refuse to boot without.
359
+ const read = async (rel) => JSON.parse(await (await import('node:fs/promises'))
360
+ .readFile(new URL(rel, import.meta.url), 'utf8'));
361
+ const provisioning = await read('../modules/provisioning/module.json');
362
+ const identity = await read('../modules/platform-identity/module.json');
363
+ assert.ok(provisioning.provides.includes('provisioning.projectOwnedBy'), 'the port is declared');
364
+ assert.ok(!(identity.consumes || []).includes('provisioning.projectOwnedBy'),
365
+ 'optional, not required — the hub must boot with no provisioning module at all');
366
+ });