@bongos/core 1.19.533 → 1.19.535

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.
Files changed (62) hide show
  1. package/.bongos-core.json +121 -56
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/config/branding.neutral.json +3 -2
  4. package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
  5. package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
  6. package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
  7. package/docs/adr/README.md +4 -0
  8. package/docs/api/openapi.json +8 -1
  9. package/docs/api-reference.md +1 -1
  10. package/docs/copy-inventory.md +162 -157
  11. package/docs/copy-registry.json +252 -196
  12. package/docs/file-map.md +1 -0
  13. package/docs/module-api-changelog.md +16 -0
  14. package/modules/hall-ui/public/hall-kit.js +93 -7
  15. package/modules/hall-ui/public/roadmap-timeline.js +105 -0
  16. package/modules/hall-ui/public/roadmap.html +7 -1
  17. package/modules/hall-ui/public/roadmap.js +16 -0
  18. package/modules/hall-ui/public/style.css +4 -2
  19. package/modules/hall-ui/public/watch.html +4 -0
  20. package/modules/hall-ui/public/watch.js +116 -0
  21. package/modules/onboarding/module.json +1 -1
  22. package/modules/onboarding/routes/access-requests.js +21 -6
  23. package/modules/platform-identity/module.json +1 -1
  24. package/modules/platform-identity/project-catalog.js +202 -0
  25. package/modules/platform-identity/routes/my-projects.js +7 -2
  26. package/modules/platform-identity/routes/projects.js +6 -0
  27. package/modules/platform-identity/routes/sso.js +10 -0
  28. package/modules/provisioning/catalog-bridge.js +105 -0
  29. package/modules/provisioning/provisioning.js +28 -2
  30. package/modules/provisioning/routes/provisioning.js +45 -11
  31. package/modules/provisioning/tests/provisioning.mjs +11 -8
  32. package/modules/public-landing/public/assets/cosmos.css +15 -0
  33. package/modules/public-landing/public/projects.html +162 -3
  34. package/modules/ui-design/kit/serve.js +1 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/fitness.js +9 -0
  38. package/scripts/gds/provision-net.js +7 -0
  39. package/scripts/hall-preview/refresh-fixtures.js +6 -0
  40. package/src/bongos/project-door.js +138 -0
  41. package/src/branding.js +8 -0
  42. package/src/module-api.js +12 -1
  43. package/tests/access_requests_invite.mjs +2 -2
  44. package/tests/application_lifecycle.mjs +1 -0
  45. package/tests/catalog_only_client.mjs +242 -0
  46. package/tests/currency_label.mjs +8 -5
  47. package/tests/hall_kit_charts.mjs +173 -0
  48. package/tests/hall_oversight_world.mjs +159 -0
  49. package/tests/hall_reading_world.mjs +153 -0
  50. package/tests/module_api.mjs +1 -0
  51. package/tests/project_catalog_bridge.mjs +355 -0
  52. package/tests/project_catalog_routes.mjs +213 -0
  53. package/tests/project_join_door.mjs +166 -0
  54. package/tests/project_join_door_route.mjs +206 -0
  55. package/tests/project_modules_ui.mjs +318 -0
  56. package/tests/projects_hub.mjs +6 -1
  57. package/tests/projects_hub_pre_uat.mjs +2 -2
  58. package/tests/provision_settings_apply.mjs +6 -5
  59. package/tests/provisioning_module_selection.mjs +164 -3
  60. package/tests/provisioning_settings.mjs +10 -7
  61. package/tests/provisioning_settings_apply.mjs +3 -3
  62. package/tests/provisioning_settings_env.mjs +7 -4
