@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.
- package/.bongos-core.json +71 -51
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +6 -3
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md +116 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +74 -3
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +162 -154
- package/docs/copy-registry.json +271 -198
- package/docs/design/mocks/hub/picker.html +421 -0
- package/docs/design/projects-hub-direction-v2.md +22 -0
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/settings.js +17 -8
- package/modules/platform-identity/account-visibility.js +47 -0
- package/modules/platform-identity/platform-identity.js +4 -4
- package/modules/platform-identity/recruiter-sliver.js +197 -0
- package/modules/platform-identity/routes/projects.js +24 -0
- package/modules/platform-identity/routes/scouting.js +58 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +63 -2
- package/modules/provisioning/routes/provisioning.js +28 -6
- package/modules/provisioning/tests/provisioning.mjs +34 -0
- package/modules/ui-design/kit/fixtures/featured-hub.json +1 -1
- package/modules/ui-design/kit/fixtures/featured.json +1 -1
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
- package/modules/ui-design/kit/serve.js +28 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +37 -0
- package/public-landing/projects.html +164 -5
- package/public-landing/projects.probes.json +12 -0
- package/public-landing/projects.states.json +3 -2
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +115 -9
- package/tests/effective_visibility_predicate.mjs +58 -1
- package/tests/government_require_permission.mjs +22 -0
- package/tests/profile_route.mjs +6 -0
- package/tests/projects_hub.mjs +122 -7
- package/tests/provisioning_settings.mjs +81 -2
- package/tests/provisioning_settings_env.mjs +6 -0
- package/tests/recruiter_sliver_boundary.mjs +841 -0
- 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
|
-
//
|
|
952
|
-
//
|
|
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
|
};
|
|
@@ -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
|
-
|
|
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
|
|
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: ${
|
|
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 && !
|
|
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' });
|