@bongos/core 1.19.418 → 1.19.420

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 (48) hide show
  1. package/.bongos-core.json +95 -45
  2. package/.claude/skills/new-project/SKILL.md +22 -11
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +18 -0
  5. package/clients/bongos-client/index.cjs +18 -0
  6. package/clients/bongos-client/index.d.ts +28 -1
  7. package/clients/bongos-client/index.mjs +18 -0
  8. package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
  9. package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
  10. package/docs/adr/README.md +2 -0
  11. package/docs/api/openapi.json +518 -5
  12. package/docs/api-reference.md +16 -3
  13. package/docs/copy-inventory.md +186 -174
  14. package/docs/copy-registry.json +348 -238
  15. package/docs/file-map.md +1 -0
  16. package/docs/module-api-changelog.md +4 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +18 -0
  18. package/modules/onboarding/routes/access-requests.js +16 -3
  19. package/modules/platform-identity/account-visibility.js +58 -0
  20. package/modules/platform-identity/connections.js +490 -0
  21. package/modules/platform-identity/migrations/platform_identity_015_connections.sql +78 -0
  22. package/modules/platform-identity/module.json +1 -1
  23. package/modules/platform-identity/platform-identity.js +5 -5
  24. package/modules/platform-identity/routes/connections.js +236 -0
  25. package/modules/platform-identity/tests/platform-identity.mjs +20 -0
  26. package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
  27. package/modules/provisioning/onboard-plan.js +13 -0
  28. package/modules/provisioning/provisioning.js +113 -5
  29. package/modules/provisioning/routes/provisioning.js +105 -0
  30. package/modules/provisioning/tests/provisioning.mjs +84 -0
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/public-landing/projects.html +161 -42
  34. package/scripts/gds/fitness.js +9 -0
  35. package/scripts/gds/onboard.js +41 -1
  36. package/scripts/gds/provision-net.js +7 -3
  37. package/src/bongos/middleware/rate-limit.js +68 -2
  38. package/src/bongos/routes/auth.js +19 -12
  39. package/src/module-api.js +13 -1
  40. package/tests/account_existence_rate_limit.mjs +375 -0
  41. package/tests/connections_api.mjs +568 -0
  42. package/tests/connections_ownership.mjs +313 -0
  43. package/tests/module_api.mjs +1 -0
  44. package/tests/onboard.mjs +56 -0
  45. package/tests/own_scope_guard.mjs +5 -0
  46. package/tests/projects_hub.mjs +69 -0
  47. package/tests/projects_hub_pre_uat.mjs +27 -5
  48. package/tests/provision.mjs +20 -0