@@ -0,0 +1,202 @@
1
+ // modules/platform-identity/project-catalog.js — CATALOG MEMBERSHIP, split from SSO
2
+ // CREDENTIALS (task 1003576, ADR 0246). How a project the control plane owns gets onto
3
+ // the hub's map at all, and keeps its row current when its owner edits it.
4
+ //
5
+ // THE BUG THIS FILE IS THE ANSWER TO. The owner-facing publish verdict is computed over
6
+ // `provisioning_instances` (the control plane's own row); the public map reads
7
+ // `platform_identity_projects`. The ONLY thing that ever wrote the second from the first
8
+ // was federation — registerClient → upsertProject — which `shouldFederate` refuses for any
9
+ // instance that is not `co-tenant`, and which runs ONCE, at provision time. So a wizard-made
10
+ // (standalone) project was never catalogued at all, and a co-tenant one that answered
11
+ // "tell us about your project" LATER never re-federated. `publishable: true` and an empty
12
+ // map, forever (the BV1.R18 proof, task 1002337 §3).
13
+ //
14
+ // THE SHAPE. The catalog row is a PROJECTION of the provisioning row, re-derived at every
15
+ // write that can change its inputs — not a side effect of standing up a particular hosting
16
+ // shape. Federation mints CREDENTIALS (a secret + a redirect-URI allow-list, so an instance
17
+ // can run the SSO handoff); this mints MEMBERSHIP (a name the map, the invite and the
18
+ // membership directory can resolve). Conflating the two is what made hosting topology decide
19
+ // who is on the map — ADR 0182 D4 says the gate is IDENTITY (a name and a description), and
20
+ // identity has nothing to do with where a project is hosted.
21
+ //
22
+ // A CATALOG-ONLY CLIENT CARRIES NO CREDENTIAL, and that is enforced by the row's own shape
23
+ // rather than by a flag some future caller could forget: `secret_hash = ''` and
24
+ // `redirect_uris = '{}'`. Every SSO leg then refuses it with machinery that already existed —
25
+ // clientSecretValid() fails closed on a secretless client (/sso/token, /sso/membership/check-in,
26
+ // /sso/activity/rollup) and redirectUriAllowed() fails closed on an empty allow-list
27
+ // (/sso/authorize). The ONE leg that checked neither was /sso/device/start, which resolves a
28
+ // client from a typed origin; clientIsCredentialed() below is the guard it now applies.
29
+ // No migration: `secret_hash` is NOT NULL, and '' satisfies NOT NULL while hashing to nothing.
30
+ //
31
+ // TWO CLAIM RULES, because this writes rows keyed on values an owner supplies (a slug, a
32
+ // bring-your-own domain) and neither table proves who owns an origin:
33
+ // 1. THE CLIENT — an existing row is refreshed ONLY when it is uncredentialed. The bridge
34
+ // can therefore never rewrite a real client's `origin` (which is what binds the CLI
35
+ // device-mint audience) nor blank its secret. A slug that collides with a credentialed
36
+ // client is simply not claimed.
37
+ // 2. THE PROJECT — an existing row is refreshed ONLY when its `owner_github_id` already
38
+ // equals this instance owner's. A NULL owner on either side compares NULL, i.e. not
39
+ // true, so the hand-seeded catalog rows (owner null) can never be claimed by someone
40
+ // creating an instance at their address. First INSERT wins and stamps the owner; every
41
+ // later write must match it.
42
+ // Both are expressed as the ON CONFLICT action's own WHERE, so the check and the write are
43
+ // one statement and there is no read-then-write window to lose.
44
+ //
45
+ // WHAT IS AUTHORITATIVE AND WHAT IS NOT. `description` and `visibility` are written
46
+ // AUTHORITATIVELY (not COALESCEd): clearing a description must take the project OFF the map,
47
+ // and the owner's door must be able to change — a COALESCE is precisely why the catalog kept
48
+ // a NULL description while the owner stared at `publishable: true`. `tagline`, `art_url`,
49
+ // `featured` and `template` are CURATION and are never touched here. `owner_github_id` is
50
+ // ATTRIBUTION, never authority (ADR 0238): ownership stays `provisioning_instances.owner_builder_id`.
51
+ // Proof: ../../tests/project_catalog_bridge.mjs, ../../tests/catalog_only_client.mjs
52
+ 'use strict';
53
+
54
+ const { pool: defaultPool } = require('../../src/module-api');
55
+ const { ORB_BY_STATE, FALLBACK_PROJECT_STATE } = require('./projects-feed');
56
+
57
+ // The catalog row's operational lifecycle, mirrored from migration 004's CHECK. 'hidden'
58
+ // keeps a reclaimed project out of every listing without deleting its history.
59
+ const CATALOG_STATUSES = Object.freeze(['active', 'hidden']);
60
+
61
+ // Does this client hold a usable SSO credential? A catalog-only row is stored with an EMPTY
62
+ // secret_hash, so this is the one question that separates "the hub can name this project"
63
+ // from "this project may run the sign-in handoff". Exported because /sso/device/start
64
+ // resolves a client from a typed origin and checks neither the secret nor the redirect
65
+ // allow-list — the other legs fail closed on their own. PURE.
66
+ function clientIsCredentialed(client) {
67
+ return !!(client && typeof client.secret_hash === 'string' && client.secret_hash.length > 0);
68
+ }
69
+
70
+ // The entry, normalised to values the two CHECK constraints accept. Both fall back to the
71
+ // CLOSED direction: an unreadable door is anonymous (the feed's own FALLBACK_PROJECT_STATE),
72
+ // and an unreadable lifecycle is hidden. A caller that sends garbage loses visibility, never
73
+ // gains it. PURE.
74
+ function normalizeEntry(entry) {
75
+ const e = entry || {};
76
+ const origin = String(e.origin || '').replace(/\/+$/, '');
77
+ const clientId = String(e.clientId || '');
78
+ const ownerGithubId = Number(e.ownerGithubId);
79
+ if (!origin) throw new Error('project-catalog: origin required');
80
+ if (!clientId) throw new Error('project-catalog: clientId required');
81
+ // No owner → no claim rule can hold, so the row would be un-refreshable forever. Refuse
82
+ // to write one rather than strand it (the bridge resolves the owner before calling).
83
+ if (!Number.isFinite(ownerGithubId) || ownerGithubId <= 0) {
84
+ throw new Error('project-catalog: ownerGithubId required — the catalog row is un-refreshable without it');
85
+ }
86
+ const visibility = Object.prototype.hasOwnProperty.call(ORB_BY_STATE, e.visibility)
87
+ ? e.visibility : FALLBACK_PROJECT_STATE;
88
+ const status = CATALOG_STATUSES.includes(e.status) ? e.status : 'hidden';
89
+ return {
90
+ origin,
91
+ clientId,
92
+ ownerGithubId,
93
+ name: String(e.name || clientId),
94
+ description: e.description ? String(e.description) : null,
95
+ type: e.type ? String(e.type) : null,
96
+ visibility,
97
+ status,
98
+ };
99
+ }
100
+
101
+ // The catalog-only SSO client — an IDENTITY the invite route and the membership directory can
102
+ // resolve, with no credential attached. The literal '' / '{}' are in the SQL, never bound
103
+ // values, so no caller can hand this function a real secret or an allow-list entry. The
104
+ // ON CONFLICT WHERE is claim rule 1: a credentialed row is left completely alone (its origin
105
+ // binds the CLI mint audience). Returns the row, or null when the client_id is already a real
106
+ // client's. Never touches `status` — a suspended client stays suspended.
107
+ async function upsertCatalogOnlyClient(entry, { pool = defaultPool } = {}) {
108
+ const e = normalizeEntry(entry);
109
+ const { rows } = await pool.query(
110
+ `INSERT INTO platform_identity_sso_clients
111
+ (client_id, secret_hash, name, redirect_uris, origin, owner_github_id)
112
+ VALUES ($1, '', $2, '{}'::text[], $3, $4)
113
+ ON CONFLICT (client_id) DO UPDATE SET
114
+ name = EXCLUDED.name,
115
+ origin = EXCLUDED.origin,
116
+ owner_github_id = EXCLUDED.owner_github_id,
117
+ updated_at = now()
118
+ WHERE platform_identity_sso_clients.secret_hash = ''
119
+ RETURNING client_id, name, origin, owner_github_id, status`,
120
+ [e.clientId, e.name, e.origin, e.ownerGithubId],
121
+ );
122
+ return rows[0] || null;
123
+ }
124
+
125
+ // The catalog row itself, keyed on origin. `description` and `visibility` are assigned, not
126
+ // COALESCEd — see the header: a once-at-provision COALESCE is the whole bug. The ON CONFLICT
127
+ // WHERE is claim rule 2 (the owner must already match), so an origin someone else's row
128
+ // occupies is refused rather than overwritten. Returns the row, or null when the claim
129
+ // refused. `featured`, `tagline`, `art_url` and `template` are curation and stay untouched.
130
+ async function upsertOwnedProject(entry, { pool = defaultPool } = {}) {
131
+ const e = normalizeEntry(entry);
132
+ const { rows } = await pool.query(
133
+ `INSERT INTO platform_identity_projects
134
+ (origin, name, description, owner_github_id, type, visibility, status)
135
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
136
+ ON CONFLICT (origin) DO UPDATE SET
137
+ name = EXCLUDED.name,
138
+ description = EXCLUDED.description,
139
+ visibility = EXCLUDED.visibility,
140
+ status = EXCLUDED.status,
141
+ type = COALESCE(EXCLUDED.type, platform_identity_projects.type),
142
+ updated_at = now()
143
+ WHERE platform_identity_projects.owner_github_id = EXCLUDED.owner_github_id
144
+ RETURNING id, origin, name, description, owner_github_id, type, status, featured, visibility`,
145
+ [e.origin, e.name, e.description, e.ownerGithubId, e.type, e.visibility, e.status],
146
+ );
147
+ return rows[0] || null;
148
+ }
149
+
150
+ // Take a catalog row OFF the map when its project moved to a different address (the domain
151
+ // re-point, task 1003237): an abandoned origin left `active` would keep drawing an orb that
152
+ // points at a hostname the project no longer answers on — the ADR 0181 §2 leak, in the
153
+ // catalog rather than in DNS. Owner-scoped for the same reason the upsert is, so a re-point
154
+ // can only retire the caller's own row. Returns the retired row or null.
155
+ async function retireOwnedProject({ origin, ownerGithubId }, { pool = defaultPool } = {}) {
156
+ const norm = String(origin || '').replace(/\/+$/, '');
157
+ const owner = Number(ownerGithubId);
158
+ if (!norm || !Number.isFinite(owner) || owner <= 0) return null;
159
+ const { rows } = await pool.query(
160
+ `UPDATE platform_identity_projects SET status = 'hidden', updated_at = now()
161
+ WHERE origin = $1 AND owner_github_id = $2
162
+ RETURNING id, origin, status`,
163
+ [norm, owner],
164
+ );
165
+ return rows[0] || null;
166
+ }
167
+
168
+ // ONE control-plane project, projected into the catalog: the identity row first, then the map
169
+ // row, then (on a re-point) the address it left. Both writes are claim-gated, so `claimed`
170
+ // tells the caller whether the map actually carries this project — a false is a refusal, not
171
+ // an error, and the caller decides whether it is worth saying anything about.
172
+ async function publishControlPlaneProject(entry, { retireOrigin = null, pool = defaultPool } = {}) {
173
+ const e = normalizeEntry(entry);
174
+ const client = await upsertCatalogOnlyClient(e, { pool });
175
+ const project = await upsertOwnedProject(e, { pool });
176
+ let retired = null;
177
+ if (retireOrigin && String(retireOrigin).replace(/\/+$/, '') !== e.origin) {
178
+ retired = await retireOwnedProject({ origin: retireOrigin, ownerGithubId: e.ownerGithubId }, { pool });
179
+ }
180
+ return { claimed: !!project, client, project, retired };
181
+ }
182
+
183
+ // The KERNEL SEAM PORT — `project.catalog`, registered by routes/projects.js and declared in
184
+ // module.json. The `project.publishGate` precedent, and for the same reason: provisioning is
185
+ // the plane that OWNS these projects and may not import this module (the ADR 0083 one-way
186
+ // rule), while this module owns the catalog's tables. One definition, reached through the
187
+ // switchboard. `publish` is deliberately the only verb — a consumer maps its own row onto the
188
+ // entry shape, so the catalog never learns another module's schema.
189
+ const projectCatalogPort = Object.freeze({
190
+ publish: (entry, opts) => publishControlPlaneProject(entry, opts),
191
+ });
192
+
193
+ module.exports = {
194
+ CATALOG_STATUSES,
195
+ clientIsCredentialed,
196
+ normalizeEntry,
197
+ upsertCatalogOnlyClient,
198
+ upsertOwnedProject,
199
+ retireOwnedProject,
200
+ publishControlPlaneProject,
201
+ projectCatalogPort,
202
+ };
@@ -42,13 +42,18 @@ const RELAY_SENTENCE = {
42
42
  invite_only: 'This project is invite-only — it doesn’t take requests. Ask its owner for an invite; once invited, sign in to it.',
43
43
  open_enrollment: 'This project is open — no request needed. Sign in to it and you’re in.',
44
44
  members_only: 'This project is only visible to its members right now, so it can’t take a request from here — ask its owner.',
45
+ // Dark matter (BV1.R12, task 1002331): the project's own door says it is not
46
+ // published yet. Relayed as its own outcome rather than left to fall through,
47
+ // because the fall-through is a 502 "could not be reached" — the exact lie about
48
+ // someone else's door that ADR 0194 D5 forbids.
49
+ not_published: 'This project isn’t published yet, so it isn’t taking anyone on. Its owner has to finish setting it up first.',
45
50
  };
