@bongos/core 1.19.181 → 1.19.183
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 +39 -29
- 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 +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +81 -4
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/governance/protected-surfaces.json +2 -1
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_009_profile_fields.sql +38 -0
- package/modules/platform-identity/platform-identity.js +201 -3
- package/modules/platform-identity/routes/profile.js +63 -1
- package/modules/platform-identity/routes/public-profile.js +41 -2
- package/modules/platform-identity/tests/platform-identity.mjs +90 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +2 -1
- package/src/bongos/module-scope-map.js +22 -1
- package/src/bongos/route-rank-check.js +37 -18
- package/src/module-api.js +5 -1
- package/tests/governance_protected_surfaces.mjs +60 -4
- package/tests/hub_profile_leak.mjs +11 -6
- package/tests/profile_fields.mjs +133 -0
|
@@ -107,13 +107,21 @@ function redirectUriAllowed(client, redirectUri) {
|
|
|
107
107
|
// where a human is demonstrably at the hub. Every other caller (device-poll,
|
|
108
108
|
// membership check-in) leaves the fail-closed 'provisional' default; activation
|
|
109
109
|
// is one-way (a later non-activating upsert never demotes an active account).
|
|
110
|
+
//
|
|
111
|
+
// display_name is source-guarded (the ADR 0156 mirror, P02/task 1002798): once
|
|
112
|
+
// a builder sets the account name themselves (setAccountProfileFields stamps
|
|
113
|
+
// display_name_source='user'), no check-in/authorize re-sync overwrites it; a
|
|
114
|
+
// never-renamed account keeps following GitHub. github_login and avatar_url
|
|
115
|
+
// re-sync unconditionally — neither is user-editable (spec Decision 4).
|
|
110
116
|
async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, activate = false }, { pool = defaultPool } = {}) {
|
|
111
117
|
const { rows } = await pool.query(
|
|
112
118
|
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
|
|
113
119
|
VALUES ($1, $2, $3, $4, CASE WHEN $5::boolean THEN 'active' ELSE 'provisional' END)
|
|
114
120
|
ON CONFLICT (github_id) DO UPDATE SET
|
|
115
121
|
github_login = EXCLUDED.github_login,
|
|
116
|
-
display_name =
|
|
122
|
+
display_name = CASE WHEN platform_identity_accounts.display_name_source = 'user'
|
|
123
|
+
THEN platform_identity_accounts.display_name
|
|
124
|
+
ELSE EXCLUDED.display_name END,
|
|
117
125
|
avatar_url = EXCLUDED.avatar_url,
|
|
118
126
|
profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END,
|
|
119
127
|
updated_at = now()
|
|
@@ -142,7 +150,8 @@ async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
|
|
|
142
150
|
async function getAccount(githubId, { pool = defaultPool } = {}) {
|
|
143
151
|
const { rows } = await pool.query(
|
|
144
152
|
`SELECT id, github_id, github_login, display_name, avatar_url, profile_state, hide_stats,
|
|
145
|
-
handle, previous_handle, handle_changed_at,
|
|
153
|
+
handle, previous_handle, handle_changed_at, bio, links, display_name_source,
|
|
154
|
+
created_at, updated_at
|
|
146
155
|
FROM platform_identity_accounts WHERE github_id = $1`,
|
|
147
156
|
[githubId],
|
|
148
157
|
);
|
|
@@ -762,7 +771,7 @@ async function resolveHandle(handle, { pool = defaultPool } = {}) {
|
|
|
762
771
|
const norm = normalizeHandle(handle);
|
|
763
772
|
if (!norm) return null;
|
|
764
773
|
const { rows } = await pool.query(
|
|
765
|
-
`SELECT github_id, github_login, display_name, avatar_url, handle, created_at,
|
|
774
|
+
`SELECT github_id, github_login, display_name, avatar_url, handle, bio, links, created_at,
|
|
766
775
|
(lower(handle) = $1) AS is_current
|
|
767
776
|
FROM platform_identity_accounts
|
|
768
777
|
WHERE profile_state = 'active'
|
|
@@ -797,11 +806,189 @@ async function getPublicProfileByHandle(handle, { pool = defaultPool } = {}) {
|
|
|
797
806
|
display_name: a.display_name,
|
|
798
807
|
avatar_url: a.avatar_url,
|
|
799
808
|
member_since: a.created_at,
|
|
809
|
+
// P02 (spec Decision 4): plain-text bio + the typed link set. RENDER
|
|
810
|
+
// CONTRACT for any consumer: escape the bio, never linkify it, and emit
|
|
811
|
+
// links with rel="nofollow ugc noopener" (validated at write, but the
|
|
812
|
+
// page must still treat them as user content).
|
|
813
|
+
bio: a.bio ?? null,
|
|
814
|
+
links: a.links ?? null,
|
|
800
815
|
},
|
|
801
816
|
cross_project: await getPublicProfileExtras(a.github_id, { pool }),
|
|
802
817
|
};
|
|
803
818
|
}
|
|
804
819
|
|
|
820
|
+
// -------------------------------------------------------------------------
|
|
821
|
+
// Profile fields: bio + typed links + account display name (P02, task 1002798
|
|
822
|
+
// — profile-page spec Decision 4, ADR 0171 amendments R01)
|
|
823
|
+
// -------------------------------------------------------------------------
|
|
824
|
+
//
|
|
825
|
+
// The typed-and-capped shape is the anti-spam design: a public, indexable page
|
|
826
|
+
// with user-controlled links is an SEO-spam magnet, and a fixed set of four
|
|
827
|
+
// typed slots rendered rel="nofollow ugc noopener" removes the incentive
|
|
828
|
+
// without a moderation queue. Validation lives HERE, next to the data, where
|
|
829
|
+
// no route can forget it (the upsertActivity precedent) — the client is never
|
|
830
|
+
// relied on.
|
|
831
|
+
|
|
832
|
+
// ~280 chars, Twitter-bio-sized (spec Decision 4). Counted in code points so a
|
|
833
|
+
// 280-emoji bio is exactly at the cap, not over it (the display-name precedent).
|
|
834
|
+
const BIO_MAX_CHARS = 280;
|
|
835
|
+
|
|
836
|
+
// Mirrors modules/builder-settings/display-name.js MAX_LENGTH — boundary-forced
|
|
837
|
+
// duplication (the ADR 0091 §2 pattern): the module may not import another
|
|
838
|
+
// module's validator, and the two caps should not drift apart silently.
|
|
839
|
+
const DISPLAY_NAME_MAX_CHARS = 60;
|
|
840
|
+
|
|
841
|
+
// The closed link vocabulary. One slot per type ⇒ max 4 by construction.
|
|
842
|
+
const PROFILE_LINK_TYPES = ['website', 'x', 'linkedin', 'discord'];
|
|
843
|
+
|
|
844
|
+
// Typed slots are host-pinned: a link labelled "LinkedIn" that resolves
|
|
845
|
+
// anywhere else is a phishing shape, not a profile link. `website` is the one
|
|
846
|
+
// free slot (any https host). Subdomains of a pinned host are allowed.
|
|
847
|
+
const PROFILE_LINK_HOSTS = {
|
|
848
|
+
x: ['x.com', 'twitter.com'],
|
|
849
|
+
linkedin: ['linkedin.com'],
|
|
850
|
+
discord: ['discord.gg', 'discord.com'],
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
const PROFILE_LINK_MAX_CHARS = 300;
|
|
854
|
+
|
|
855
|
+
const codePointLength = (s) => Array.from(s).length;
|
|
856
|
+
|
|
857
|
+
// Bio: plain text only — NO markdown, no auto-linking (that is a RENDER
|
|
858
|
+
// contract: consumers escape + never linkify). Trimmed; empty/null clears.
|
|
859
|
+
// Newlines and tabs are fine; other control characters are not text.
|
|
860
|
+
// Returns { value } (value null = clear) or { error, ... }.
|
|
861
|
+
function validateProfileBio(raw) {
|
|
862
|
+
if (raw == null) return { value: null };
|
|
863
|
+
if (typeof raw !== 'string') return { error: 'invalid_bio', reason: 'not_a_string' };
|
|
864
|
+
const trimmed = raw.trim();
|
|
865
|
+
if (!trimmed) return { value: null };
|
|
866
|
+
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(trimmed)) {
|
|
867
|
+
return { error: 'invalid_bio', reason: 'control_characters' };
|
|
868
|
+
}
|
|
869
|
+
const length = codePointLength(trimmed);
|
|
870
|
+
if (length > BIO_MAX_CHARS) return { error: 'bio_too_long', max: BIO_MAX_CHARS, length };
|
|
871
|
+
return { value: trimmed };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function hostAllowed(hostname, allowed) {
|
|
875
|
+
return allowed.some((h) => hostname === h || hostname.endsWith('.' + h));
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// One link slot: https only — rejecting every other scheme (javascript:,
|
|
879
|
+
// data:, http:, …) — with the userinfo/host tricks closed: credentials in the
|
|
880
|
+
// URL (https://linkedin.com@evil.com parses linkedin.com as USERNAME) are
|
|
881
|
+
// refused outright, and typed slots must land on their pinned host. Returns
|
|
882
|
+
// the WHATWG-normalized href (lowercased host, canonical escaping) or an error.
|
|
883
|
+
function validateProfileLink(type, raw) {
|
|
884
|
+
if (typeof raw !== 'string' || !raw.trim()) return { error: 'invalid_link', link_type: type, reason: 'not_a_url' };
|
|
885
|
+
const trimmed = raw.trim();
|
|
886
|
+
if (trimmed.length > PROFILE_LINK_MAX_CHARS) return { error: 'invalid_link', link_type: type, reason: 'too_long' };
|
|
887
|
+
let u;
|
|
888
|
+
try { u = new URL(trimmed); } catch { return { error: 'invalid_link', link_type: type, reason: 'not_a_url' }; }
|
|
889
|
+
if (u.protocol !== 'https:') return { error: 'invalid_link', link_type: type, reason: 'https_only' };
|
|
890
|
+
if (u.username || u.password) return { error: 'invalid_link', link_type: type, reason: 'userinfo_not_allowed' };
|
|
891
|
+
if (!u.hostname || !u.hostname.includes('.')) return { error: 'invalid_link', link_type: type, reason: 'bad_host' };
|
|
892
|
+
const pinned = PROFILE_LINK_HOSTS[type];
|
|
893
|
+
if (pinned && !hostAllowed(u.hostname, pinned)) {
|
|
894
|
+
return { error: 'invalid_link', link_type: type, reason: 'wrong_host', expected_hosts: pinned };
|
|
895
|
+
}
|
|
896
|
+
return { value: u.href };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// The full links object — REPLACES the stored set wholesale (a PATCH sends the
|
|
900
|
+
// set it wants, omitting a key drops that link; null/{} clears everything).
|
|
901
|
+
// Only the closed vocabulary is accepted; a null/empty slot value is treated
|
|
902
|
+
// as "drop this slot". Returns { value } (null = clear) or the first error.
|
|
903
|
+
function validateProfileLinks(raw) {
|
|
904
|
+
if (raw == null) return { value: null };
|
|
905
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return { error: 'invalid_links', reason: 'not_an_object' };
|
|
906
|
+
const out = {};
|
|
907
|
+
for (const [type, val] of Object.entries(raw)) {
|
|
908
|
+
if (!PROFILE_LINK_TYPES.includes(type)) return { error: 'invalid_links', reason: 'unknown_link_type', link_type: type };
|
|
909
|
+
if (val == null || val === '') continue;
|
|
910
|
+
const one = validateProfileLink(type, val);
|
|
911
|
+
if (one.error) return one;
|
|
912
|
+
out[type] = one.value;
|
|
913
|
+
}
|
|
914
|
+
return { value: Object.keys(out).length ? out : null };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Account display name (spec Decision 4: the hub gets its OWN copy — hub value
|
|
918
|
+
// wins on hub surfaces, instance value on instance surfaces, no sync engine).
|
|
919
|
+
function validateAccountDisplayName(raw) {
|
|
920
|
+
if (typeof raw !== 'string') return { error: 'invalid_display_name', reason: 'not_a_string' };
|
|
921
|
+
const trimmed = raw.trim();
|
|
922
|
+
if (!trimmed) return { error: 'invalid_display_name', reason: 'empty' };
|
|
923
|
+
if (/[\u0000-\u001F\u007F]/.test(trimmed)) return { error: 'invalid_display_name', reason: 'control_characters' };
|
|
924
|
+
const length = codePointLength(trimmed);
|
|
925
|
+
if (length > DISPLAY_NAME_MAX_CHARS) return { error: 'display_name_too_long', max: DISPLAY_NAME_MAX_CHARS, length };
|
|
926
|
+
return { value: trimmed };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// The own-scoped profile-fields WRITE (P02) — callers key this by the
|
|
930
|
+
// AUTHENTICATED session's github_id, never a caller-named one. Validates here,
|
|
931
|
+
// then upserts on the setAccountHideStats pattern: the INSERT branch creates a
|
|
932
|
+
// fail-closed provisional account (a hub builder who never federated can still
|
|
933
|
+
// fill their profile ahead of time); the UPDATE branch touches ONLY the fields
|
|
934
|
+
// the caller sent — never identity re-sync, never profile_state (activation
|
|
935
|
+
// stays /sso/authorize's alone, D3), never hide_stats. Setting display_name
|
|
936
|
+
// stamps display_name_source='user' — from then on upsertAccount's re-sync
|
|
937
|
+
// keeps its hands off the name (the ADR 0156 mirror).
|
|
938
|
+
async function setAccountProfileFields({ githubId, githubLogin, displayName, avatarUrl, fields = {} }, { pool = defaultPool } = {}) {
|
|
939
|
+
if (!isNumericGithubId(githubId)) return { error: 'no_account' };
|
|
940
|
+
const setName = 'display_name' in fields;
|
|
941
|
+
const setBio = 'bio' in fields;
|
|
942
|
+
const setLinks = 'links' in fields;
|
|
943
|
+
if (!setName && !setBio && !setLinks) return { error: 'no_fields' };
|
|
944
|
+
let nameValue = null;
|
|
945
|
+
if (setName) {
|
|
946
|
+
const v = validateAccountDisplayName(fields.display_name);
|
|
947
|
+
if (v.error) return v;
|
|
948
|
+
nameValue = v.value;
|
|
949
|
+
}
|
|
950
|
+
let bioValue = null;
|
|
951
|
+
if (setBio) {
|
|
952
|
+
const v = validateProfileBio(fields.bio);
|
|
953
|
+
if (v.error) return v;
|
|
954
|
+
bioValue = v.value;
|
|
955
|
+
}
|
|
956
|
+
let linksValue = null;
|
|
957
|
+
if (setLinks) {
|
|
958
|
+
const v = validateProfileLinks(fields.links);
|
|
959
|
+
if (v.error) return v;
|
|
960
|
+
linksValue = v.value;
|
|
961
|
+
}
|
|
962
|
+
const { rows } = await pool.query(
|
|
963
|
+
`INSERT INTO platform_identity_accounts
|
|
964
|
+
(github_id, github_login, display_name, avatar_url, bio, links, display_name_source)
|
|
965
|
+
VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN $7::boolean THEN 'user' ELSE 'github' END)
|
|
966
|
+
ON CONFLICT (github_id) DO UPDATE SET
|
|
967
|
+
display_name = CASE WHEN $7::boolean THEN EXCLUDED.display_name ELSE platform_identity_accounts.display_name END,
|
|
968
|
+
display_name_source = CASE WHEN $7::boolean THEN 'user' ELSE platform_identity_accounts.display_name_source END,
|
|
969
|
+
bio = CASE WHEN $8::boolean THEN EXCLUDED.bio ELSE platform_identity_accounts.bio END,
|
|
970
|
+
links = CASE WHEN $9::boolean THEN EXCLUDED.links ELSE platform_identity_accounts.links END,
|
|
971
|
+
updated_at = now()
|
|
972
|
+
RETURNING github_id, display_name, bio, links, handle`,
|
|
973
|
+
[
|
|
974
|
+
githubId,
|
|
975
|
+
githubLogin,
|
|
976
|
+
setName ? nameValue : (displayName ?? null),
|
|
977
|
+
avatarUrl ?? null,
|
|
978
|
+
bioValue,
|
|
979
|
+
linksValue == null ? null : JSON.stringify(linksValue),
|
|
980
|
+
setName,
|
|
981
|
+
setBio,
|
|
982
|
+
setLinks,
|
|
983
|
+
],
|
|
984
|
+
);
|
|
985
|
+
const row = rows[0];
|
|
986
|
+
// This upsert can CREATE the account row — seed the handle then, same as
|
|
987
|
+
// upsertAccount / setAccountHideStats.
|
|
988
|
+
if (row && !row.handle) row.handle = await ensureHandle({ githubId, githubLogin }, { pool });
|
|
989
|
+
return row;
|
|
990
|
+
}
|
|
991
|
+
|
|
805
992
|
// -------------------------------------------------------------------------
|
|
806
993
|
// Authorization codes (single-use, short-lived, bound)
|
|
807
994
|
// -------------------------------------------------------------------------
|
|
@@ -912,6 +1099,17 @@ module.exports = {
|
|
|
912
1099
|
setAccountHandle,
|
|
913
1100
|
resolveHandle,
|
|
914
1101
|
getPublicProfileByHandle,
|
|
1102
|
+
// profile fields: bio + typed links + account display name (P02)
|
|
1103
|
+
BIO_MAX_CHARS,
|
|
1104
|
+
DISPLAY_NAME_MAX_CHARS,
|
|
1105
|
+
PROFILE_LINK_TYPES,
|
|
1106
|
+
PROFILE_LINK_HOSTS,
|
|
1107
|
+
PROFILE_LINK_MAX_CHARS,
|
|
1108
|
+
validateProfileBio,
|
|
1109
|
+
validateProfileLink,
|
|
1110
|
+
validateProfileLinks,
|
|
1111
|
+
validateAccountDisplayName,
|
|
1112
|
+
setAccountProfileFields,
|
|
915
1113
|
// codes
|
|
916
1114
|
mintAuthCode,
|
|
917
1115
|
redeemAuthCode,
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// PATCH /me/handle — rename the caller's own handle (P01, task 1002797;
|
|
17
17
|
// spec Decision 3). Low rate limit, previous handle
|
|
18
18
|
// kept for the 301 window.
|
|
19
|
+
// PATCH /me/profile — the profile-fields write (P02, task 1002798; spec
|
|
20
|
+
// Decision 4): bio + typed links + the account
|
|
21
|
+
// display name. Validation lives in the domain
|
|
22
|
+
// (https-only typed links, host pinning, 280-char
|
|
23
|
+
// plain-text bio); setting display_name stamps
|
|
24
|
+
// display_name_source='user' so federated re-syncs
|
|
25
|
+
// stop overwriting it (the ADR 0156 mirror).
|
|
19
26
|
//
|
|
20
27
|
// Reaches core only through the doorway (ADR 0083).
|
|
21
28
|
'use strict';
|
|
@@ -35,7 +42,7 @@ module.exports = function profileRoutes() {
|
|
|
35
42
|
// Core builders include system:* principals whose github_id is not numeric;
|
|
36
43
|
// they have no platform identity — answer the empty shape, not a 500.
|
|
37
44
|
if (!pi.isNumericGithubId(req.builder.github_id)) {
|
|
38
|
-
return res.json({ hide_stats: false, profile_state: null, handle: null, cross_project: null });
|
|
45
|
+
return res.json({ hide_stats: false, profile_state: null, handle: null, display_name: null, bio: null, links: null, cross_project: null });
|
|
39
46
|
}
|
|
40
47
|
const [acct, extras] = await Promise.all([
|
|
41
48
|
pi.getAccount(req.builder.github_id, { pool }),
|
|
@@ -45,6 +52,10 @@ module.exports = function profileRoutes() {
|
|
|
45
52
|
hide_stats: !!(acct && acct.hide_stats),
|
|
46
53
|
profile_state: acct ? acct.profile_state : null,
|
|
47
54
|
handle: (acct && acct.handle) || null,
|
|
55
|
+
// P02: the account-global profile fields, for the Settings form (P04).
|
|
56
|
+
display_name: (acct && acct.display_name) || null,
|
|
57
|
+
bio: (acct && acct.bio) || null,
|
|
58
|
+
links: (acct && acct.links) || null,
|
|
48
59
|
cross_project: extras,
|
|
49
60
|
});
|
|
50
61
|
} catch (err) {
|
|
@@ -119,5 +130,56 @@ module.exports = function profileRoutes() {
|
|
|
119
130
|
}
|
|
120
131
|
});
|
|
121
132
|
|
|
133
|
+
// rank: any-builder — the caller's OWN profile fields (P02, task 1002798; spec
|
|
134
|
+
// Decision 4): bio, typed links, account display name. Own data, keyed on the
|
|
135
|
+
// authenticated github_id. The schema declares the keys for strict mode; the
|
|
136
|
+
// REAL validation (bio length/charset, https-only + host-pinned links, name
|
|
137
|
+
// length) lives in the domain, next to the data, and each field is nullable
|
|
138
|
+
// (null clears), which validateOrRespond's typed rules can't express.
|
|
139
|
+
router.patch('/me/profile', requireBuilder, async (req, res) => {
|
|
140
|
+
if (validateOrRespond(req, res, { display_name: {}, bio: {}, links: {} })) return;
|
|
141
|
+
try {
|
|
142
|
+
if (!pi.isNumericGithubId(req.builder.github_id)) {
|
|
143
|
+
return res.fail('no_platform_identity', 400);
|
|
144
|
+
}
|
|
145
|
+
const fields = {};
|
|
146
|
+
for (const k of ['display_name', 'bio', 'links']) {
|
|
147
|
+
if (k in req.body) fields[k] = req.body[k];
|
|
148
|
+
}
|
|
149
|
+
const out = await pi.setAccountProfileFields({
|
|
150
|
+
githubId: req.builder.github_id,
|
|
151
|
+
githubLogin: req.builder.github_login,
|
|
152
|
+
displayName: req.builder.display_name,
|
|
153
|
+
avatarUrl: req.builder.avatar_url,
|
|
154
|
+
fields,
|
|
155
|
+
}, { pool });
|
|
156
|
+
if (out.error === 'no_fields') {
|
|
157
|
+
return res.fail('no_fields', { status: 400, message: 'send at least one of display_name, bio, links' });
|
|
158
|
+
}
|
|
159
|
+
if (out.error === 'bio_too_long') {
|
|
160
|
+
return res.fail('bio_too_long', { status: 400, message: `a bio is at most ${pi.BIO_MAX_CHARS} characters of plain text` });
|
|
161
|
+
}
|
|
162
|
+
if (out.error === 'invalid_bio') {
|
|
163
|
+
return res.fail('invalid_bio', { status: 400, message: 'a bio is plain text (no control characters)' });
|
|
164
|
+
}
|
|
165
|
+
if (out.error === 'invalid_link' || out.error === 'invalid_links') {
|
|
166
|
+
return res.fail(out.error, { status: 400, message: `links are a typed set (${pi.PROFILE_LINK_TYPES.join(', ')}), https URLs only${out.link_type ? ` — problem with '${out.link_type}'` : ''}` });
|
|
167
|
+
}
|
|
168
|
+
if (out.error === 'display_name_too_long') {
|
|
169
|
+
return res.fail('display_name_too_long', { status: 400, message: `a display name is at most ${pi.DISPLAY_NAME_MAX_CHARS} characters` });
|
|
170
|
+
}
|
|
171
|
+
if (out.error === 'invalid_display_name') {
|
|
172
|
+
return res.fail('invalid_display_name', { status: 400, message: 'a display name must be non-empty plain text' });
|
|
173
|
+
}
|
|
174
|
+
if (out.error) {
|
|
175
|
+
return res.fail('profile_write_failed', { status: 500, message: 'internal error' });
|
|
176
|
+
}
|
|
177
|
+
res.json({ display_name: out.display_name, bio: out.bio, links: out.links });
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error('[platform-identity] PATCH /me/profile', err);
|
|
180
|
+
res.fail('profile_write_failed', { status: 500, message: 'internal error' });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
122
184
|
return router;
|
|
123
185
|
};
|
|
@@ -19,6 +19,43 @@ const pi = require('../platform-identity');
|
|
|
19
19
|
|
|
20
20
|
const { pool, corsPublicGet } = api;
|
|
21
21
|
|
|
22
|
+
// Per-IP read cap for this UNAUTHENTICATED route (R05 grade advisory, task
|
|
23
|
+
// 1002798): the global limiter only meters writes, so an uncapped scraper could
|
|
24
|
+
// enumerate the handle→identity mapping at line rate. Vendored sliding window —
|
|
25
|
+
// the modules/memory readRateLimit pattern (the core /public/* limiter is not
|
|
26
|
+
// on the doorway; a kernel file may not import it). 120/60s mirrors the
|
|
27
|
+
// /public/* posture; req.ip honors 'trust proxy' so it is the end user.
|
|
28
|
+
const READ_RATE_WINDOW_MS = 60 * 1000;
|
|
29
|
+
const READ_RATE_LIMIT = 120;
|
|
30
|
+
const buckets = new Map(); // ip -> timestamp[]
|
|
31
|
+
|
|
32
|
+
const sweepTimer = setInterval(() => {
|
|
33
|
+
const cutoff = Date.now() - READ_RATE_WINDOW_MS;
|
|
34
|
+
for (const [k, arr] of buckets) {
|
|
35
|
+
while (arr.length && arr[0] < cutoff) arr.shift();
|
|
36
|
+
if (arr.length === 0) buckets.delete(k);
|
|
37
|
+
}
|
|
38
|
+
}, 5 * 60 * 1000);
|
|
39
|
+
if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
|
|
40
|
+
|
|
41
|
+
function profileReadRateLimit(req, res, next) {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const key = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
44
|
+
let bucket = buckets.get(key);
|
|
45
|
+
if (!bucket) { bucket = []; buckets.set(key, bucket); }
|
|
46
|
+
const cutoff = now - READ_RATE_WINDOW_MS;
|
|
47
|
+
while (bucket.length && bucket[0] < cutoff) bucket.shift();
|
|
48
|
+
if (bucket.length >= READ_RATE_LIMIT) {
|
|
49
|
+
const retryAfter = Math.max(1, Math.ceil((bucket[0] + READ_RATE_WINDOW_MS - now) / 1000));
|
|
50
|
+
res.set('Retry-After', String(retryAfter));
|
|
51
|
+
res.set('X-RateLimit-Scope', 'public-profile-read');
|
|
52
|
+
res.set('X-RateLimit-Limit', String(READ_RATE_LIMIT));
|
|
53
|
+
return res.fail('rate_limited', 429, { scope: 'public-profile-read', retry_after_seconds: retryAfter });
|
|
54
|
+
}
|
|
55
|
+
bucket.push(now);
|
|
56
|
+
return next();
|
|
57
|
+
}
|
|
58
|
+
|
|
22
59
|
module.exports = function publicProfileRoutes() {
|
|
23
60
|
const router = express.Router();
|
|
24
61
|
|
|
@@ -28,8 +65,10 @@ module.exports = function publicProfileRoutes() {
|
|
|
28
65
|
// world-readable on the numeric profile route + the leaderboard; the handle is
|
|
29
66
|
// just the rename-proof address (spec Decision 3). Whitelisting happens inside
|
|
30
67
|
// the module (getPublicProfileByHandle); no-store because the payload embeds
|
|
31
|
-
// cross_project and hide must beat the cache (ADR 0171 D5).
|
|
32
|
-
|
|
68
|
+
// cross_project and hide must beat the cache (ADR 0171 D5). Rate-limited like
|
|
69
|
+
// /public/* (per-IP read limiter) so the handle→identity mapping can't be
|
|
70
|
+
// scripted-enumerated at line rate (R05 grade advisory, task 1002798).
|
|
71
|
+
router.get('/profiles/:handle', profileReadRateLimit, async (req, res) => {
|
|
33
72
|
try {
|
|
34
73
|
const out = await pi.getPublicProfileByHandle(req.params.handle, { pool });
|
|
35
74
|
res.set('Cache-Control', 'no-store');
|
|
@@ -756,7 +756,7 @@ await ta('getPublicProfileByHandle: identity-band whitelist — github_id/profil
|
|
|
756
756
|
{ rows: [{ profile_state: 'active', hide_stats: true }], rowCount: 1 },
|
|
757
757
|
]);
|
|
758
758
|
const out = await P.getPublicProfileByHandle('x', { pool });
|
|
759
|
-
assert.deepEqual(Object.keys(out.account).sort(), ['avatar_url', 'display_name', 'github_login', 'handle', 'member_since'].sort());
|
|
759
|
+
assert.deepEqual(Object.keys(out.account).sort(), ['avatar_url', 'bio', 'display_name', 'github_login', 'handle', 'links', 'member_since'].sort());
|
|
760
760
|
assert.equal(out.cross_project, null, 'hidden → null, indistinguishable from none');
|
|
761
761
|
const text = JSON.stringify(out);
|
|
762
762
|
assert.doesNotMatch(text, /github_id/, 'the join key stays internal');
|
|
@@ -772,5 +772,94 @@ await ta('getPublicProfileByHandle: unknown → null; moved passes through', asy
|
|
|
772
772
|
assert.deepEqual(await P.getPublicProfileByHandle('then', { pool: pool2 }), { moved_to: 'now' });
|
|
773
773
|
});
|
|
774
774
|
|
|
775
|
+
console.log('\nprofile fields (P02, task 1002798):');
|
|
776
|
+
|
|
777
|
+
t('validateProfileBio: trims, clears on empty/null, caps at 280 code points, plain text only', () => {
|
|
778
|
+
assert.deepEqual(P.validateProfileBio(null), { value: null });
|
|
779
|
+
assert.deepEqual(P.validateProfileBio(' '), { value: null });
|
|
780
|
+
assert.deepEqual(P.validateProfileBio(' hi '), { value: 'hi' });
|
|
781
|
+
assert.equal(P.validateProfileBio('🔥'.repeat(P.BIO_MAX_CHARS)).error, undefined, 'code points, not UTF-16 units');
|
|
782
|
+
assert.equal(P.validateProfileBio('x'.repeat(P.BIO_MAX_CHARS + 1)).error, 'bio_too_long');
|
|
783
|
+
assert.equal(P.validateProfileBio('a\nb\tc').value, 'a\nb\tc', 'newlines and tabs are text');
|
|
784
|
+
assert.equal(P.validateProfileBio('a\u0007b').error, 'invalid_bio', 'other control chars are not');
|
|
785
|
+
assert.equal(P.validateProfileBio(42).error, 'invalid_bio');
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
t('validateProfileLink: https only — every other scheme refused', () => {
|
|
789
|
+
assert.equal(P.validateProfileLink('website', 'https://me.example/x').value, 'https://me.example/x');
|
|
790
|
+
assert.equal(P.validateProfileLink('website', 'HTTPS://Me.EXAMPLE/x').value, 'https://me.example/x', 'scheme+host normalize');
|
|
791
|
+
for (const bad of ['http://me.example', 'javascript:alert(1)', 'data:text/html,x', 'ftp://me.example', '//me.example', 'me.example']) {
|
|
792
|
+
assert.ok(P.validateProfileLink('website', bad).error, `refused: ${bad}`);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
t('validateProfileLink: userinfo + host tricks refused', () => {
|
|
797
|
+
assert.equal(P.validateProfileLink('website', 'https://linkedin.com@evil.example/x').error, 'invalid_link');
|
|
798
|
+
assert.equal(P.validateProfileLink('website', 'https://a:b@evil.example/x').error, 'invalid_link');
|
|
799
|
+
assert.equal(P.validateProfileLink('website', 'https://localhost/x').error, 'invalid_link', 'dotless hosts refused');
|
|
800
|
+
assert.equal(P.validateProfileLink('x', 'https://x.com.evil.example/u').reason, 'wrong_host', 'suffix lookalike refused');
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
t('validateProfileLink: typed slots are host-pinned; website is free', () => {
|
|
804
|
+
assert.ok(P.validateProfileLink('x', 'https://x.com/jaxri').value);
|
|
805
|
+
assert.ok(P.validateProfileLink('x', 'https://www.twitter.com/jaxri').value, 'subdomains of a pinned host allowed');
|
|
806
|
+
assert.equal(P.validateProfileLink('x', 'https://evil.example/u').reason, 'wrong_host');
|
|
807
|
+
assert.ok(P.validateProfileLink('linkedin', 'https://www.linkedin.com/in/jaxri').value);
|
|
808
|
+
assert.ok(P.validateProfileLink('discord', 'https://discord.gg/bongos').value);
|
|
809
|
+
assert.equal(P.validateProfileLink('discord', 'https://discord.example/x').reason, 'wrong_host');
|
|
810
|
+
assert.ok(P.validateProfileLink('website', 'https://anything.example/at-all').value);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
t('validateProfileLinks: closed vocabulary, object only, empty → clear', () => {
|
|
814
|
+
assert.deepEqual(P.validateProfileLinks(null), { value: null });
|
|
815
|
+
assert.deepEqual(P.validateProfileLinks({}), { value: null });
|
|
816
|
+
assert.deepEqual(P.validateProfileLinks({ website: '' }), { value: null }, 'empty slot values drop');
|
|
817
|
+
assert.equal(P.validateProfileLinks([]).error, 'invalid_links');
|
|
818
|
+
assert.equal(P.validateProfileLinks('https://x.com').error, 'invalid_links');
|
|
819
|
+
assert.equal(P.validateProfileLinks({ github: 'https://github.com/x' }).reason, 'unknown_link_type');
|
|
820
|
+
const full = P.validateProfileLinks({ website: 'https://me.example', x: 'https://x.com/j', linkedin: 'https://linkedin.com/in/j', discord: 'https://discord.gg/j' });
|
|
821
|
+
assert.deepEqual(Object.keys(full.value).sort(), [...P.PROFILE_LINK_TYPES].sort(), 'max 4 by construction — one slot per type');
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
t('validateAccountDisplayName: trimmed, non-empty, ≤60 code points, single line', () => {
|
|
825
|
+
assert.equal(P.validateAccountDisplayName(' Percy J ').value, 'Percy J');
|
|
826
|
+
assert.equal(P.validateAccountDisplayName('').error, 'invalid_display_name');
|
|
827
|
+
assert.equal(P.validateAccountDisplayName('x'.repeat(P.DISPLAY_NAME_MAX_CHARS + 1)).error, 'display_name_too_long');
|
|
828
|
+
assert.equal(P.validateAccountDisplayName('a\nb').error, 'invalid_display_name');
|
|
829
|
+
assert.equal(P.validateAccountDisplayName(42).error, 'invalid_display_name');
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
await ta('upsertAccount SQL carries the ADR 0156 guard: a user-set name is never overwritten', async () => {
|
|
833
|
+
const pool = mockPool([{ rows: [{ handle: 'x' }], rowCount: 1 }]);
|
|
834
|
+
await P.upsertAccount({ githubId: '7', githubLogin: 'x' }, { pool });
|
|
835
|
+
const sql = pool.calls[0].text;
|
|
836
|
+
assert.match(sql, /display_name\s*=\s*CASE WHEN platform_identity_accounts\.display_name_source = 'user'/,
|
|
837
|
+
'the conflict branch guards display_name on display_name_source');
|
|
838
|
+
assert.match(sql, /github_login\s*=\s*EXCLUDED\.github_login/, 'github_login still re-syncs unconditionally');
|
|
839
|
+
assert.match(sql, /avatar_url\s*=\s*EXCLUDED\.avatar_url/, 'avatar_url still re-syncs unconditionally');
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
await ta('setAccountProfileFields: validates before any query; refuses empty + non-numeric ids', async () => {
|
|
843
|
+
const pool = mockPool();
|
|
844
|
+
assert.deepEqual(await P.setAccountProfileFields({ githubId: 'system:bfg', githubLogin: 'x', fields: { bio: 'b' } }, { pool }), { error: 'no_account' });
|
|
845
|
+
assert.deepEqual(await P.setAccountProfileFields({ githubId: '7', githubLogin: 'x', fields: {} }, { pool }), { error: 'no_fields' });
|
|
846
|
+
const bad = await P.setAccountProfileFields({ githubId: '7', githubLogin: 'x', fields: { links: { x: 'https://evil.example/u' } } }, { pool });
|
|
847
|
+
assert.equal(bad.error, 'invalid_link');
|
|
848
|
+
assert.equal(pool.calls.length, 0, 'nothing reached the DB');
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
await ta('setAccountProfileFields SQL: flag-guarded UPDATE that never touches consent or identity re-sync', async () => {
|
|
852
|
+
const pool = mockPool([{ rows: [{ github_id: '7', display_name: 'N', bio: 'b', links: null, handle: 'h' }], rowCount: 1 }]);
|
|
853
|
+
await P.setAccountProfileFields({ githubId: '7', githubLogin: 'x', fields: { display_name: 'N', bio: 'b' } }, { pool });
|
|
854
|
+
const { text, params } = pool.calls[0];
|
|
855
|
+
const updateBranch = text.slice(text.indexOf('DO UPDATE SET'));
|
|
856
|
+
assert.match(updateBranch, /display_name_source = CASE WHEN \$7::boolean THEN 'user'/, 'setting the name stamps source=user');
|
|
857
|
+
assert.doesNotMatch(updateBranch, /profile_state/, 'consent never moves here (activation is /sso/authorize alone)');
|
|
858
|
+
assert.doesNotMatch(updateBranch, /hide_stats/, 'the C2 flag never moves here');
|
|
859
|
+
assert.doesNotMatch(updateBranch, /github_login\s*=/, 'no identity re-sync on the profile write');
|
|
860
|
+
assert.doesNotMatch(updateBranch, /avatar_url\s*=/, 'no identity re-sync on the profile write');
|
|
861
|
+
assert.deepEqual(params.slice(6, 9), [true, true, false], 'flags: name+bio sent, links not');
|
|
862
|
+
});
|
|
863
|
+
|
|
775
864
|
console.log(`\nplatform-identity: ${passed} passed, ${failed} failed`);
|
|
776
865
|
if (failed > 0) process.exit(1);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.183",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.183",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.183",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -61,7 +61,8 @@ const PUBLISH_ALLOWLIST = [
|
|
|
61
61
|
'modules/sessions/',
|
|
62
62
|
'modules/autonomy/',
|
|
63
63
|
'modules/security/',
|
|
64
|
-
'modules/governance/', // configurable roles & permissions (BV1 goal 1000030, ADR 0151) — the rank/permission machinery publishes AS the open-source methodology (ADR 0065 §4 / ADR 0098); it is in PROTECTED_GLOBS for write-protection, NOT publish-exclusion. default:true.
|
|
64
|
+
'modules/governance/', // configurable roles & permissions (BV1 goal 1000030, ADR 0151) — the rank/permission machinery publishes AS the open-source methodology (ADR 0065 §4 / ADR 0098); it is in PROTECTED_GLOBS for write-protection, NOT publish-exclusion. default:true. Renamed → modules/government/ (ADR 0174); dropped with the folder in goal 1000068 R04.
|
|
65
|
+
'modules/government/', // the same machinery under its post-ADR-0174 name (goal 1000068, task 1003003). Added in lockstep with the protected-surfaces.json entry: publish_manifest asserts every PROTECTED_GLOB is either topology-excluded or publishable, so registering the glob as protected WITHOUT this line default-denies the module from the published core — the open-source methodology would silently lose its permission machinery at the next release.
|
|
65
66
|
'modules/hall-ui/',
|
|
66
67
|
'modules/status-ui/',
|
|
67
68
|
'modules/dev-box/',
|
|
@@ -174,8 +174,29 @@ const MODULE_GLOBS = {
|
|
|
174
174
|
// role templates (R96), listed here as goal territory; the module code lands in
|
|
175
175
|
// R93+ (the carve precedes the feature — ADR 0086 §4). Written as exact paths,
|
|
176
176
|
// not 'config/governance*.json' — the touches convention has no wildcards. ---
|
|
177
|
+
// RENAMED governance → government (goal 1000068, task 1003003, ADR 0174).
|
|
178
|
+
// Both keys live through the transition and BOTH carry BOTH sets of globs,
|
|
179
|
+
// so the wall is identical whichever key a goal names and whichever name
|
|
180
|
+
// the folder currently has on disk. The `government` key must land BEFORE
|
|
181
|
+
// R04 moves the folder: goals carry scope_modules by KEY, so moving the
|
|
182
|
+
// folder first would leave goal 1000068's own wall not matching the files
|
|
183
|
+
// its remaining tasks need to touch, blocking the rename mid-goal.
|
|
184
|
+
// `modules/government/` is registered in protected-surfaces.json in the
|
|
185
|
+
// same change — `protected` DERIVES from that registry, so without it this
|
|
186
|
+
// key would compute protected:false and silently drop the Archon gate.
|
|
187
|
+
government: [
|
|
188
|
+
'modules/government/', 'modules/governance/',
|
|
189
|
+
'config/government.json', 'config/government.neutral.json',
|
|
190
|
+
'config/governance.json', 'config/governance.neutral.json',
|
|
191
|
+
],
|
|
192
|
+
// DEPRECATED ALIAS — kept for one release so goals and tasks already
|
|
193
|
+
// scoped to `governance` keep resolving. Removed by goal 1000068 R05
|
|
194
|
+
// (task 1003006), the release that drops the /governance/* route aliases;
|
|
195
|
+
// the two removals are the same compatibility window and should land
|
|
196
|
+
// together.
|
|
177
197
|
governance: [
|
|
178
|
-
'modules/governance/',
|
|
198
|
+
'modules/government/', 'modules/governance/',
|
|
199
|
+
'config/government.json', 'config/government.neutral.json',
|
|
179
200
|
'config/governance.json', 'config/governance.neutral.json',
|
|
180
201
|
],
|
|
181
202
|
// --- the two web surfaces ---
|
|
@@ -231,6 +231,21 @@ const EXPECTED_RANKS = {
|
|
|
231
231
|
'GET /security/adr': 'metic+archon',
|
|
232
232
|
};
|
|
233
233
|
|
|
234
|
+
// The authz-core route pins, referenced by BOTH the `governance` and the
|
|
235
|
+
// `government` module keys below while goal 1000068's rename is in flight
|
|
236
|
+
// (ADR 0174, task 1003003). One object, two keys — never two literals.
|
|
237
|
+
const GOVERNMENT_PINS = {
|
|
238
|
+
'GET /governance/roles': 'perm:governance.manage',
|
|
239
|
+
'GET /governance/permissions': 'perm:governance.manage',
|
|
240
|
+
'GET /governance/assignments': 'perm:governance.manage',
|
|
241
|
+
'POST /governance/roles': 'perm:governance.manage',
|
|
242
|
+
'PUT /governance/roles/:roleKey/permissions/:permissionKey': 'perm:governance.manage',
|
|
243
|
+
'DELETE /governance/roles/:roleKey/permissions/:permissionKey': 'perm:governance.manage',
|
|
244
|
+
'POST /governance/roles/:roleKey/reset': 'perm:governance.manage',
|
|
245
|
+
'POST /governance/assignments': 'perm:governance.manage',
|
|
246
|
+
'DELETE /governance/assignments/:builderId/:roleKey': 'perm:governance.manage',
|
|
247
|
+
};
|
|
248
|
+
|
|
234
249
|
// Module-scoped privileged-rank pins: a privileged route that lives in a FEATURE
|
|
235
250
|
// MODULE (modules/<key>/routes/…), pinned like EXPECTED_RANKS but verified ONLY
|
|
236
251
|
// when that module is in scope (its routes were discovered in this scan). So an
|
|
@@ -400,24 +415,28 @@ const MODULE_EXPECTED_RANKS = {
|
|
|
400
415
|
'POST /security/reports/:id/fix': 'perm:security.report.adjudicate',
|
|
401
416
|
'POST /security/reports/:id/link-fix': 'perm:security.report.adjudicate',
|
|
402
417
|
},
|
|
403
|
-
// The
|
|
404
|
-
// the
|
|
405
|
-
// pinned to the `governance.manage` ATOM rather than a rank:
|
|
406
|
-
// the atom IS the authority (it is the catalog's meta-escalation
|
|
407
|
-
// downgrade here is the model's worst case — the routes that write
|
|
408
|
-
// drifting below the wall that guards authority. Reads are pinned too
|
|
409
|
-
// the full
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
418
|
+
// The government tab's server half (BV1.R110, ADR 0151; renamed by ADR 0174) —
|
|
419
|
+
// the routes that EDIT the rank/permission graph itself. Every route, the three
|
|
420
|
+
// reads included, is pinned to the `governance.manage` ATOM rather than a rank:
|
|
421
|
+
// on this surface the atom IS the authority (it is the catalog's meta-escalation
|
|
422
|
+
// key), and a downgrade here is the model's worst case — the routes that write
|
|
423
|
+
// authority drifting below the wall that guards authority. Reads are pinned too
|
|
424
|
+
// because the full rank/grant/assignment map is the authority model's blueprint.
|
|
425
|
+
//
|
|
426
|
+
// The ROUTE PATHS and the `governance.manage` key are renamed by R05 (task
|
|
427
|
+
// 1003006), not here: R02 only makes the pins reachable under both module keys.
|
|
428
|
+
// Renaming the paths before the routes move would pin routes that do not exist
|
|
429
|
+
// and red the build for the whole window.
|
|
430
|
+
governance: GOVERNMENT_PINS,
|
|
431
|
+
// The same pins under the post-ADR-0174 module name (goal 1000068, task
|
|
432
|
+
// 1003003). Pins are applied only when the module directory is IN SCOPE — at
|
|
433
|
+
// least one route file was discovered under modules/<key>/ — so this entry is
|
|
434
|
+
// inert until R04 (task 1003005) moves the folder, and becomes load-bearing
|
|
435
|
+
// the moment it does, with no second edit needed. Both keys reference the SAME
|
|
436
|
+
// object rather than a copy: two literals here would be the "second list that
|
|
437
|
+
// drifts" failure mode this file exists to prevent, on the surface where a
|
|
438
|
+
// silent drift is worst.
|
|
439
|
+
government: GOVERNMENT_PINS,
|
|
421
440
|
};
|
|
422
441
|
|
|
423
442
|
// The rank a route's RANK middleware implies, or null when it carries none.
|