@@ -0,0 +1,78 @@
1
+ -- platform_identity_015_connections.sql — the connection graph (task 1002295 /
2
+ -- goal 1000045 criterion C6; ADR 0202 D1, whose migration sketch this is).
3
+ --
4
+ -- WHAT THIS IS. One directed row per relationship — the REQUEST, not the edge.
5
+ -- requester/addressee are kept because the inbox, the cancel path and the
6
+ -- 90-day decline cooldown all need to know who asked; the unordered pair is
7
+ -- GENERATED from LEAST/GREATEST so A→B and B→A collide on one index instead of
8
+ -- both being accepted. Reads are symmetric (the module unions both directions);
9
+ -- no route writes a direction-aware WHERE of its own.
10
+ --
11
+ -- WHY GENERATED + A PARTIAL UNIQUE INDEX rather than a route check: a duplicate
12
+ -- request and a simultaneous reciprocal request then become DATABASE errors, not
13
+ -- a race two handlers have to remember to lose. The index covers only the OPEN
14
+ -- statuses, so resolved rows stay as the audit trail and never block a later ask
15
+ -- (the goal_membership_requests precedent, core_188).
16
+ --
17
+ -- WHY NO TOMBSTONE ON REMOVE (ADR 0202 D2): removing an accepted connection
18
+ -- HARD-DELETES the row. "A removed B" is data neither party consented to keep
19
+ -- and no read path uses. 'rejected' is kept because it still does work — it is
20
+ -- the cooldown — which is exactly why the asymmetry is not an inconsistency.
21
+ --
22
+ -- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). No rank check, admission gate
23
+ -- or credit path reads this table; accepting a connection changes nobody's rank,
24
+ -- credits, or access to any project. It is descriptive social data only.
25
+ --
26
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Forward-safe: CREATE
27
+ -- only, so the previous code release still runs against this schema. Idempotent.
28
+
29
+ BEGIN;
30
+
31
+ CREATE TABLE IF NOT EXISTS platform_identity_connections (
32
+ id bigserial PRIMARY KEY,
33
+ requester_github_id bigint NOT NULL,
34
+ addressee_github_id bigint NOT NULL,
35
+ -- The unordered pair, derived — never written by a caller, so A→B and B→A
36
+ -- collide on the index below instead of both being accepted.
37
+ pair_lo bigint GENERATED ALWAYS AS (LEAST(requester_github_id, addressee_github_id)) STORED,
38
+ pair_hi bigint GENERATED ALWAYS AS (GREATEST(requester_github_id, addressee_github_id)) STORED,
39
+ status text NOT NULL DEFAULT 'pending'
40
+ CHECK (status IN ('pending', 'accepted', 'rejected', 'cancelled', 'expired')),
41
+ -- The note cap lives HERE as well as in the route: requestConnection is an
42
+ -- exported domain function, and every other invariant in this table is one the
43
+ -- engine refuses to break rather than one a caller must remember.
44
+ note text CONSTRAINT platform_identity_connections_note_len
45
+ CHECK (note IS NULL OR length(note) <= 280),
46
+ seen_at timestamptz,
47
+ created_at timestamptz NOT NULL DEFAULT now(),
48
+ resolved_at timestamptz,
49
+ updated_at timestamptz NOT NULL DEFAULT now(),
50
+ CONSTRAINT platform_identity_connections_not_self
51
+ CHECK (requester_github_id <> addressee_github_id)
52
+ );
53
+
54
+ -- At most ONE open relationship per pair. Resolved rows stay as the audit trail
55
+ -- and never block a later ask.
56
+ CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_identity_connections_open
57
+ ON platform_identity_connections (pair_lo, pair_hi)
58
+ WHERE status IN ('pending', 'accepted');
59
+
60
+ -- The three reads: my inbox, my outbox, and the 1-hop neighbourhood both the
61
+ -- degree traversal and the suggester expand from.
62
+ CREATE INDEX IF NOT EXISTS platform_identity_connections_addressee
63
+ ON platform_identity_connections (addressee_github_id, status);
64
+ CREATE INDEX IF NOT EXISTS platform_identity_connections_requester
65
+ ON platform_identity_connections (requester_github_id, status);
66
+ CREATE INDEX IF NOT EXISTS platform_identity_connections_pair_accepted
67
+ ON platform_identity_connections (pair_lo, pair_hi) WHERE status = 'accepted';
68
+
69
+ COMMENT ON TABLE platform_identity_connections IS
70
+ 'Account-to-account connections on the hub (ADR 0202). One DIRECTED request row per relationship; pair_lo/pair_hi are the generated unordered key a partial unique index over (pending, accepted) enforces. Lifecycle pending -> accepted | rejected | cancelled | expired; remove hard-deletes. Descriptive only — never authority (ADR 0016).';
71
+
72
+ COMMENT ON COLUMN platform_identity_connections.status IS
73
+ 'pending -> accepted (addressee accepted) | rejected (addressee declined — SILENT to the requester, and the 90-day cooldown that swallows a re-ask) | cancelled (requester withdrew) | expired (30 days unanswered, resolved at write time because now() cannot live in an index predicate).';
74
+
75
+ COMMENT ON COLUMN platform_identity_connections.seen_at IS
76
+ 'Stamped on first inbox render (ADR 0202 D2, the privacy-spec D4 parity item) so a delivered/seen affordance can land later without a schema change. Never exposed to the requester — a decline is silent, and so is a read.';
77
+
78
+ COMMIT;
@@ -10,7 +10,7 @@
10
10
  "maintainer": "Cloud Bongos maintainers",
11
11
  "maintenance": { "status": "core-maintained" },
12
12
  "contributes": {
13
- "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile"],
13
+ "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections"],
14
14
  "migrations": true,
15
15
  "pollers": ["code-sweep"]
16
16
  },