46
51
  function joinRelayOutcome(status, body) {
47
52
  const err = (body && body.error) || null;
48
53
  const code = err && (err.code || err);
49
54
  let outcome;
50
55
  if (status >= 200 && status < 300) outcome = 'requested';
51
- else if (code === 'invite_only' || code === 'open_enrollment') outcome = code;
56
+ else if (code === 'invite_only' || code === 'open_enrollment' || code === 'not_published') outcome = code;
52
57
  else if (status === 409) outcome = 'already_member';
53
58
  else if (status === 401) outcome = 'members_only';
54
59
  else return null;
@@ -184,7 +189,7 @@ module.exports = function myProjectsRoutes() {
184
189
  }
185
190
  if (!outcome) return res.fail('join_relay_failed', { status: 502, message: 'the project could not be reached to request access' });
186
191
  // The echo (ADR 0201 D1.3) — written ONLY when the project actually took an
187
- // application. 'already_member', the two door refusals and 'members_only'
192
+ // application. 'already_member', the three door refusals and 'members_only'
188
193
  // are not applications and must leave no trace; a re-apply IS one ('requested'
189
194
  // covers the project's own already_pending 200, D1.4), and refreshes the date.
190
195
  // Best-effort in both directions: a lost echo must never turn a filed
@@ -21,6 +21,7 @@
21
21
  const express = require('express');
22
22
  const api = require('../../../src/module-api');
23
23
  const pi = require('../platform-identity');
24
+ const projectCatalog = require('../project-catalog');
24
25
  const projectStats = require('../project-stats');
25
26
 
26
27
  const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet } = api;
