@bongos/core 1.19.180 → 1.19.182

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.
@@ -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 = EXCLUDED.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, created_at, updated_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
- router.get('/profiles/:handle', async (req, res) => {
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.180",
3
+ "version": "1.19.182",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.180",
9
+ "version": "1.19.182",
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.180",
3
+ "version": "1.19.182",
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",
package/src/module-api.js CHANGED
@@ -787,8 +787,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
787
787
  // landed since 1.19.178 with no explicit bump. run 31972209764. (task 1002620)
788
788
  // 1.19.180 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
789
789
  // landed since 1.19.179 with no explicit bump. run 31972568511. (task 1002620)
790
+ // 1.19.181 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
791
+ // landed since 1.19.180 with no explicit bump. run 31972686846. (task 1002620)
792
+ // 1.19.182 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
793
+ // landed since 1.19.181 with no explicit bump. run 31972846686. (task 1002620)
790
794
  // ---------------------------------------------------------------------------
791
- const CORE_VERSION = '1.19.180'; // CI auto-patch carrier (ADR 0161); see changelog above
795
+ const CORE_VERSION = '1.19.182'; // CI auto-patch carrier (ADR 0161); see changelog above
792
796
 
793
797
  // A namespaced logger so a module's log lines are attributable + consistent.
794
798
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -69,7 +69,7 @@ const SECRET_PROJECT = 'P05-PRIVATE-DO-NOT-LEAK';
69
69
  const SECRET_PREV = 'p05-prev-sentinel';
70
70
  const PUB_ORIGIN = 'https://p05-pub.example', PRIV_ORIGIN = 'https://p05-priv.example';
71
71
  const CP_KEYS = ['totals', 'disciplines', 'last_active', 'projects', 'more_projects'];
72
- const ACCOUNT_KEYS = ['handle', 'github_login', 'display_name', 'avatar_url', 'member_since'];
72
+ const ACCOUNT_KEYS = ['handle', 'github_login', 'display_name', 'avatar_url', 'member_since', 'bio', 'links'];
73
73
 
74
74
  const q = (sql, params) => pool.query(sql, params);
75
75
  async function cleanup() {
@@ -103,7 +103,7 @@ function get(port, path) {
103
103
 
104
104
  const NEVER_IN_A_BODY = [
105
105
  SECRET_CLIENT_PUB, SECRET_CLIENT_PRIV, SECRET_HASH, SECRET_PROJECT, SECRET_PREV,
106
- 'profile_state', 'hide_stats', 'github_id',
106
+ 'profile_state', 'hide_stats', 'github_id', 'display_name_source',
107
107
  ];
108
108
  function assertNoSentinels(text, where) {
109
109
  for (const s of NEVER_IN_A_BODY) {
@@ -118,14 +118,17 @@ try {
118
118
  await q(migrationSql('platform_identity_005_builder_activity.sql'));
119
119
  await q(migrationSql('platform_identity_007_profile_consent.sql'));
120
120
  await q(migrationSql('platform_identity_008_handle.sql'));
121
+ await q(migrationSql('platform_identity_009_profile_fields.sql'));
121
122
  await cleanup();
122
123
 
124
+ // The active account carries the P02 fields + a user-set name source, so the
125
+ // 200 proves bio/links serialize while display_name_source never does.
123
126
  await q(
124
127
  `INSERT INTO platform_identity_accounts
125
- (github_id, github_login, display_name, avatar_url, profile_state, hide_stats, handle, previous_handle)
126
- VALUES ($1, 'p05-active-login', 'P05 Active', 'https://a.example/a.png', 'active', false, 'p05-active', $4),
127
- ($2, 'p05-hidden-login', 'P05 Hidden', NULL, 'active', true, 'p05-hidden', NULL),
128
- ($3, 'p05-prov-login', 'P05 Prov', NULL, 'provisional', false, 'p05-prov', NULL)`,
128
+ (github_id, github_login, display_name, avatar_url, profile_state, hide_stats, handle, previous_handle, bio, links, display_name_source)
129
+ VALUES ($1, 'p05-active-login', 'P05 Active', 'https://a.example/a.png', 'active', false, 'p05-active', $4, 'Builds things.', '{"website":"https://p05.example/"}', 'user'),
130
+ ($2, 'p05-hidden-login', 'P05 Hidden', NULL, 'active', true, 'p05-hidden', NULL, NULL, NULL, 'github'),
131
+ ($3, 'p05-prov-login', 'P05 Prov', NULL, 'provisional', false, 'p05-prov', NULL, NULL, NULL, 'github')`,
129
132
  [ACTIVE, HIDDEN, PROV, SECRET_PREV],
130
133
  );
131
134
  await q(
@@ -165,6 +168,8 @@ try {
165
168
  assert.equal(r.json.cross_project.projects[0].name, 'P05 Pub Proj');
166
169
  assert.deepEqual(Object.keys(r.json.cross_project.projects[0]).sort(), ['name', 'origin', 'rank_held'].sort());
167
170
  assert.equal(r.json.cross_project.more_projects, 1, 'the non-public project is counted, never named');
171
+ assert.equal(r.json.account.bio, 'Builds things.', 'bio serializes (P02)');
172
+ assert.deepEqual(r.json.account.links, { website: 'https://p05.example/' }, 'links serialize (P02)');
168
173
  assertNoSentinels(r.text, 'the 200 body');
169
174
  });
170
175