@bongos/core 1.19.429 → 1.19.431

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 +71 -51
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +6 -3
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md +116 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +74 -3
  10. package/docs/api-reference.md +4 -3
  11. package/docs/copy-inventory.md +162 -154
  12. package/docs/copy-registry.json +271 -198
  13. package/docs/design/mocks/hub/picker.html +421 -0
  14. package/docs/design/projects-hub-direction-v2.md +22 -0
  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 +2 -0
  18. package/modules/hall-ui/public/settings.html +1 -1
  19. package/modules/hall-ui/public/settings.js +17 -8
  20. package/modules/platform-identity/account-visibility.js +47 -0
  21. package/modules/platform-identity/platform-identity.js +4 -4
  22. package/modules/platform-identity/recruiter-sliver.js +197 -0
  23. package/modules/platform-identity/routes/projects.js +24 -0
  24. package/modules/platform-identity/routes/scouting.js +58 -0
  25. package/modules/provisioning/module.json +1 -1
  26. package/modules/provisioning/provisioning.js +63 -2
  27. package/modules/provisioning/routes/provisioning.js +28 -6
  28. package/modules/provisioning/tests/provisioning.mjs +34 -0
  29. package/modules/ui-design/kit/fixtures/featured-hub.json +1 -1
  30. package/modules/ui-design/kit/fixtures/featured.json +1 -1
  31. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
  32. package/modules/ui-design/kit/serve.js +28 -4
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/public-landing/assets/cosmos.css +37 -0
  36. package/public-landing/projects.html +164 -5
  37. package/public-landing/projects.probes.json +12 -0
  38. package/public-landing/projects.states.json +3 -2
  39. package/src/module-api.js +1 -1
  40. package/tests/account_privacy_flags.mjs +115 -9
  41. package/tests/effective_visibility_predicate.mjs +58 -1
  42. package/tests/government_require_permission.mjs +22 -0
  43. package/tests/profile_route.mjs +6 -0
  44. package/tests/projects_hub.mjs +122 -7
  45. package/tests/provisioning_settings.mjs +81 -2
  46. package/tests/provisioning_settings_env.mjs +6 -0
  47. package/tests/recruiter_sliver_boundary.mjs +841 -0
  48. package/tests/scouting_owner_access_and_reach.mjs +4 -0
@@ -34,6 +34,11 @@
34
34
  // scoutingListableSql THE DIRECTORY — numbers AND reach,
35
35
  // the ONE predicate the recruiting directory joins (ADR 0210, task
36
36
  // 1002290). Composed, never restated.
37
+ // recruiterSliverVisibleSql / isRecruiterSliverVisible THE PRE-ACCEPT
38
+ // SLIVER — consent AND reach, WITHOUT the existence axis (ADR 0215, task
39
+ // 1002968; privacy spec D3). The one predicate that deliberately does not
40
+ // floor on accountVisibleSql, because a PRIVATE, discoverable account is
41
+ // precisely its subject. Composed, never restated.
37
42
  //
38
43
  // The rollup pair was originally left to "the rollup readers" to apply. Task
39
44
  // 1002967 moved it here for the reason this file exists: there are two such
@@ -200,6 +205,46 @@ function scoutingListableSql(alias) {
200
205
  return `(${rollupVisibleSql(alias)} AND ${recruiterReachSql(alias)})`;
201
206
  }
202
207
 