@@ -41,9 +42,14 @@ function hostOfOrigin(origin) {
41
42
  // catalogued here — ask THIS module's gate whether a project is on the map,
42
43
  // without importing it (the ADR 0083 one-way rule) and without keeping a
43
44
  // second copy of the rule that could drift from publishableSql().
45
+ // • PROVIDES port `project.catalog` → { publish(entry) } (task 1003576, ADR 0246).
46
+ // The write half of the same seam: the plane that OWNS a project (provisioning)
47
+ // projects it into the catalog these tables hold, so getting onto the map stops
48
+ // depending on whether the project happened to federate as a co-tenant.
44
49
  function registerPublishGateSeam() {
45
50
  if (api.hasProvider('project.publishGate')) return;
46
51
  api.registerProvider('project.publishGate', pi.publishGatePort);
52
+ api.registerProvider('project.catalog', projectCatalog.projectCatalogPort);
47
53
  }
48
54
 
49
55
  module.exports = function projectsRoutes() {
@@ -17,6 +17,7 @@
17
17
  const express = require('express');
18
18
  const api = require('../../../src/module-api');
19
19
  const pi = require('../platform-identity');
20
+ const projectCatalog = require('../project-catalog');
20
21
  const scoutingAuthz = require('../scouting-authz');
21
22
  const canonicalProfile = require('../canonical-profile');
22
23
  const hubKeys = require('../hub-keys');
@@ -325,8 +326,17 @@ module.exports = function ssoRoutes() {
325
326
  try {
326
327
  // Resolve the client from the user-typed origin — NOT from any client_id the
327
328
  // caller supplies. An unregistered origin has no client → refuse (correct default).
329
+ //
330
+ // …and a CATALOG-ONLY client is not a federation partner (task 1003576, ADR 0246).
331
+ // This is the one SSO leg that checks neither the secret nor the redirect allow-list,
332
+ // so it is the one that needs the credential asked for explicitly: /sso/token,
333
+ // /sso/membership/check-in and /sso/activity/rollup already fail closed on a
334
+ // secretless client (clientSecretValid), and /sso/authorize on an empty allow-list
335
+ // (redirectUriAllowed). Without this, minting a catalog row for every control-plane
336
+ // project would hand each of them a device flow nobody registered a credential for.
328
337
  const client = await pi.getClientByOrigin(req.body.origin, { pool });
329
338
  if (!client || client.status !== 'active') return res.fail('invalid_client', 401);
339
+ if (!projectCatalog.clientIsCredentialed(client)) return res.fail('invalid_client', 401);
330
340
  const out = await hubDevice.startHubGithubDevice(client.client_id);
331
341
  res.json(out);
332
342
  } catch (err) {
@@ -0,0 +1,105 @@
1
+ // modules/provisioning/catalog-bridge.js — the provisioning row → hub catalog projection
2
+ // (task 1003576, ADR 0246). The half of the bridge this module owns: WHAT a control-plane
3
+ // project looks like on the map, and WHEN that picture is re-taken.
4
+ //
5
+ // WHY IT EXISTS. An owner's publish verdict is computed here, over this module's row
6
+ // (`publishVerdict` in routes/provisioning.js). The map is `platform_identity_projects`, a
7
+ // table this module may not touch (ADR 0083's one-way rule). Until this file the ONLY thing
8
+ // that ever carried the first into the second was federation at provision time, gated on
9
+ // `hosting_shape === 'co-tenant'` — so a wizard-made standalone project was never on the map
10
+ // and a co-tenant one that answered later never updated. The projection below is re-taken at
11
+ // every write that can change its inputs (create, the detail answers, the settings door, a
12
+ // re-pointed address), which is the property a once-at-standup write can never have.
13
+ //
14
+ // IT GRANTS NOTHING. The catalog row is attribution (ADR 0238): ownership stays
15
+ // `provisioning_instances.owner_builder_id`, which is what `instanceAccessDenied` already
16
+ // enforces on every route that calls this. The projection carries the owner's github id only
17
+ // so the hub can attribute the row and claim-check its own writes.
18
+ 'use strict';
19
+
20
+ const api = require('../../src/module-api');
21
+ const { effectiveSettings } = require('./provisioning');
22
+
23
+ const log = api.logger('provisioning');
24
+
25
+ // The instance states in which the project is being, or has been, reclaimed — the two that
26
+ // map to a HIDDEN catalog row. Every other state is a project that exists: 'requested',
27
+ // 'provisioning' and 'error' are all "the owner is standing this up", and the map is a
28
+ // directory of projects, not an uptime board (an unreachable 'active' instance already stays
29
+ // listed; liveness is an overlay, never a state).
30
+ const CATALOG_HIDDEN_STATES = new Set(['tearing_down', 'torn_down']);
31
+
32
+ // ONE instance row → the catalog entry, or null when it cannot be catalogued. PURE.
33
+ //
34
+ // Null has exactly two causes, both honest refusals rather than failures:
35
+ // • NO ADDRESS — the catalog is keyed on `origin`, so a `no_address: true` instance has
36
+ // nothing to key a row on. That opt-out is a deliberate choice not to have a public
37
+ // address, and it therefore means not being on the public map.
38
+ // • NO OWNER GITHUB ID — the hub's claim rule compares owners, so a row written without
39
+ // one could never be refreshed again. Refusing to write it beats stranding it.
40
+ //
41
+ // The MAPPING is the part this module owns: an instance's `slug` IS its project's name on
42
+ // this plane (the same mapping `publishVerdict` feeds the publish gate) and its `client_id`
43
+ // on the hub (the same one `federateInstance` uses, so a later real federation rotates the
44
+ // SAME row rather than orphaning it). The door comes from `effectiveSettings`, never the raw
45
+ // jsonb, so an off-vocabulary stored value resolves to today's behaviour exactly as it does
46
+ // on every other surface.
47
+ function catalogEntryForInstance(row, { ownerGithubId } = {}) {
48
+ if (!row || !row.slug || !row.domain) return null;
49
+ const owner = Number(ownerGithubId);
50
+ if (!Number.isFinite(owner) || owner <= 0) return null;
51
+ return {
52
+ clientId: row.slug,
53
+ origin: `https://${row.domain}`,
54
+ name: row.slug,
55
+ description: row.description || null,
56
+ type: row.type || null,
57
+ visibility: effectiveSettings(row).visibility,
58
+ status: CATALOG_HIDDEN_STATES.has(row.status) ? 'hidden' : 'active',
59
+ ownerGithubId: owner,
60
+ };
61
+ }
62
+
63
+ // The owner's stable GitHub id, for attribution on the catalog row. Reads core's `builders`
64
+ // table directly, which is `federateInstance`'s own precedent for exactly this lookup — a
65
+ // nullable, best-effort field, so a miss must never fail the caller.
66
+ async function ownerGithubIdForInstance(db, row) {
67
+ if (!row || !row.owner_builder_id) return null;
68
+ const { rows } = await db.query('SELECT github_id FROM builders WHERE id = $1', [row.owner_builder_id]);
69
+ return rows[0] ? rows[0].github_id : null;
70
+ }
71
+
72
+ // Re-take the projection for one instance and hand it to the hub through the `project.catalog`
73
+ // port. `retireOrigin` is the address a re-point moved OFF, so the abandoned row leaves the map
74
+ // with the same call that puts the new one on it.
75
+ //
76
+ // resolveOptional, not resolve, and NOT declared in `consumes` — the publishVerdict precedent:
77
+ // platform-identity is hub-only (`default: false`), so an instance running provisioning without
78
+ // it has no map to be on and the honest result is "not published", never an error.
79
+ //
80
+ // BEST-EFFORT BY CONSTRUCTION. Every caller is a route whose real work — the create, the
81
+ // detail answers, the settings save — is already durably written when this runs. A catalog
82
+ // hiccup must not turn a saved change into a 500, so this never throws; it reports why.
83
+ async function publishInstanceToCatalog(db, row, { retireOrigin = null } = {}) {
84
+ const catalog = api.resolveOptional('project.catalog');
85
+ if (!catalog) return { published: false, reason: 'no_catalog' };
86
+ try {
87
+ const ownerGithubId = await ownerGithubIdForInstance(db, row);
88
+ const entry = catalogEntryForInstance(row, { ownerGithubId });
89
+ if (!entry) return { published: false, reason: row && row.domain ? 'no_owner_identity' : 'no_address' };
90
+ const out = await catalog.publish(entry, { retireOrigin: retireOrigin ? `https://${retireOrigin}` : null });
91
+ if (!out.claimed) {
92
+ log.warn({ slug: row.slug, origin: entry.origin }, 'catalog claim refused — that address is already another project\'s row');
93
+ }
94
+ return { published: !!out.claimed, reason: out.claimed ? null : 'origin_held', entry };
95
+ } catch (err) {
96
+ log.error({ err: err.message, slug: row && row.slug }, 'catalog projection failed (non-fatal)');
97
+ return { published: false, reason: 'error' };
98
+ }
99
+ }
100
+
101
+ module.exports = {
102
+ CATALOG_HIDDEN_STATES,
103
+ catalogEntryForInstance,
104
+ publishInstanceToCatalog,
105
+ };
@@ -136,6 +136,8 @@ const RESTART_SHAPES = new Set(['co-tenant', 'standalone']);
136
136
  // visibility how the project stands on the PLATFORM's map: named and
137
137
  // open (public), named and closed (private), or present but
138
138
  // anonymous (stealth)
139
+ // join_grant what a PUBLIC join GRANTS: a look (view), a request the
140
+ // owner reviews (apply), or membership itself (full)
139
141
  //
140
142
  // The name is ADR 0182 D1's deliberate choice — `goals.visibility` established the
141
143
  // word and one word across both planes beat an accurate name used in one place —
@@ -148,15 +150,27 @@ const RESTART_SHAPES = new Set(['co-tenant', 'standalone']);
148
150
  // so it rides web.env like its two neighbours and a restart applies it. The instance
149
151
  // reads it back as `project.visibility` (src/branding.js), which is what lets a later
150
152
  // task enforce the door there and report the value to the hub catalog (R08).
153
+ //
154
+ // `join_grant` is the owner's answer to "what does joining my public project GET
155
+ // someone?" (BV1.R12, task 1002331 — the project interview's own words). It is the
156
+ // CEILING on the visibility axis that ADR 0182 D6 says `joinability` may only
157
+ // narrow, and it governs the `public` state alone: `private` and `stealth` set
158
+ // their own ceilings and never consult it. `full` is "this axis narrows nothing",
159
+ // which is why it is the default — DEFAULT_SETTINGS must be today's behaviour, and
160
+ // today a public project runs whatever door its joinability runs. Composed at the
161
+ // door by src/bongos/project-door.js; ADR 0247.
151
162
  const SETTINGS_VOCAB = Object.freeze({
152
163
  platform_visibility: ['public', 'gated'],
153
164
  joinability: ['open', 'apply', 'invite_only'],
154
165
  visibility: ['public', 'private', 'stealth'],
166
+ join_grant: ['view', 'apply', 'full'],
155
167
  });
156
168
  // Default = today's behavior on every axis, including the join door: ADR 0182's
157
169
  // migration gives the hub column the same `public` default, "existing rows keep
158
170
  // today's behaviour".
159
- const DEFAULT_SETTINGS = Object.freeze({ platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
171
+ const DEFAULT_SETTINGS = Object.freeze({
172
+ platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full',
173
+ });
160
174
 
161
175
  // The project's PLANET (task 1003347, direction record §16): how the platform
162
176
  // DRAWS it on the sky, in the owner's list and on the card — its template
@@ -646,7 +660,14 @@ async function updateInstanceSettings(db, id, patch) {
646
660
  // merge. Passing description: null clears it — and un-publishes the project, which
647
661
  // is the honest consequence of deleting half the identity gate, not a bug.
648
662
  // Undefined leaves the column alone.
649
- async function updateInstanceDetail(db, id, { description, detail } = {}) {
663
+ //
664
+ // `modules` is the SAME way back for the wizard's other optional step (BV1.R23,
665
+ // task 1002342, ADR 0245). It REPLACES like `description` and unlike `detail`: a
666
+ // module selection is one whole list, so merging would make removing a module
667
+ // impossible — the very half of "add modules later" an owner needs most. Only an
668
+ // array ever reaches here, so the column's NULL ("never asked", ADR 0240 §2)
669
+ // stays reachable only by never answering; no write can un-ask the question.
670
+ async function updateInstanceDetail(db, id, { description, detail, modules } = {}) {
650
671
  const sets = ['updated_at = now()'];
651
672
  const params = [id];
652
673
  if (description !== undefined) {
@@ -657,6 +678,11 @@ async function updateInstanceDetail(db, id, { description, detail } = {}) {
657
678
  params.push(JSON.stringify(normalizeProjectDetail(detail)));
658
679
  sets.push(`detail = detail || $${params.length}::jsonb`);
659
680
  }
681
+ if (modules !== undefined) {
682
+ const picked = normalizeModuleSelection(modules);
683
+ params.push(picked === null ? null : JSON.stringify(picked));
684
+ sets.push(`modules = $${params.length}::jsonb`);
685
+ }
660
686
  const { rows } = await db.query(
661
687
  `UPDATE provisioning_instances SET ${sets.join(', ')} WHERE id = $1 RETURNING *`,
662
688
  params
@@ -42,6 +42,7 @@ const { onboardPlan, stepsForShape } = require('../onboard-plan');
42
42
  const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
43
43
  const { recommendationsForDetails } = require('../recommendations');
44
44
  const { makeAskRateLimiter, clientIp } = require('../rate-limit');
45
+ const catalogBridge = require('../catalog-bridge');
45
46
  const { callbackPage } = require('./callback-page');
46
47
  // task 1003208: structured logging (pino via the doorway) — was console.*.
47
48
  const log = api.logger('provisioning');
@@ -479,6 +480,12 @@ module.exports = function provisioningRoutes() {
479
480
  actor: 'api:self',
480
481
  }).catch(() => {});
481
482
  }
483
+ // The project joins the hub catalog the moment it EXISTS, not when (or whether) it
484
+ // later federates as a co-tenant (task 1003576, ADR 0246). It is dark matter until a
485
+ // description exists — the publish gate alone decides that — but the row has to be
486
+ // there for the gate to have anything to decide about, and for the wizard's invite
487
+ // step to have a project to invite into while standup is still running.
488
+ await catalogBridge.publishInstanceToCatalog(pool, instance).catch(() => {});
482
489
  // A re-request cannot change an existing instance's domain — SAY so instead of
483
490
  // silently dropping it (task 1002692); the domain route is the real path.
484
491
  let domainIgnored;
@@ -520,14 +527,13 @@ module.exports = function provisioningRoutes() {
520
527
  hint: `a re-request never changes an existing instance's project detail — PATCH /provisioning/instances/${instance.id}/detail instead`,
521
528
  };
522
529
  }
523
- // And the same for the picker's module set (task 1002339). Like `type` —
524
- // and unlike detail — there is no mutation route yet (R23, task 1002342 is
525
- // "add modules later"), so the receipt says the answer was kept rather
526
- // than naming a way back that does not exist.
530
+ // And the same for the picker's module set (task 1002339). Like detail —
531
+ // and unlike `type` — this one HAS a way back since BV1.R23 (task 1002342),
532
+ // so the receipt names it instead of leaving the owner to hunt for one.
527
533
  let modulesIgnored;
528
534
  if (!created && body.modules !== undefined) {
529
535
  modulesIgnored = {
530
- hint: "a re-request never changes an existing instance's module selection",
536
+ hint: `a re-request never changes an existing instance's module selection — PATCH /provisioning/instances/${instance.id}/detail with { modules } instead`,
531
537
  };
532
538
  }
533
539
  res.status(created ? 201 : 200).json({
@@ -653,9 +659,9 @@ module.exports = function provisioningRoutes() {
653
659
 
654
660
  // ── The optional project detail (task 1002334, goal 1000046) ──────────────
655
661
  // PATCH /provisioning/instances/:id/detail — answer (or re-answer) the
656
- // "tell us about your project" step after creation. Body: any subset of
657
- // { description, detail }. rank: any authenticated builder (own resource;
658
- // archon may change any — same gate as settings).
662
+ // wizard's OPTIONAL creation steps after creation. Body: any subset of
663
+ // { description, detail, modules }. rank: any authenticated builder (own
664
+ // resource; archon may change any — same gate as settings).
659
665
  //
660
666
  // THIS ROUTE IS WHAT MAKES THE STEP SKIPPABLE. Skipping at creation leaves the
661
667
  // project dark-matter — off the map and un-joinable (ADR 0182 D4) — and that is
@@ -668,19 +674,31 @@ module.exports = function provisioningRoutes() {
668
674
  // answers live on the platform row until a catalog row materializes (the
669
675
  // provisioning_007 `type` precedent), and R16 (task 1002335) is the surface that
670
676
  // reads `description` to compute publishability and name what is still missing.
677
+ //
678
+ // `modules` rides the SAME route for the same reason (BV1.R23, task 1002342,
679
+ // ADR 0245): step 4 of the wizard is skippable exactly as step 3 is, so it needs
680
+ // the same way back, and it lands on the same platform row under the same
681
+ // ownership gate. Only an ARRAY is a change — an omitted or null key leaves the
682
+ // column alone, so the "never asked" NULL (ADR 0240 §2) can never be written by
683
+ // a later edit. The always-on core stays unremovable by construction:
684
+ // badModuleSelection refuses its keys and `effectiveModules` re-adds it on read.
671
685
  router.patch('/provisioning/instances/:id/detail', auth.requireBuilder, async (req, res) => {
672
686
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
673
687
  if (!id) return;
674
688
  if (validateOrRespond(req, res, {
675
689
  description: { type: 'string', maxLength: provisioning.DESCRIPTION_MAX },
676
690
  detail: { type: 'object' },
691
+ modules: { type: 'array', itemsType: 'string', maxItems: 50 },
677
692
  })) return;
678
693
  const body = req.body || {};
679
694
  const detailFault = badProjectDetail(body.detail);
680
695
  if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
681
- if (body.description === undefined && body.detail === undefined) {
696
+ const modulesFault = badModuleSelection(body.modules);
697
+ if (modulesFault) return res.fail(modulesFault.code, { status: 400, message: modulesFault.message });
698
+ const wantsModules = Array.isArray(body.modules);
699
+ if (body.description === undefined && body.detail === undefined && !wantsModules) {
682
700
  return res.fail('bad_detail', { status: 400,
683
- message: 'The body carries nothing to change — send description, detail, or both.' });
701
+ message: 'The body carries nothing to change — send description, detail, modules, or any mix.' });
684
702
  }
685
703
  try {
686
704
  const inst = await provisioning.getInstanceById(pool, id);
@@ -692,12 +710,18 @@ module.exports = function provisioningRoutes() {
692
710
  const patch = {};
693
711
  if (body.description !== undefined) patch.description = provisioning.normalizeDescription(body.description);
694
712
  if (body.detail !== undefined) patch.detail = body.detail;
713
+ if (wantsModules) patch.modules = body.modules;
695
714
  const updated = await provisioning.updateInstanceDetail(pool, id, patch);
696
715
  await provisioning.recordEvent(pool, {
697
716
  instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'detail',
698
717
  detail: `project detail changed: ${Object.keys(patch).join(', ')}`,
699
718
  actor,
700
719
  }).catch(() => {});
720
+ // Re-take the catalog projection (task 1003576, ADR 0246) — this is THE write the
721
+ // R18 proof caught going nowhere. A description added here has to reach the map, and
722
+ // one CLEARED here has to leave it, which is why the hub assigns `description`
723
+ // rather than COALESCEing it.
724
+ await catalogBridge.publishInstanceToCatalog(pool, updated).catch(() => {});
701
725
  res.json({
702
726
  ok: true,
703
727
  description: updated.description || null,
@@ -809,6 +833,11 @@ module.exports = function provisioningRoutes() {
809
833
  detail: `settings changed: ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(', ')}`,
810
834
  actor,
811
835
  }).catch(() => {});
836
+ // The door the owner just set is what the map draws this project's orb from
837
+ // (planet / star / black hole), so the catalog projection is re-taken here as well
838
+ // as on the detail write (task 1003576, ADR 0246). Unlike the push below this
839
+ // needs no restart and no reachable shape — it is the PLATFORM's own row.
840
+ await catalogBridge.publishInstanceToCatalog(pool, updated).catch(() => {});
812
841
  }
813
842
  // The push (task 1003140). The row is now the platform's truth, but a
814
843
  // RUNNING instance reads its settings from web.env through a pack memoized
@@ -1054,7 +1083,12 @@ module.exports = function provisioningRoutes() {
1054
1083
  // Record BEFORE enqueueing: the runner reads the row's domain, so the intent
1055
1084
  // must never race ahead of the column it wires. The release queue rides the
1056
1085
  // SAME statement — see updateInstanceDomain.
1057
- await provisioning.updateInstanceDomain(pool, id, domain, releasing);
1086
+ const rewired = await provisioning.updateInstanceDomain(pool, id, domain, releasing);
1087
+ // The catalog is keyed on ORIGIN, so an address change moves the project's row (task
1088
+ // 1003576, ADR 0246). `retireOrigin` takes the abandoned one off the map in the same
1089
+ // call — a row left `active` at a hostname the project no longer answers on is the
1090
+ // ADR 0181 §2 leak, in the catalog instead of in DNS.
1091
+ await catalogBridge.publishInstanceToCatalog(pool, rewired, { retireOrigin: current }).catch(() => {});
1058
1092
  let queued = false;
1059
1093
  if (inst.status === 'active') {
1060
1094
  const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
@@ -263,24 +263,27 @@ await ta('recordDnsNote / clearErrorNote are NARROW single-column updates — no
263
263
  console.log('\nPer-project settings (task 1003040):');
264
264
 
265
265
  t('effectiveSettings: defaults for a bare/empty row = today\'s behavior; stored keys overlay; junk falls back', () => {
266
- assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
267
- assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
268
- assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
266
+ assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
267
+ assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
268
+ assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
269
269
  assert.deepEqual(
270
270
  P.effectiveSettings({ settings: { platform_visibility: 'gated' } }),
271
- { platform_visibility: 'gated', joinability: 'apply', visibility: 'public' },
271
+ { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full' },
272
272
  'a stored key overlays only itself'
273
273
  );
274
274
  assert.deepEqual(
275
- P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x', visibility: 'unlisted' } }),
276
- { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public' },
275
+ P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x', visibility: 'unlisted', join_grant: 'everything' } }),
276
+ { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full' },
277
277
  'off-vocabulary values fall back to the default; unknown keys never leak out'
278
278
  );
279
279
  assert.deepEqual(P.effectiveSettings({ settings: ['not', 'an', 'object'] }),
280
- { platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'a non-object settings value reads as defaults');
280
+ { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' }, 'a non-object settings value reads as defaults');
281
281
  // the join door reads back like any other policy key (task 1002324)
282
282
  assert.deepEqual(P.effectiveSettings({ settings: { visibility: 'stealth' } }),
283
- { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth' }, 'a stored door overlays only itself');
283
+ { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full' }, 'a stored door overlays only itself');
284
+ // what a public join grants is a policy key like any other (task 1002331)
285
+ assert.deepEqual(P.effectiveSettings({ settings: { join_grant: 'view' } }),
286
+ { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'view' }, 'the grant overlays only itself');
284
287
  });
285
288
 
286
289
  t('SETTINGS_VOCAB / DEFAULT_SETTINGS: every default is on its own vocabulary (lockstep guard)', () => {