@@ -201,7 +201,7 @@ async function getAccount(githubId, { pool = defaultPool } = {}) {
201
201
  // (task #1002109) calls this with a freshly generated secret; the plaintext is
202
202
  // hashed here and never stored. Upsert-on-client_id so re-provisioning rotates
203
203
  // the secret + redirect_uris without a duplicate row.
204
- async function registerClient({ clientId, secret, name, redirectUris, origin, ownerGithubId, type }, { pool = defaultPool } = {}) {
204
+ async function registerClient({ clientId, secret, name, redirectUris, origin, ownerGithubId, type, description }, { pool = defaultPool } = {}) {
205
205
  const secretHash = sha256hex(secret);
206
206
  const uris = Array.isArray(redirectUris) ? redirectUris : [];
207
207
  const { rows } = await pool.query(
@@ -218,11 +218,11 @@ async function registerClient({ clientId, secret, name, redirectUris, origin, ow
218
218
  RETURNING id, client_id, name, redirect_uris, origin, owner_github_id, status`,
219
219
  [clientId, secretHash, name, uris, origin ?? null, ownerGithubId ?? null],
220
220
  );
221
- // Auto-catalogue the project (unfeatured) so it appears in the Archon curation
222
- // list the moment it federates (ADR 0141 §5). Best-effort — a catalog hiccup must
223
- // never fail client registration. Only when an origin is known.
221
+ // Auto-catalogue the project (unfeatured) so it appears in the Archon curation list the
222
+ // moment it federates (ADR 0141 §5). Best-effort — a hiccup must never fail registration;
223
+ // only when an origin is known. `description` rides along like `type` (ADR 0182 D4, task 1002334).
224
224
  if (origin) {
225
- try { await upsertProject({ origin, name, ownerGithubId, type }, { pool }); }
225
+ try { await upsertProject({ origin, name, description, ownerGithubId, type }, { pool }); }
226
226
  catch (err) { console.error('[platform-identity] auto-catalogue failed (non-fatal):', err.message); }
227
227
  }
228
228
  return rows[0];
@@ -0,0 +1,236 @@
1
+ // modules/platform-identity/routes/connections.js — the connection graph API
2
+ // (R19, task 1002295 / goal 1000045 criterion C6; ADR 0202).
3
+ //
4
+ // EVERY ROUTE HERE IS OWN-SCOPED. The acting account is always
5
+ // req.builder.github_id — the session — and no route reads its subject from a
6
+ // query, body or path parameter. The counterparty is named by HANDLE (the
7
+ // public address, ADR 0171 D8) or by the connection row's own id; neither is a
8
+ // way to act AS someone else. tests/own_scope_guard.mjs holds that structurally.
9
+ //
10
+ // POST /connections — ask. Body { handle, note? }. 201
11
+ // { status: 'pending' } whether the row
12
+ // was written or SWALLOWED by the 90-day
13
+ // decline cooldown: the two answers are
14
+ // identical on purpose (ADR 0202 D2 §2).
15
+ // 404 — not 403 — for a private or absent
16
+ // target, because 403 confirms the handle.
17
+ // GET /connections — my accepted connections. count is the
18
+ // array's own length (no hidden remainder).
19
+ // GET /connections/requests — my inbox + outbox; stamps seen_at.
20
+ // GET /connections/degree/:handle — the viewer-relative label 0|1|2|3|null
21
+ // plus the mutuals it is computed from.
22
+ // An OWN-SCOPE read (D4 §4): this is how a
23
+ // viewer-relative number coexists with the
24
+ // viewer-independent public profile read.
25
+ // POST /connections/:id/accept — addressee only. 404 for anyone else.
26
+ // POST /connections/:id/decline — addressee only, SILENT (204). 404 else.
27
+ // DELETE /connections/:id — remove an accepted row (either side) or
28
+ // cancel a pending one (requester only).
29
+ //
30
+ // A CONNECTION IS NEVER AUTHORITY (ADR 0016 / ADR 0141). Nothing here reads or
31
+ // writes rank, credits, admission or permission; the numbers are descriptive.
32
+ //
33
+ // no-store on every response: these payloads are privacy-filtered per viewer and
34
+ // a hide or a visibility flip must beat any cache (ADR 0171 D5).
35
+ //
36
+ // Reaches core only through the doorway (ADR 0083).
37
+ 'use strict';
38
+
39
+ const express = require('express');
40
+ const api = require('../../../src/module-api');
41
+ const pi = require('../platform-identity');
42
+ const conn = require('../connections');
43
+ const { isAccountVisible } = require('../account-visibility');
44
+
45
+ const { pool, requireBuilder, validateOrRespond, parsePagination } = api;
46
+ const log = api.logger('platform-identity');
47
+
48
+ // A connection row id is a bigserial. Anything else is answered 404 rather than
49
+ // 400, for the same reason a wrong-owner id is: the id space must not be probeable.
50
+ // The length cap is load-bearing, not cosmetic: a 25-digit string is all digits
51
+ // and would reach Postgres, where it raises 22003 (bigint out of range) and
52
+ // surfaces as a 500 — a different answer for a different id, i.e. a probe.
53
+ function connectionId(req) {
54
+ return /^\d{1,19}$/.test(String(req.params.id || '')) ? req.params.id : null;
55
+ }
56
+
57
+ // The caller's platform account, or a { fail } describing why they cannot take
58
+ // part. profile_state = 'active' is the floor for the WHOLE feature (ADR 0202
59
+ // D4 §1): a provisional account cannot send, cannot receive, and never appears
60
+ // in a list or a frontier. This is about the CALLER, so it may say so plainly.
61
+ async function actingAccount(builder) {
62
+ const githubId = builder && builder.github_id;
63
+ if (!pi.isNumericGithubId(githubId)) {
64
+ return { fail: ['no_platform_identity', { status: 400, message: 'this session has no platform account' }] };
65
+ }
66
+ const acct = await pi.getAccount(githubId, { pool });
67
+ if (!acct || acct.profile_state !== 'active') {
68
+ return { fail: ['profile_provisional', { status: 403, message: 'sign in to the platform once to activate your profile before connecting' }] };
69
+ }
70
+ return { githubId };
71
+ }
72
+
73
+ // Resolve a handle to a counterparty THIS VIEWER may address: publicly visible,
74
+ // or a private account the viewer is already connected to (ADR 0202 D4 §2).
75
+ // Everything else — unknown handle, provisional account, private stranger — is
76
+ // the same null, and the caller answers 404 for all of them.
77
+ async function visibleTarget(viewerGithubId, handle) {
78
+ const acct = await conn.findAccountByHandle(handle, { pool });
79
+ if (!acct) return null;
80
+ if (isAccountVisible(acct)) return acct;
81
+ if (acct.profile_state !== 'active') return null;
82
+ return (await conn.isConnectedTo(viewerGithubId, acct.github_id, { pool })) ? acct : null;
83
+ }
84
+
85
+ module.exports = function connectionRoutes() {
86
+ const router = express.Router();
87
+
88
+ // One prefix covers every route below (/connections and /connections/...).
89
+ router.use('/connections', (req, res, next) => {
90
+ res.set('Cache-Control', 'no-store');
91
+ next();
92
+ });
93
+
94
+ // rank: any-builder — MY accepted connections (own data, keyed on the session).
95
+ // Own scope, so the counterparty floors on profile_state alone: a private
96
+ // account you connected with appears here and in no public list (D4 §2).
97
+ router.get('/connections', requireBuilder, async (req, res) => {
98
+ try {
99
+ const me = await actingAccount(req.builder);
100
+ if (me.fail) return res.fail(...me.fail);
101
+ const { limit, offset } = parsePagination(req, { defaultLimit: conn.FANOUT_1, maxLimit: conn.FANOUT_1 });
102
+ const connections = await conn.listConnections({ githubId: me.githubId, limit, offset }, { pool });
103
+ // The count IS the array's length — never a raw total beside a filtered
104
+ // list, which is a leak by subtraction (ADR 0171 D4 / ADR 0202 D4).
105
+ res.json({ connections, count: connections.length });
106
+ } catch (err) {
107
+ log.error({ err: err && err.message }, 'GET /connections failed');
108
+ res.fail('connections_read_failed', { status: 500, message: 'internal error' });
109
+ }
110
+ });
111
+
112
+ // rank: any-builder — MY pending requests, both directions (own data).
113
+ router.get('/connections/requests', requireBuilder, async (req, res) => {
114
+ try {
115
+ const me = await actingAccount(req.builder);
116
+ if (me.fail) return res.fail(...me.fail);
117
+ const { incoming, outgoing } = await conn.listRequests({ githubId: me.githubId }, { pool });
118
+ res.json({ incoming, outgoing, incoming_count: incoming.length, outgoing_count: outgoing.length });
119
+ } catch (err) {
120
+ log.error({ err: err && err.message }, 'GET /connections/requests failed');
121
+ res.fail('connection_requests_read_failed', { status: 500, message: 'internal error' });
122
+ }
123
+ });
124
+
125
+ // rank: any-builder — the degree label between the CALLER and one handle, plus
126
+ // the mutuals it was computed from (own-scope read, D4 §4). Bounded by
127
+ // construction: degreeBetween caps every level and answers null past the caps.
128
+ router.get('/connections/degree/:handle', requireBuilder, async (req, res) => {
129
+ try {
130
+ const me = await actingAccount(req.builder);
131
+ if (me.fail) return res.fail(...me.fail);
132
+ const target = await visibleTarget(me.githubId, req.params.handle);
133
+ if (!target) return res.fail('connection_target_not_found', 404);
134
+ const out = await conn.degreeBetween(me.githubId, target.github_id, { pool });
135
+ res.json({
136
+ handle: target.handle,
137
+ degree: out.degree,
138
+ mutuals: out.mutuals,
139
+ mutual_count: out.mutuals.length,
140
+ truncated: out.truncated,
141
+ });
142
+ } catch (err) {
143
+ log.error({ err: err && err.message }, 'GET /connections/degree/:handle failed');
144
+ res.fail('connection_degree_failed', { status: 500, message: 'internal error' });
145
+ }
146
+ });
147
+
148
+ // rank: any-builder — ask to connect. The REQUESTER is always the session; the
149
+ // body names only who is being asked, and it names them by public handle.
150
+ router.post('/connections', requireBuilder, async (req, res) => {
151
+ if (validateOrRespond(req, res, {
152
+ handle: { required: true, type: 'string', minLength: 1, maxLength: 39 },
153
+ note: { type: 'string', maxLength: conn.NOTE_MAX_CHARS },
154
+ })) return;
155
+ try {
156
+ const me = await actingAccount(req.builder);
157
+ if (me.fail) return res.fail(...me.fail);
158
+ const target = await visibleTarget(me.githubId, req.body.handle);
159
+ // Absent, provisional and private-stranger are ONE answer. A 403 would
160
+ // confirm the handle exists, which is the whole thing private protects.
161
+ if (!target) return res.fail('connection_target_not_found', 404);
162
+ if (String(target.github_id) === String(me.githubId)) {
163
+ return res.fail('cannot_connect_to_self', { status: 400, message: 'that is your own account' });
164
+ }
165
+ const out = await conn.requestConnection({
166
+ requesterGithubId: me.githubId,
167
+ addresseeGithubId: target.github_id,
168
+ note: req.body.note || null,
169
+ }, { pool });
170
+ if (out.outcome === 'already_open') {
171
+ return res.fail('connection_already_open', { status: 409, message: 'there is already a request or a connection between you' });
172
+ }
173
+ // 'created' and 'swallowed' answer IDENTICALLY — no id, no timestamps,
174
+ // nothing a caller could difference to learn they were declined before.
175
+ res.status(201).json({ status: 'pending' });
176
+ } catch (err) {
177
+ log.error({ err: err && err.message }, 'POST /connections failed');
178
+ res.fail('connection_request_failed', { status: 500, message: 'internal error' });
179
+ }
180
+ });
181
+
182
+ // rank: any-builder — accept a request addressed to ME. The addressee is the
183
+ // session; a row belonging to anyone else simply does not match, and the 404
184
+ // that follows is what stops a builder acting on someone else's connection.
185
+ router.post('/connections/:id/accept', requireBuilder, async (req, res) => {
186
+ const id = connectionId(req);
187
+ if (!id) return res.fail('connection_request_not_found', 404);
188
+ try {
189
+ const me = await actingAccount(req.builder);
190
+ if (me.fail) return res.fail(...me.fail);
191
+ const ok = await conn.acceptConnection({ id, githubId: me.githubId }, { pool });
192
+ if (!ok) return res.fail('connection_request_not_found', 404);
193
+ res.json({ status: 'accepted' });
194
+ } catch (err) {
195
+ log.error({ err: err && err.message }, 'POST /connections/:id/accept failed');
196
+ res.fail('connection_accept_failed', { status: 500, message: 'internal error' });
197
+ }
198
+ });
199
+
200
+ // rank: any-builder — decline a request addressed to ME. 204: the requester is
201
+ // never told, so there is nothing to return that they could ever observe.
202
+ router.post('/connections/:id/decline', requireBuilder, async (req, res) => {
203
+ const id = connectionId(req);
204
+ if (!id) return res.fail('connection_request_not_found', 404);
205
+ try {
206
+ const me = await actingAccount(req.builder);
207
+ if (me.fail) return res.fail(...me.fail);
208
+ const ok = await conn.declineConnection({ id, githubId: me.githubId }, { pool });
209
+ if (!ok) return res.fail('connection_request_not_found', 404);
210
+ res.status(204).end();
211
+ } catch (err) {
212
+ log.error({ err: err && err.message }, 'POST /connections/:id/decline failed');
213
+ res.fail('connection_decline_failed', { status: 500, message: 'internal error' });
214
+ }
215
+ });
216
+
217
+ // rank: any-builder — remove an accepted connection (either side may end it)
218
+ // or cancel a pending request I sent. An ADDRESSEE ending a pending row is a
219
+ // decline, not a delete, and is 404'd here so the cooldown cannot be dodged.
220
+ router.delete('/connections/:id', requireBuilder, async (req, res) => {
221
+ const id = connectionId(req);
222
+ if (!id) return res.fail('connection_not_found', 404);
223
+ try {
224
+ const me = await actingAccount(req.builder);
225
+ if (me.fail) return res.fail(...me.fail);
226
+ const outcome = await conn.removeConnection({ id, githubId: me.githubId }, { pool });
227
+ if (!outcome) return res.fail('connection_not_found', 404);
228
+ res.json({ outcome });
229
+ } catch (err) {
230
+ log.error({ err: err && err.message }, 'DELETE /connections/:id failed');
231
+ res.fail('connection_remove_failed', { status: 500, message: 'internal error' });
232
+ }
233
+ });
234
+
235
+ return router;
236
+ };
@@ -406,6 +406,26 @@ await ta('registerClient auto-catalogues the project (2nd query) when an origin
406
406
  assert.match(pool.calls[1].text, /INSERT INTO platform_identity_projects/);
407
407
  });
408
408
 
409
+ await ta('registerClient forwards the project-detail description into the catalog row (task 1002334)', async () => {
410
+ // Half the publish gate (ADR 0182 D4). It is given on the platform before this
411
+ // row exists, so if this hop drops it a project that DID describe itself sits
412
+ // in dark matter and the optional step is decorative.
413
+ const pool = mockPool([{ rows: [{ client_id: 'c1', origin: 'https://p' }] }, { rows: [{ id: '9' }] }]);
414
+ await P.registerClient(
415
+ { clientId: 'c1', secret: 'z'.repeat(64), name: 'Proj', redirectUris: [], origin: 'https://p', ownerGithubId: 7, description: 'A weather sim.' },
416
+ { pool },
417
+ );
418
+ assert.ok(pool.calls[1].params.includes('A weather sim.'), 'the description reaches the catalog upsert');
419
+ });
420
+
421
+ await ta('registerClient with a SKIPPED interview passes no description — and COALESCE nulls nothing', async () => {
422
+ const pool = mockPool([{ rows: [{ client_id: 'c1', origin: 'https://p' }] }, { rows: [{ id: '9' }] }]);
423
+ await P.registerClient({ clientId: 'c1', secret: 'z'.repeat(64), name: 'Proj', redirectUris: [], origin: 'https://p' }, { pool });
424
+ assert.equal(pool.calls[1].params[3], null, 'absent travels as null');
425
+ assert.match(pool.calls[1].text, /description = COALESCE\(EXCLUDED\.description/,
426
+ 'so a re-registration can never erase a description the owner added later');
427
+ });
428
+
409
429
  console.log('\nproject memberships (§3 check-in directory):');
410
430
 
411
431
  await ta('recordFederatedSignin is the ONE path that creates a membership, and stamps the witness', async () => {
@@ -0,0 +1,54 @@
1
+ -- provisioning_013_project_detail.sql — the optional project-detail interview's
2
+ -- storage (BV1.R15, task 1002334; goal 1000046 criterion `proj-detail-interview`).
3
+ --
4
+ -- The creation sequence gains ONE optional, skippable step — "tell us about your
5
+ -- project" — and its answers have to live somewhere real the moment they are
6
+ -- given. A standalone project never federates into the hub catalog at create
7
+ -- time, so this mirrors exactly what provisioning_007 did for the declared
8
+ -- `type`: the answer rides the provisioning request, and the control-plane
9
+ -- runner carries it into platform_identity_projects whenever a catalog row
10
+ -- materializes.
11
+ --
12
+ -- TWO columns, deliberately not one:
13
+ --
14
+ -- description a REAL column, because it is half of the publish gate (ADR 0182
15
+ -- D4: publishable iff name AND description are both present). It
16
+ -- mirrors platform_identity_projects.description, which
17
+ -- platform_identity_011 added for the same reason. NOT `tagline` —
18
+ -- ADR 0182 refuses to collapse them, since a description is the
19
+ -- publish bar and editing your card must never unpublish you.
20
+ --
21
+ -- detail jsonb, because the interview grows one answer at a time as R17
22
+ -- (rule-based recommendations), R19 (starter bundles) and R27
23
+ -- (invites) land, and a new question must not cost a migration
24
+ -- each time. Same contract as `settings` (provisioning_009): the
25
+ -- column is a dumb store, the known keys + their vocabularies are
26
+ -- enforced in code (provisioning.PROJECT_DETAIL_VOCAB +
27
+ -- normalizeProjectDetail), and an unknown or off-vocabulary stored
28
+ -- value is dropped at read time rather than leaking out.
29
+ --
30
+ -- SKIPPING IS THE DEFAULT, not a failure: both columns are nullable/empty, and an
31
+ -- empty description is precisely what leaves a project dark-matter until its
32
+ -- owner fills it in (ADR 0182 D4 — the bar exists to keep empty husks off the
33
+ -- map, not to enforce completeness). Nothing here changes live behaviour; R16
34
+ -- (task 1002335) is the surface that computes publishability and names what is
35
+ -- missing.
36
+ --
37
+ -- Additive + provisioning_-namespaced (ADR 0083 §5): ADD COLUMN only, so the
38
+ -- previous code release still runs against this schema. Idempotent: safe to re-run.
39
+
40
+ BEGIN;
41
+
42
+ ALTER TABLE provisioning_instances
43
+ ADD COLUMN IF NOT EXISTS description text;
44
+
45
+ ALTER TABLE provisioning_instances
46
+ ADD COLUMN IF NOT EXISTS detail jsonb NOT NULL DEFAULT '{}'::jsonb;
47
+
48
+ COMMENT ON COLUMN provisioning_instances.description IS
49
+ 'What the owner said they are building — the answer to the optional project-detail step (BV1.R15). Half of the publish gate (ADR 0182 D4: publishable iff name AND description are present), mirrored into platform_identity_projects.description when a catalog row materializes. NULL = the step was skipped, which is a supported default, not a failure: the project stays dark-matter until it is filled in.';
50
+
51
+ COMMENT ON COLUMN provisioning_instances.detail IS
52
+ 'The rest of the optional project-detail interview, as a dumb jsonb store (the `settings` contract from provisioning_009). Known keys + vocabularies live in provisioning.PROJECT_DETAIL_VOCAB and are enforced at the API layer; an unknown or off-vocabulary stored value is dropped at read time. jsonb so the interview can grow an answer at a time (R17 recommendations, R19 bundles, R27 invites) without a migration each time. {} = skipped.';
53
+
54
+ COMMIT;
@@ -9,6 +9,15 @@
9
9
  //
10
10
  // PURE — no I/O, no core imports. Safe to require from a script, a route, or a test.
11
11
  //
12
+ // `optional: true` (task 1002334) marks a step every form must OFFER and every
13
+ // owner may SKIP — today just `project-detail`, the "tell us about your project"
14
+ // interview. It is a real step in the sequence rather than a wizard-only flourish
15
+ // because all four forms have to ask it the same way; skipping is a supported
16
+ // default that leaves the project dark-matter (off the map, un-joinable) until its
17
+ // description is filled in later, never a dead end (ADR 0182 D4). A form that can
18
+ // only render a linear checklist may treat an optional step as any other; one that
19
+ // takes answers must let the owner past it without one.
20
+ //
12
21
  // Each step: { key, kind: 'auto' | 'human', label }. `kind: 'human'` marks the
13
22
  // irreducibly-manual owner actions (create the GitHub repo, wire GitHub sign-in, first
14
23
  // sign-in, enter the hall) that every form renders UI-first; `kind: 'auto'` steps the
@@ -36,6 +45,10 @@ function onboardPlan(mode = 'greenfield', opts = {}) {
36
45
  const domain = typeof opts.domain === 'string' && opts.domain.trim() ? opts.domain.trim() : null;
37
46
  return [
38
47
  { key: 'name-address', kind: 'auto', label: 'Decide name + address (interview)' },
48
+ // 'auto' like name-address above, and for the same reason: the form itself
49
+ // collects it, so it is not one of the irreducibly-manual legs a done panel
50
+ // lists. `optional` is what makes it skippable — see the header.
51
+ { key: 'project-detail', kind: 'auto', optional: true, label: 'Tell us about your project — optional, skip it and add it later' },
39
52
  { key: 'github-repo', kind: 'human', label: adopt ? 'Use your EXISTING GitHub repository' : 'Create the empty GitHub repository' },
40
53
  { key: 'dns-preflight', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'DNS token preflight — before the address step (task 1980)' },
41
54
  { key: 'init', kind: 'auto', label: adopt ? 'bongos init --adopt — LAYER onto the existing repo (pre-flight + accept)' : 'bongos init — scaffold the instance config' },
@@ -124,6 +124,34 @@ const SETTINGS_VOCAB = Object.freeze({
124
124
  });
125
125
  const DEFAULT_SETTINGS = Object.freeze({ platform_visibility: 'public', joinability: 'apply' });
126
126
 
127
+ // The optional project-detail interview (BV1.R15, task 1002334) — the "tell us
128
+ // about your project" step every creation form offers and every owner may skip.
129
+ // The interview asks two things, and the vocabulary is the contract for the
130
+ // closed-answer half:
131
+ //
132
+ // team_shape who is going to build it — the input R17 (task 1002336) maps to a
133
+ // recommended governance preset and to invite suggestions.
134
+ //
135
+ // (The module bundle R19 recommends comes off the separately-declared `type`, so
136
+ // the interview deliberately does not ask it a second time.)
137
+ //
138
+ // The free-text half of the interview is NOT here: `description` is a real column
139
+ // (migration provisioning_013) because it is half of the publish gate (ADR 0182
140
+ // D4), while these are jsonb so a later question costs no migration — same dumb-
141
+ // store contract as SETTINGS_VOCAB above.
142
+ //
143
+ // There is deliberately NO default. Settings describe a policy the instance must
144
+ // obey, so an absent one has to resolve to today's behaviour; a detail answer is
145
+ // something the owner SAID, and inventing one they never said would feed R17 a
146
+ // recommendation nobody asked for. Absent stays absent, and skipped stays skipped.
147
+ const PROJECT_DETAIL_VOCAB = Object.freeze({
148
+ team_shape: ['solo', 'small-team', 'community'],
149
+ });
150
+ // The publish-gate half of the interview is prose, so it needs a ceiling. Long
151
+ // enough for a real paragraph, short enough that the column is never a document —
152
+ // the Explore card renders `tagline`, not this.
153
+ const DESCRIPTION_MAX = 600;
154
+
127
155
  // Monthly cost estimate (USD) for a dedicated droplet tier — VISIBILITY only
128
156
  // (ADR 0111 §6), never a bill. A small local table so this module needs no
129
157
  // sibling-module import; P2 (shared control-plane client extraction) is where a
@@ -257,6 +285,14 @@ function publicInstance(row) {
257
285
  // instances so a declaration shows before the catalog carries it. Nullable:
258
286
  // rows predating provisioning_007, or a declined answer, project null.
259
287
  type: row.type || null,
288
+ // The optional project-detail interview (task 1002334): what the owner said
289
+ // they are building, and the answers that feed R17's recommendations. Both
290
+ // are legitimately empty — the step is skippable by design — and `description`
291
+ // being null is exactly what keeps the project dark-matter until it is filled
292
+ // in (ADR 0182 D4). Surfaced so a form can render "here's what's still
293
+ // missing" from a plain instance read, without a second endpoint.
294
+ description: row.description || null,
295
+ detail: projectDetail(row),
260
296
  // The verification overlay (task 1002696, redesign direction §3): what the
261
297
  // heartbeat last OBSERVED, which is NOT what `status` claims. null = never
262
298
  // probed — a different claim from last_verified_up=false, and the UI renders
@@ -330,6 +366,40 @@ function effectiveSettings(row) {
330
366
  return out;
331
367
  }
332
368
 
369
+ // A description as it may be STORED: trimmed, collapsed of runaway whitespace,
370
+ // capped, and empty-means-absent. Returns null for anything that isn't real prose,
371
+ // because null is what leaves the project dark-matter — an empty string stored in a
372
+ // nullable column would look answered to `description IS NOT NULL` and publish an
373
+ // husk, which is the one thing the gate exists to prevent (ADR 0182 D4). PURE.
374
+ function normalizeDescription(input) {
375
+ if (typeof input !== 'string') return null;
376
+ const text = input.replace(/\s+/g, ' ').trim();
377
+ if (!text) return null;
378
+ return text.slice(0, DESCRIPTION_MAX);
379
+ }
380
+
381
+ // The detail answers as they may be STORED: on-vocabulary keys only, everything
382
+ // else dropped. An off-vocabulary value is dropped rather than defaulted — a
383
+ // recommendation built on an answer the owner never gave is worse than no
384
+ // recommendation (see PROJECT_DETAIL_VOCAB). Always returns a plain object, so a
385
+ // skipped interview stores `{}` rather than null. PURE.
386
+ function normalizeProjectDetail(input) {
387
+ const given = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
388
+ const out = {};
389
+ for (const [key, vocab] of Object.entries(PROJECT_DETAIL_VOCAB)) {
390
+ if (vocab.includes(given[key])) out[key] = given[key];
391
+ }
392
+ return out;
393
+ }
394
+
395
+ // What a row's detail answers READ as — the stored object filtered through the
396
+ // same vocabulary, so a value that was valid when written and has since left the
397
+ // vocabulary never leaks out of the read surface. Mirrors effectiveSettings, minus
398
+ // the defaults it deliberately has none of. PURE.
399
+ function projectDetail(row) {
400
+ return normalizeProjectDetail(row && row.detail);
401
+ }
402
+
333
403
  // ---------------------------------------------------------------------------
334
404
  // GitHub App Manifest flow — PURE builders (task 2080, ADR 0133).
335
405
  // GitHub has no create-an-OAuth-App API, but the App *Manifest* flow lets the owner
@@ -479,6 +549,35 @@ async function updateInstanceSettings(db, id, patch) {
479
549
  return rows[0] || null;
480
550
  }
481
551
 
552
+ // Fill in (or change) the project-detail answers AFTER creation — the other half
553
+ // of "skippable" (task 1002334). Skipping the step at creation is a supported
554
+ // default, but it is only a default and not a dead end if there is a way back; this
555
+ // is that way back, and it is what turns a dark-matter project publishable once its
556
+ // owner writes a description (ADR 0182 D4).
557
+ //
558
+ // `detail` MERGES (the settings `||` precedent — a form may send one answer without
559
+ // resending the rest) while `description` REPLACES, because prose has no keys to
560
+ // merge. Passing description: null clears it — and un-publishes the project, which
561
+ // is the honest consequence of deleting half the identity gate, not a bug.
562
+ // Undefined leaves the column alone.
563
+ async function updateInstanceDetail(db, id, { description, detail } = {}) {
564
+ const sets = ['updated_at = now()'];
565
+ const params = [id];
566
+ if (description !== undefined) {
567
+ params.push(description);
568
+ sets.push(`description = $${params.length}`);
569
+ }
570
+ if (detail !== undefined) {
571
+ params.push(JSON.stringify(normalizeProjectDetail(detail)));
572
+ sets.push(`detail = detail || $${params.length}::jsonb`);
573
+ }
574
+ const { rows } = await db.query(
575
+ `UPDATE provisioning_instances SET ${sets.join(', ')} WHERE id = $1 RETURNING *`,
576
+ params
577
+ );
578
+ return rows[0] || null;
579
+ }
580
+
482
581
  // Record an instance's domain — the ONLY columns this touches (task 1002692;
483
582
  // `releasing` added by task 1003237). Status changes stay setInstanceStatus's job;
484
583
  // keeping this narrow means the domain-attach route can never drift an instance's
@@ -630,7 +729,7 @@ async function listAllInstances(db, { status } = {}) {
630
729
  // re-fetches the existing row and returns { created:false } so the ROUTE can
631
730
  // decide (same owner → idempotent re-request; different owner → 409). Never
632
731
  // silently reassigns ownership.
633
- async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type }) {
732
+ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type, description, detail }) {
634
733
  const shape = HOSTING_SHAPES.includes(hostingShape) ? hostingShape : 'co-tenant';
635
734
  // Unknown/absent → 'greenfield', matching the column default. Falling back to the
636
735
  // SAFE mode matters: greenfield refuses to push over existing history (no --force
@@ -639,15 +738,22 @@ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShap
639
738
  const mode = ONBOARD_MODES.includes(onboardMode) ? onboardMode : 'greenfield';
640
739
  // Type is a declaration — off-vocabulary or absent stores null, never a guess.
641
740
  const declaredType = PROJECT_TYPES.includes(type) ? type : null;
741
+ // The optional project-detail step (task 1002334). Skipping it is the SUPPORTED
742
+ // default, so both of these legitimately land empty — a null description is what
743
+ // leaves the project dark-matter until its owner fills it in (ADR 0182 D4), and
744
+ // updateInstanceDetail is the way back.
745
+ const told = normalizeDescription(description);
746
+ const answers = normalizeProjectDetail(detail);
642
747
  const cost = costEstimateUsd({ hostingShape: shape, tier });
643
748
  try {
644
749
  const { rows } = await db.query(
645
750
  `INSERT INTO provisioning_instances
646
- (owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode, type)
647
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
751
+ (owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode, type, description, detail)
752
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)
648
753
  RETURNING *`,
649
754
  [ownerBuilderId, slug, targetRef ?? null, shape, domain ?? null,
650
- dbName ?? null, port ?? null, tier ?? null, cost, mode, declaredType]
755
+ dbName ?? null, port ?? null, tier ?? null, cost, mode, declaredType,
756
+ told, JSON.stringify(answers)]
651
757
  );
652
758
  return { instance: rows[0], created: true };
653
759
  } catch (e) {
@@ -959,10 +1065,11 @@ module.exports = {
959
1065
  SETTINGS_APPLY_FROM, SETTINGS_APPLY_SHAPES,
960
1066
  RESTART_FROM, RESTART_SHAPES,
961
1067
  TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
962
- SETTINGS_VOCAB, DEFAULT_SETTINGS,
1068
+ SETTINGS_VOCAB, DEFAULT_SETTINGS, PROJECT_DETAIL_VOCAB, DESCRIPTION_MAX,
963
1069
  // pure
964
1070
  isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
965
1071
  defaultDomainForSlug, isReservedHostname, effectiveSettings,
1072
+ normalizeDescription, normalizeProjectDetail, projectDetail,
966
1073
  // pure — GitHub App Manifest flow (task 2080)
967
1074
  githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
968
1075
  // data access
@@ -970,6 +1077,7 @@ module.exports = {
970
1077
  getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
971
1078
  // data access — verification overlay (task 1002696)
972
1079
  listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
1080
+ createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
973
1081
  createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
974
1082
  enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
975
1083
  recordEvent, instanceStateForSlug, costLedger,