208
+ // recruiterSliverVisibleSql — THE predicate the PRE-ACCEPT recruiter sliver
209
+ // joins: the CONSENT floor AND the reach axis (ADR 0215, privacy spec D3; task
210
+ // 1002968). modules/platform-identity/recruiter-sliver.js is its one reader.
211
+ //
212
+ // WHY IT FLOORS ON CONSENT AND NOT ON EXISTENCE — the one deliberate difference
213
+ // in this file. Every other public answer here ANDs accountVisibleSql, which
214
+ // carries account_visibility = 'public'. This one must not, because the account
215
+ // the D3 sliver EXISTS to serve is the private, explicitly-discoverable one —
216
+ // the privacy spec's D2 "private, discoverable" row. Flooring on the existence
217
+ // axis would empty the sliver of exactly its subject and leave it a redundant,
218
+ // poorer copy of the directory.
219
+ //
220
+ // THAT IS SAFE ONLY BECAUSE THE REACH HALF IS AN EXPLICIT OPT-IN FOR A PRIVATE
221
+ // ACCOUNT. recruiterReachSql is COALESCE(recruiter_discoverable, visibility =
222
+ // 'public'): for a private account the NULL default resolves to FALSE, so a
223
+ // private account reaches this surface only when its owner set the flag to true
224
+ // by hand. Dropping the existence axis therefore widens nothing on its own — it
225
+ // hands the decision to the account, which is what consent means.
226
+ //
227
+ // profile_state = 'active' still floors it, so a PROVISIONAL account is never
228
+ // recruiter-visible (spec D3, said out loud). And this predicate answers only
229
+ // WHO may appear; WHAT appears is the whitelist in recruiter-sliver.js, which
230
+ // shrinks to name-only under hide_stats. Never authority (ADR 0016): being
231
+ // reachable by a recruiter grants the account nothing and costs it nothing.
232
+ function recruiterSliverVisibleSql(alias) {
233
+ // Validated here as well as in both delegates, for the scoutingListableSql
234
+ // reason: a security property must not rest on template evaluation order, and
235
+ // the error should name the function the caller actually called.
236
+ if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
237
+ throw new Error('recruiterSliverVisibleSql: alias must be a bare SQL identifier');
238
+ }
239
+ return `(${accountActiveSql(alias)} AND ${recruiterReachSql(alias)})`;
240
+ }
241
+
242
+ // The JS twin of recruiterSliverVisibleSql, for a reader that already holds the
243
+ // row. Fail-closed on an absent account, like every port above it.
244
+ function isRecruiterSliverVisible(account) {
245
+ return isAccountActive(account) && isRecruiterDiscoverable(account);
246
+ }
247
+
203
248
  module.exports = {
204
249
  isAccountVisible,
205
250
  accountVisibleSql,
@@ -211,4 +256,6 @@ module.exports = {
211
256
  isRecruiterDiscoverable,
212
257
  recruiterReachSql,
213
258
  scoutingListableSql,
259
+ recruiterSliverVisibleSql,
260
+ isRecruiterSliverVisible,
214
261
  };
@@ -947,9 +947,9 @@ async function setAccountHideStats({ githubId, githubLogin, displayName, avatarU
947
947
  // touches ONLY the flags the caller sent — never identity re-sync, never
948
948
  // profile_state (activation stays /sso/authorize's alone, D3), never hide_stats
949
949
  // (which keeps its own writer, setAccountHideStats).
950
- //
951
- // recruiter_discoverable accepts null on purpose: that is the caller RESETTING
952
- // the flag to "follow visibility", not an omitted field.
950
+ // recruiter_discoverable accepts null on purpose (the caller RESETTING it to
951
+ // "follow visibility"), and GOING PRIVATE resets it too unless the PATCH names
952
+ // it — a true set while PUBLIC is not consent to be seen once private (ADR 0215).
953
953
  async function setAccountPrivacy({ githubId, githubLogin, displayName, avatarUrl, fields = {} }, { pool = defaultPool } = {}) {
954
954
  if (!isNumericGithubId(githubId)) return { error: 'no_account' };
955
955
  const setVisibility = 'account_visibility' in fields;
@@ -970,7 +970,7 @@ async function setAccountPrivacy({ githubId, githubLogin, displayName, avatarUrl
970
970
  account_visibility = CASE WHEN $7::boolean THEN EXCLUDED.account_visibility
971
971
  ELSE platform_identity_accounts.account_visibility END,
972
972
  recruiter_discoverable = CASE WHEN $8::boolean THEN EXCLUDED.recruiter_discoverable
973
- ELSE platform_identity_accounts.recruiter_discoverable END,
973
+ WHEN $7::boolean AND EXCLUDED.account_visibility = 'private' THEN NULL ELSE platform_identity_accounts.recruiter_discoverable END,
974
974
  updated_at = now()
975
975
  RETURNING github_id, github_login, profile_state, hide_stats,
976
976
  account_visibility, recruiter_discoverable, handle`,
@@ -0,0 +1,197 @@
1
+ // modules/platform-identity/recruiter-sliver.js — THE PRE-ACCEPT BOUNDARY: what
2
+ // a recruiter may see of a builder BEFORE that builder has accepted anything
3
+ // (privacy spec D3, owner-signed 2026-08-16; ADR 0215, task 1002968).
4
+ //
5
+ // WHAT IT IS. One bounded projection — handle, display name, avatar, the
6
+ // disciplines union, and a COARSE "active in the last 30 days" boolean — and
7
+ // nothing else. It exists so a recruiter has enough to decide whether to reach
8
+ // out, and so a builder who has decided nothing yet gives up no more than that.
9
+ // Under `hide_stats` the last two drop (they are rollup-derived) and the sliver
10
+ // is NAME-ONLY, exactly as the Settings toggle promises.
11
+ //
12
+ // NEVER IN IT, and each absence is load-bearing rather than an oversight:
13
+ // credits / works shipped / karma — the numbers are the post-accept record.
14
+ // project names / project count — "the aggregate is not the leak, the name
15
+ // is" (ADR 0171 D6), and a count beside a
16
+ // hidden total is a leak by subtraction,
17
+ // so there is no count either.
18
+ // last_active as a TIMESTAMP — the signal is coarse ON PURPOSE; a date
19
+ // is a behavioural trace, a boolean is a
20
+ // yes/no. Only the boolean crosses.
21
+ // github_id / github_login as FIELDS — neither is a key of the sliver. Note
22
+ // the LIMIT, stated rather than glossed:
23
+ // the avatar D3 grants is GitHub's own URL
24
+ // and embeds the account id verbatim, so
25
+ // granting the avatar grants the id inside
26
+ // it. Recorded in ADR 0215's honest limits
27
+ // and asserted precisely in the proof (the
28
+ // id may ride the avatar and nothing else).
29
+ // a profile-page link — spec D3 forbids it outright. The handle
30
+ // is an ADDRESS, not a door: for a private
31
+ // account GET /profiles/:handle joins
32
+ // accountVisibleSql and answers 404, so
33
+ // handing a recruiter the handle hands
34
+ // them no page. That is the whole meaning
35
+ // of "pre-accept": the sliver is the
36
+ // ceiling, not a step toward the rollup.
37
+ //
38
+ // WHY ITS OWN FILE. The spec says "a port beside getPublicProfileExtras", and
39
+ // platform-identity.js sits ON the 1500-line ratchet with no headroom — the
40
+ // account-visibility.js / scouting-authz.js / application-echo.js precedent.
41
+ // Beside in the module, not in the file.
42
+ //
43
+ // WHO MAY READ IT is not this file's question — routes/scouting.js gates the one
44
+ // surface, on the same door the recruiting directory uses. WHO MAY APPEAR IN IT
45
+ // is enforced HERE, in the query, by the shared recruiterSliverVisibleSql:
46
+ // consent (profile_state='active' — a provisional account is never
47
+ // recruiter-visible) AND recruiting reach. Deliberately NOT the existence axis:
48
+ // the private, explicitly-discoverable account is the row D3 exists to serve,
49
+ // and reach for a private account is an opt-IN the owner had to set by hand.
50
+ //
51
+ // NOT AUTHORITY (ADR 0016). Appearing here confers nothing on the builder and
52
+ // buys the recruiter nothing but a name to write to; nothing in this repo reads
53
+ // the sliver to decide anything. The rollup it is derived from is descriptive
54
+ // analytics, and a projection of descriptive data is still descriptive.
55
+ //
56
+ // Fail-closed everywhere: an absent row, an unreachable account, a non-numeric
57
+ // principal and an unusable handle all answer the SAME null, so a caller cannot
58
+ // tell "no such builder" from "not discoverable" (the ADR 0171 D4 rule).
59
+ //
60
+ // tests/recruiter_sliver_boundary.mjs is the proof — a whitelist over the exact
61
+ // key set, planted sentinels asserted absent from the whole response body, and
62
+ // the 404 that shows the boundary holds.
63
+ 'use strict';
64
+
65
+ const pi = require('./platform-identity');
66
+ const { recruiterSliverVisibleSql } = require('./account-visibility');
67
+
68
+ // The complete, closed key set of a FULL sliver, in the order D3 names them.
69
+ // Exported because the proof asserts against this list rather than a copy of it:
70
+ // a key added to the projection and quietly added to the test's own expectation
71
+ // is the failure mode a whitelist is supposed to make impossible.
72
+ const SLIVER_KEYS = ['handle', 'display_name', 'avatar_url', 'disciplines', 'active_recently'];
73
+
74
+ // The key set that survives `hide_stats`. The other two are rollup-derived, so
75
+ // hiding the cross-project record hides them too — and they are ABSENT, not
76
+ // null: the sliver does not carry an empty box where a number used to be.
77
+ const SLIVER_KEYS_HIDDEN = ['handle', 'display_name', 'avatar_url'];
78
+
79
+ // "Recently" is 30 days (spec D3). A named constant because it is a privacy
80
+ // parameter, not a tuning knob — widening it widens what the boolean discloses.
81
+ const ACTIVE_WINDOW_DAYS = 30;
82
+
83
+ // The two columns a sliver may be addressed BY, as a closed vocabulary. The
84
+ // lookup key is the one thing interpolated into the statement below, so it may
85
+ // never come from a caller's string: a route passes one of these constants or
86
+ // nothing at all (the account-visibility.js alias-guard posture).
87
+ const BY_GITHUB_ID = 'github_id';
88
+ const BY_HANDLE = 'lower(handle)';
89
+ const ADDRESSABLE_BY = [BY_GITHUB_ID, BY_HANDLE];
90
+
91
+ // readSliverAccount(by, value, { pool }) — the ONE account read behind both
92
+ // entry points: the identity columns of an account that passes the shared
93
+ // pre-accept predicate, or null. `hide_stats` is fetched because it decides the
94
+ // SHAPE of the answer; it never crosses into one.
95
+ async function readSliverAccount(by, value, { pool }) {
96
+ if (!ADDRESSABLE_BY.includes(by)) {
97
+ throw new Error('readSliverAccount: unknown lookup column');
98
+ }
99
+ const { rows } = await pool.query(
100
+ `SELECT github_id, handle, display_name, avatar_url, hide_stats
101
+ FROM platform_identity_accounts
102
+ WHERE ${recruiterSliverVisibleSql()} AND ${by} = $1
103
+ LIMIT 1`,
104
+ [value],
105
+ );
106
+ return rows[0] || null;
107
+ }
108
+
109
+ // readSliverActivity(githubId, { pool }) — the rollup-derived half: the
110
+ // disciplines union across every project, and whether ANY of them saw activity
111
+ // inside the window. Aggregated over the UN-FANNED activity table with the
112
+ // disciplines union computed separately, mirroring getRollupForBuilder (the
113
+ // pre-GROUP-BY unnest fan-out was bug task 1002911).
114
+ //
115
+ // The comparison happens in SQL and only its BOOLEAN comes back — the timestamp
116
+ // never enters this process, so no later refactor can accidentally serialize it.
117
+ // A builder with no activity rows at all yields NULL, coerced to false by the
118
+ // caller: "not known to be active" and "not active" are the same answer here.
119
+ async function readSliverActivity(githubId, { pool }) {
120
+ const { rows } = await pool.query(
121
+ `WITH act AS (
122
+ SELECT max(a.last_active) AS last_active
123
+ FROM platform_identity_builder_project_activity a
124
+ WHERE a.github_id = $1
125
+ ), disc AS (
126
+ SELECT array_agg(DISTINCT x.d) AS disciplines
127
+ FROM (SELECT unnest(a.disciplines) AS d
128
+ FROM platform_identity_builder_project_activity a
129
+ WHERE a.github_id = $1) x
130
+ WHERE x.d IS NOT NULL
131
+ )
132
+ SELECT (act.last_active > now() - make_interval(days => $2::int)) AS active_recently,
133
+ disc.disciplines
134
+ FROM act LEFT JOIN disc ON true`,
135
+ [githubId, ACTIVE_WINDOW_DAYS],
136
+ );
137
+ return rows[0] || null;
138
+ }
139
+
140
+ // projectSliver(account, activity) — the WHITELIST, built literally. Every key
141
+ // is written out by hand; no row is ever spread, so a column added to either
142
+ // query cannot ride along into a response. `activity` is null for a hidden
143
+ // account, and the two rollup-derived keys are then absent entirely.
144
+ function projectSliver(account, activity) {
145
+ const sliver = {
146
+ handle: account.handle ?? null,
147
+ display_name: account.display_name ?? null,
148
+ avatar_url: account.avatar_url ?? null,
149
+ };
150
+ if (!activity) return sliver;
151
+ return {
152
+ ...sliver,
153
+ disciplines: Array.isArray(activity.disciplines) ? activity.disciplines : [],
154
+ active_recently: activity.active_recently === true,
155
+ };
156
+ }
157
+
158
+ // sliverFor(account, { pool }) — shared tail of both entry points: decide the
159
+ // shape, and for a hidden account DO NOT ISSUE the activity read at all. Not
160
+ // merely dropping the fields after fetching them is the point — the cheapest way
161
+ // for a future edit to leak a number is for the number to already be in hand.
162
+ async function sliverFor(account, { pool }) {
163
+ if (!account) return null;
164
+ if (account.hide_stats) return projectSliver(account, null);
165
+ return projectSliver(account, await readSliverActivity(account.github_id, { pool }));
166
+ }
167
+
168
+ // getRecruiterSliver(githubId) — THE port the spec names (D3), keyed by the hub
169
+ // account id. Null unless the account consents and is recruiter-reachable.
170
+ // A non-numeric principal (core's 'system:bfg' shape) answers null rather than
171
+ // throwing a bigint cast mid-request, like every other read in this module.
172
+ async function getRecruiterSliver(githubId, { pool } = {}) {
173
+ if (!pi.isNumericGithubId(githubId)) return null;
174
+ return sliverFor(await readSliverAccount(BY_GITHUB_ID, String(githubId), { pool }), { pool });
175
+ }
176
+
177
+ // getRecruiterSliverByHandle(handle) — the same answer, addressed the way the
178
+ // platform addresses a builder publicly (spec Decision 3). An unusable handle
179
+ // answers null without a query.
180
+ //
181
+ // A PREVIOUS handle is deliberately NOT resolved here, unlike resolveHandle: the
182
+ // public page answers 301 so a shared link survives a rename, and a recruiting
183
+ // read has no shared links to keep working. Answering 404 instead of a redirect
184
+ // also keeps this surface from confirming that a rename happened.
185
+ async function getRecruiterSliverByHandle(handle, { pool } = {}) {
186
+ const norm = pi.normalizeHandle(handle);
187
+ if (!norm) return null;
188
+ return sliverFor(await readSliverAccount(BY_HANDLE, norm, { pool }), { pool });
189
+ }
190
+
191
+ module.exports = {
192
+ SLIVER_KEYS,
193
+ SLIVER_KEYS_HIDDEN,
194
+ ACTIVE_WINDOW_DAYS,
195
+ getRecruiterSliver,
196
+ getRecruiterSliverByHandle,
197
+ };
@@ -17,6 +17,12 @@ const pi = require('../platform-identity');
17
17
  const projectStats = require('../project-stats');
18
18
 
19
19
  const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet } = api;
20
+ const log = api.logger('platform-identity');
21
+
22
+ // a catalog origin's host, the way the hub's sky joins it to an owned instance
23
+ function hostOfOrigin(origin) {
24
+ try { return new URL(String(origin)).hostname.replace(/^www\./, '').toLowerCase(); } catch { return ''; }
25
+ }
20
26
 
21
27
  module.exports = function projectsRoutes() {
22
28
  const router = express.Router();
@@ -127,6 +133,23 @@ module.exports = function projectsRoutes() {
127
133
  } catch (err) {
128
134
  console.error('[platform-identity] project stats enrichment failed (non-fatal):', err.message);
129
135
  }
136
+ // The owners' planets (task 1003347): a project's owner may have chosen how
137
+ // the platform draws it. The choice lives on the provisioning row, so it
138
+ // reaches this public feed through the `provisioning.projectPlanets` port —
139
+ // optional, because an instance without that module has no choices to
140
+ // carry — host-matched, the join the sky makes. Best-effort like the stats:
141
+ // a failed read leaves every project on the platform's own pick.
142
+ let planets = {};
143
+ try {
144
+ planets = await api.resolveOptional('provisioning.projectPlanets', async () => ({}))(projects.map((p) => hostOfOrigin(p.origin)));
145
+ } catch (err) {
146
+ log.warn('project planets enrichment failed (non-fatal):', err.message);
147
+ }
148
+ // the wire always carries both keys: null = the platform's own pick
149
+ const planetOf = (p) => {
150
+ const c = (planets && planets[hostOfOrigin(p.origin)]) || {};
151
+ return { planet_template: c.planet_template || null, planet_accent: c.planet_accent || null };
152
+ };
130
153
  // The enrichment's keys (builders, tags, owner, owner_avatar, faces,
131
154
  // shipped_30d, shipped_30d_capped, hall_origin) never collide with the
132
155
  // catalog's; a project that did not answer carries the ONE shared null
@@ -137,6 +160,7 @@ module.exports = function projectsRoutes() {
137
160
  ...p,
138
161
  art_url: projectStats.httpUrl(p.art_url, 2048),
139
162
  ...(stats[p.origin] || projectStats.NULL_STATS),
163
+ ...planetOf(p),
140
164
  })),
141
165
  });
142
166
  } catch (err) {
@@ -16,6 +16,19 @@
16
16
  // surface honors both the hide flag and the recruiting opt-out by construction,
17
17
  // and widening WHO may read it could not widen WHAT is shown.
18
18
  //
19
+ // TWO READS, TWO AUDIENCES — and the difference is the point (ADR 0215 D4, task
20
+ // 1002968). `GET /scouting` is the DIRECTORY: the full rollup for builders who
21
+ // are publicly visible, unhidden and reachable, read by a `project.curate` holder
22
+ // OR a project owner. `GET /scouting/:handle` is the PRE-ACCEPT SLIVER: a bounded
23
+ // projection (name, avatar, disciplines, a coarse activity boolean) of ONE
24
+ // builder — including the private-but-discoverable account the directory
25
+ // deliberately omits — and it is gated at the ARCHON floor, because it is the
26
+ // first surface here that discloses a private account and privacy spec D3 pins it
27
+ // to an archon-floor atom that has not been minted. Sharing one gate would have
28
+ // been tidier; it would also have widened an owner-signed privacy decision by
29
+ // implementation convenience. The blocker on the route below asks the owner
30
+ // whether the wider recruiting audience is authorized.
31
+ //
19
32
  // Reaches core only through the doorway (ADR 0083).
20
33
  'use strict';
21
34
 
@@ -23,6 +36,7 @@ const express = require('express');
23
36
  const api = require('../../../src/module-api');
24
37
  const pi = require('../platform-identity');
25
38
  const scoutingAuthz = require('../scouting-authz');
39
+ const recruiterSliver = require('../recruiter-sliver');
26
40
 
27
41
  const { pool, requireBuilder, parsePagination } = api;
28
42
 
@@ -64,6 +78,10 @@ module.exports = function scoutingRoutes() {
64
78
  const { limit } = parsePagination(req, { defaultLimit: 100, maxLimit: 500 });
65
79
  try {
66
80
  const builders = await pi.listScoutingRollup({ limit, pool });
81
+ // Same rollup-derived data as the sliver, so the same rule: hide and the
82
+ // recruiting opt-out must beat any cache (ADR 0171 D5). This route
83
+ // predates that posture and was the only rollup read still missing it.
84
+ res.set('Cache-Control', 'no-store');
67
85
  res.json({ builders });
68
86
  } catch (err) {
69
87
  log.error(`GET /scouting failed: ${err && err.message}`);
@@ -72,5 +90,45 @@ module.exports = function scoutingRoutes() {
72
90
  },
73
91
  );
74
92
 
93
+ // rank: archon — the PRE-ACCEPT SLIVER for ONE builder (privacy spec D3, ADR
94
+ // 0215 D4, task 1002968). This is the FIRST surface on the platform that
95
+ // discloses a PRIVATE account, and its audience is deliberately NOT the
96
+ // directory's.
97
+ //
98
+ // WHY A RANK GATE AND NOT THE RECRUITING DOOR ABOVE. Spec D3 gates this read on
99
+ // `project.recruit`, an atom whose declared shape is FLOOR ARCHON, widenable
100
+ // per instance (spec D1). That atom does not exist — ADR 0201 D3 and ADR 0210
101
+ // both refuse to mint it outside the project-admin-console wave that defines it
102
+ // (spike 1002786 R3), and this task does not either. The directory's door
103
+ // (`project.curate` at metic, OR any project owner — ADR 0210 D2) is WIDER than
104
+ // the signed floor on both halves, so adopting it here would widen an
105
+ // owner-signed privacy decision by implementation convenience. Until the atom
106
+ // lands, the honest reading of D3 is its floor, held by rank. Whether the wider
107
+ // recruiting audience is authorized for this surface is the owner's call and is
108
+ // filed as a blocker, NOT resolved here; when it is answered, this one line
109
+ // moves and nothing else does.
110
+ //
111
+ // 404, never 403, for a builder who is not recruiter-reachable: a 403 would
112
+ // confirm the handle exists, which is the disclosure the opt-out removes. The
113
+ // sliver's own predicate does the deciding, inside the module.
114
+ router.get(
115
+ '/scouting/:handle',
116
+ requireBuilder,
117
+ api.requireRank('archon'),
118
+ async (req, res) => {
119
+ try {
120
+ const sliver = await recruiterSliver.getRecruiterSliverByHandle(req.params.handle, { pool });
121
+ // The rollup half is live-derived and hide must beat any cache
122
+ // (ADR 0171 D5) — the /profiles/:handle posture, for the same reason.
123
+ res.set('Cache-Control', 'no-store');
124
+ if (!sliver) return res.fail('builder_not_found', 404);
125
+ res.json({ sliver });
126
+ } catch (err) {
127
+ log.error(`GET /scouting/:handle failed: ${err && err.message}`);
128
+ res.fail('sliver_read_failed', { status: 500, message: 'internal error' });
129
+ }
130
+ },
131
+ );
132
+
75
133
  return router;
76
134
  };
@@ -14,6 +14,6 @@
14
14
  "migrations": true,
15
15
  "pollers": ["liveness-sweep"]
16
16
  },
17
- "provides": ["provisioning.instanceState"],
17
+ "provides": ["provisioning.instanceState", "provisioning.projectPlanets"],
18
18
  "consumes": []
19
19
  }
@@ -124,6 +124,26 @@ const SETTINGS_VOCAB = Object.freeze({
124
124
  });
125
125
  const DEFAULT_SETTINGS = Object.freeze({ platform_visibility: 'public', joinability: 'apply' });
126
126
 
127
+ // The project's PLANET (task 1003347, direction record §16): how the platform
128
+ // DRAWS it on the sky, in the owner's list and on the card — its template
129
+ // (shape × material × style, the thirty-six names platform-identity's
130
+ // PROJECT_TEMPLATES pins; mirrored here so provisioning never imports the hub's
131
+ // module, the PROJECT_TYPES precedent) and its accent (one of the world's five
132
+ // TOKEN NAMES — a name, never a colour: the Fifteen Rule). Stored in the same
133
+ // jsonb `settings` column, but deliberately NOT in SETTINGS_VOCAB: a setting is a
134
+ // policy the instance obeys (it rides web.env and a restart applies it); the
135
+ // planet is the platform's to draw, so it has no default (null = the platform's
136
+ // own pick: a curated template, else the walk), never rides the env transport,
137
+ // and never restarts anything.
138
+ const PLANET_SHAPES = ['blob', 'pair', 'bead'];
139
+ const PLANET_MATERIALS = ['frost', 'gold', 'obsidian'];
140
+ const PLANET_TEMPLATES = Object.freeze([
141
+ ...PLANET_SHAPES.flatMap((s) => ['tint', 'wash', 'chrome'].map((y) => `${s}-${y}`)),
142
+ ...PLANET_SHAPES.flatMap((s) => PLANET_MATERIALS.flatMap((m) => ['tint', 'wash', 'plain'].map((y) => `${s}-${m}-${y}`))),
143
+ ]);
144
+ const PLANET_ACCENTS = Object.freeze(['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent']);
145
+ const PLANET_VOCAB = Object.freeze({ planet_template: PLANET_TEMPLATES, planet_accent: PLANET_ACCENTS });
146
+
127
147
  // The optional project-detail interview (BV1.R15, task 1002334) — the "tell us
128
148
  // about your project" step every creation form offers and every owner may skip.
129
149
  // The interview asks two things, and the vocabulary is the contract for the
@@ -285,6 +305,10 @@ function publicInstance(row) {
285
305
  // instances so a declaration shows before the catalog carries it. Nullable:
286
306
  // rows predating provisioning_007, or a declined answer, project null.
287
307
  type: row.type || null,
308
+ // The owner's planet (task 1003347): how the platform draws this project —
309
+ // null = the platform's own pick. Public by nature (the sky shows it), so
310
+ // the list and the manage page read it off the row, no second read.
311
+ ...planetChoice(row),
288
312
  // The optional project-detail interview (task 1002334): what the owner said
289
313
  // they are building, and the answers that feed R17's recommendations. Both
290
314
  // are legitimately empty — the step is skippable by design — and `description`
@@ -366,6 +390,18 @@ function effectiveSettings(row) {
366
390
  return out;
367
391
  }
368
392
 
393
+ // The owner's planet choice as READ (task 1003347): each key its stored
394
+ // on-vocabulary value or null — unset, or a name that has since left the
395
+ // vocabulary, which never leaks. PURE; the shape is constant so a wire never
396
+ // drops a key.
397
+ function planetChoice(row) {
398
+ const stored = row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings)
399
+ ? row.settings : {};
400
+ const out = {};
401
+ for (const [key, vocab] of Object.entries(PLANET_VOCAB)) out[key] = vocab.includes(stored[key]) ? stored[key] : null;
402
+ return out;
403
+ }
404
+
369
405
  // A description as it may be STORED: trimmed, collapsed of runaway whitespace,
370
406
  // capped, and empty-means-absent. Returns null for anything that isn't real prose,
371
407
  // because null is what leaves the project dark-matter — an empty string stored in a
@@ -1033,6 +1069,30 @@ async function instanceStateForSlug(db, slug) {
1033
1069
  };
1034
1070
  }
1035
1071
 
1072
+ // The `provisioning.projectPlanets` port (task 1003347): the owners' planet
1073
+ // choices for a set of HOSTS (the catalog's origins, host-matched — the join the
1074
+ // hub's sky makes), so platform-identity's public featured feed can carry them
1075
+ // without importing this module. The newest row per host wins; a host with no
1076
+ // row, or no choice, is absent. Read-only.
1077
+ async function planetChoicesForHosts(db, hosts) {
1078
+ const wanted = [...new Set((hosts || []).map((h) => String(h || '').toLowerCase()).filter(Boolean))];
1079
+ if (!wanted.length) return {};
1080
+ const { rows } = await db.query(
1081
+ `SELECT lower(domain) AS host, settings
1082
+ FROM provisioning_instances
1083
+ WHERE domain IS NOT NULL AND lower(domain) = ANY($1)
1084
+ ORDER BY updated_at DESC`,
1085
+ [wanted],
1086
+ );
1087
+ const out = {};
1088
+ for (const r of rows) {
1089
+ if (out[r.host]) continue;
1090
+ const choice = planetChoice(r);
1091
+ if (choice.planet_template || choice.planet_accent) out[r.host] = choice;
1092
+ }
1093
+ return out;
1094
+ }
1095
+
1036
1096
  // Fleet cost ledger (Archon): per-instance monthly estimate + a total, over the
1037
1097
  // instances that still cost something (not torn_down). VISIBILITY only (ADR 0111 §6).
1038
1098
  async function costLedger(db, { limit = 500 } = {}) {
@@ -1066,9 +1126,10 @@ module.exports = {
1066
1126
  RESTART_FROM, RESTART_SHAPES,
1067
1127
  TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
1068
1128
  SETTINGS_VOCAB, DEFAULT_SETTINGS, PROJECT_DETAIL_VOCAB, DESCRIPTION_MAX,
1129
+ PLANET_TEMPLATES, PLANET_ACCENTS, PLANET_VOCAB,
1069
1130
  // pure
1070
1131
  isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
1071
- defaultDomainForSlug, isReservedHostname, effectiveSettings,
1132
+ defaultDomainForSlug, isReservedHostname, effectiveSettings, planetChoice,
1072
1133
  normalizeDescription, normalizeProjectDetail, projectDetail,
1073
1134
  // pure — GitHub App Manifest flow (task 2080)
1074
1135
  githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
@@ -1080,7 +1141,7 @@ module.exports = {
1080
1141
  createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
1081
1142
  createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
1082
1143
  enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
1083
- recordEvent, instanceStateForSlug, costLedger,
1144
+ recordEvent, instanceStateForSlug, planetChoicesForHosts, costLedger,
1084
1145
  // data access — GitHub App Manifest attempts (task 2080)
1085
1146
  createOAuthManifest, getOAuthManifestByState, attachManifestCode,
1086
1147
  getLatestOAuthManifestForInstance, claimNextOAuthManifest, resolveOAuthManifest,
@@ -253,9 +253,13 @@ function callbackPage({ ok, title, body, state, domain }) {
253
253
  // • PROVIDES port `provisioning.instanceState` (slug) → { status, … } | null.
254
254
  // Lets other code read an instance's provisioning status via
255
255
  // resolveOptional without importing this module (box.hasEverConnected pattern).
256
+ // • PROVIDES port `provisioning.projectPlanets` (hosts[]) → { host: { planet_template,
257
+ // planet_accent } } — the owners' planet choices (task 1003347), so the hub's
258
+ // public featured feed can carry them without importing this module.
256
259
  function registerProvisioningSeams() {
257
260
  if (api.hasProvider('provisioning.instanceState')) return;
258
261
  api.registerProvider('provisioning.instanceState', (slug) => provisioning.instanceStateForSlug(pool, slug));
262
+ api.registerProvider('provisioning.projectPlanets', (hosts) => provisioning.planetChoicesForHosts(pool, hosts));
259
263
  }
260
264
 
261
265
  // Project an instance row for the fleet view — the owner identity + the same
@@ -764,7 +768,10 @@ module.exports = function provisioningRoutes() {
764
768
  try {
765
769
  const inst = await provisioning.getInstanceById(pool, id);
766
770
  if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
767
- res.json({ ok: true, settings: provisioning.effectiveSettings(inst), options: provisioning.SETTINGS_VOCAB });
771
+ // `planet` rides beside `settings` (task 1003347): the same row, read by a
772
+ // different rule — no defaults, null = the platform's own pick.
773
+ res.json({ ok: true, settings: provisioning.effectiveSettings(inst), planet: provisioning.planetChoice(inst),
774
+ options: { ...provisioning.SETTINGS_VOCAB, ...provisioning.PLANET_VOCAB } });
768
775
  } catch (err) {
769
776
  log.error('[provisioning] GET /provisioning/instances/:id/settings', err);
770
777
  res.fail('settings_read_failed', { status: 500, message: 'internal error' });
@@ -792,14 +799,18 @@ module.exports = function provisioningRoutes() {
792
799
  if (validateOrRespond(req, res, {
793
800
  platform_visibility: { type: 'string', enum: provisioning.SETTINGS_VOCAB.platform_visibility },
794
801
  joinability: { type: 'string', enum: provisioning.SETTINGS_VOCAB.joinability },
802
+ // the planet (task 1003347): nullable on purpose — null hands the pick back to the platform
803
+ planet_template: { type: 'string', enum: provisioning.PLANET_VOCAB.planet_template },
804
+ planet_accent: { type: 'string', enum: provisioning.PLANET_VOCAB.planet_accent },
795
805
  })) return;
806
+ const settingKeys = [...Object.keys(provisioning.SETTINGS_VOCAB), ...Object.keys(provisioning.PLANET_VOCAB)];
796
807
  const patch = {};
797
- for (const key of Object.keys(provisioning.SETTINGS_VOCAB)) {
808
+ for (const key of settingKeys) {
798
809
  if ((req.body || {})[key] !== undefined) patch[key] = req.body[key];
799
810
  }
800
811
  if (!Object.keys(patch).length) {
801
812
  return res.fail('bad_settings', { status: 400,
802
- message: `The body carries no settings to change — send any of: ${Object.keys(provisioning.SETTINGS_VOCAB).join(', ')}.` });
813
+ message: `The body carries no settings to change — send any of: ${settingKeys.join(', ')}.` });
803
814
  }
804
815
  try {
805
816
  const inst = await provisioning.getInstanceById(pool, id);
@@ -811,8 +822,17 @@ module.exports = function provisioningRoutes() {
811
822
  // whole-form save or a retried request must not bounce a live project. The
812
823
  // one exception is the owner re-kick (the domain route's F19 shape): the same
813
824
  // values re-sent while a retired push left its error_note re-queue the push.
814
- const before = provisioning.effectiveSettings(inst);
825
+ const before = { ...provisioning.effectiveSettings(inst), ...provisioning.planetChoice(inst) };
815
826
  const changed = Object.entries(patch).some(([k, v]) => before[k] !== v);
827
+ // The planet is the platform's to draw (task 1003347): a change to it
828
+ // alone is applied the moment it is read back — the sky, the list and the
829
+ // card repaint from the row — so it queues no push and restarts nothing.
830
+ // Only a POLICY key (SETTINGS_VOCAB) reaches the instance, and only a
831
+ // policy key re-sent over a retired push's error_note re-kicks that push.
832
+ const policyKeys = Object.keys(provisioning.SETTINGS_VOCAB);
833
+ const policyTouched = policyKeys.some((k) => patch[k] !== undefined);
834
+ const policyChanged = policyKeys.some((k) => patch[k] !== undefined && before[k] !== patch[k]);
835
+ const policyResent = !!inst.error_note && policyTouched;
816
836
  const updated = changed ? await provisioning.updateInstanceSettings(pool, id, patch) : inst;
817
837
  if (changed) {
818
838
  await provisioning.recordEvent(pool, {
@@ -834,8 +854,10 @@ module.exports = function provisioningRoutes() {
834
854
  const reachable = provisioning.SETTINGS_APPLY_SHAPES.has(inst.hosting_shape);
835
855
  const receipt = (fields) => ({ reachable, ...fields });
836
856
  let push;
837
- if (!changed && !inst.error_note) {
857
+ if (!changed && !policyResent) {
838
858
  push = receipt({ queued: false, restart: false, message: 'No change — this project already has these settings.' });
859
+ } else if (!policyChanged && !policyResent) {
860
+ push = receipt({ queued: false, restart: false, message: 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.' });
839
861
  } else if (!provisioning.SETTINGS_APPLY_FROM.has(inst.status)) {
840
862
  const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
841
863
  push = receipt({ queued: false, restart: false, message: offline
@@ -867,7 +889,7 @@ module.exports = function provisioningRoutes() {
867
889
  : 'Saved. A restart is already queued for this project and will carry this change.' });
868
890
  }
869
891
  }
870
- res.json({ ok: true, saved: provisioning.effectiveSettings(updated), push });
892
+ res.json({ ok: true, saved: provisioning.effectiveSettings(updated), planet: provisioning.planetChoice(updated), push });
871
893
  } catch (err) {
872
894
  log.error('[provisioning] PATCH /provisioning/instances/:id/settings', err);
873
895
  res.fail('settings_update_failed', { status: 500, message: 'internal error' });