@bongos/core 1.19.171 → 1.19.173

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.
@@ -117,10 +117,13 @@ async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, ac
117
117
  avatar_url = EXCLUDED.avatar_url,
118
118
  profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END,
119
119
  updated_at = now()
120
- RETURNING id, github_id, github_login, display_name, avatar_url, profile_state`,
120
+ RETURNING id, github_id, github_login, display_name, avatar_url, profile_state, handle`,
121
121
  [githubId, githubLogin, displayName ?? null, avatarUrl ?? null, !!activate],
122
122
  );
123
- return rows[0];
123
+ const row = rows[0];
124
+ // Seed the handle on first sight (spec Decision 3); a no-op once set.
125
+ if (row && !row.handle) row.handle = await ensureHandle({ githubId, githubLogin }, { pool });
126
+ return row;
124
127
  }
125
128
 
126
129
  // Resolve the platform account for a github_login (for invitations by handle). A
@@ -138,7 +141,8 @@ async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
138
141
 
139
142
  async function getAccount(githubId, { pool = defaultPool } = {}) {
140
143
  const { rows } = await pool.query(
141
- `SELECT id, github_id, github_login, display_name, avatar_url, profile_state, hide_stats, created_at, updated_at
144
+ `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
142
146
  FROM platform_identity_accounts WHERE github_id = $1`,
143
147
  [githubId],
144
148
  );
@@ -615,10 +619,187 @@ async function setAccountHideStats({ githubId, githubLogin, displayName, avatarU
615
619
  ON CONFLICT (github_id) DO UPDATE SET
616
620
  hide_stats = EXCLUDED.hide_stats,
617
621
  updated_at = now()
618
- RETURNING github_id, github_login, profile_state, hide_stats`,
622
+ RETURNING github_id, github_login, profile_state, hide_stats, handle`,
619
623
  [githubId, githubLogin, displayName ?? null, avatarUrl ?? null, !!hide],
620
624
  );
621
- return rows[0];
625
+ const row = rows[0];
626
+ // This upsert can CREATE the account row (hide-before-federating) — seed the
627
+ // handle then, same as upsertAccount.
628
+ if (row && !row.handle) row.handle = await ensureHandle({ githubId, githubLogin }, { pool });
629
+ return row;
630
+ }
631
+
632
+ // -------------------------------------------------------------------------
633
+ // Builder handle (P01, task 1002797 — profile-page spec Decision 3, ADR 0171 D8)
634
+ // -------------------------------------------------------------------------
635
+ //
636
+ // The canonical profile URL key (cloudbongos.com/u/<handle>). github_id stays
637
+ // the join key everywhere — the handle is presentation only, unique
638
+ // case-insensitively, seeded from github_login (which is mutable and therefore
639
+ // unfit to hang a URL on).
640
+
641
+ // Route words the /u namespace uses (or will) — a handle may never shadow them.
642
+ // Mirrored by the seeding list in migrations/platform_identity_008_handle.sql;
643
+ // keep the two in lockstep.
644
+ const RESERVED_HANDLES = ['me', 'new', 'edit', 'settings', 'admin', 'api', 'u'];
645
+
646
+ // One rename per window — low by design: a handle is a shared URL, and churning
647
+ // it breaks links faster than the single-slot 301 window can cover.
648
+ const HANDLE_RENAME_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
649
+
650
+ // Canonical form: lowercase, 1–39 chars of [a-z0-9-], no leading/trailing
651
+ // hyphen (github_login's charset, lowercased). Returns the normalized handle,
652
+ // or null when the input can't be one.
653
+ function normalizeHandle(raw) {
654
+ if (typeof raw !== 'string') return null;
655
+ const h = raw.trim().toLowerCase();
656
+ return /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(h) ? h : null;
657
+ }
658
+
659
+ function isReservedHandle(handle) {
660
+ return RESERVED_HANDLES.includes(handle);
661
+ }
662
+
663
+ // The seed base for an account: its login squeezed into the handle charset; a
664
+ // login that contributes nothing falls back to builder-<github_id>.
665
+ function handleSeedBase({ githubId, githubLogin }) {
666
+ const squeezed = String(githubLogin || '')
667
+ .toLowerCase()
668
+ .replace(/[^a-z0-9-]/g, '')
669
+ .replace(/^-+|-+$/g, '');
670
+ return normalizeHandle(squeezed) || `builder-${githubId}`;
671
+ }
672
+
673
+ // Seed the handle for an account that has none (new accounts at upsert; legacy
674
+ // rows were seeded by migration 008 with the same algorithm). First free of
675
+ // base, base-2, base-3, … wins; reserved words are never handed out bare. The
676
+ // unique index is the arbiter — losing a race just moves to the next suffix,
677
+ // and a concurrent seeder winning the row (handle no longer NULL) ends the loop.
678
+ async function ensureHandle({ githubId, githubLogin }, { pool = defaultPool } = {}) {
679
+ if (!isNumericGithubId(githubId)) return null;
680
+ const base = handleSeedBase({ githubId, githubLogin });
681
+ let cand = isReservedHandle(base) ? `${base}-2` : base;
682
+ let next = isReservedHandle(base) ? 3 : 2;
683
+ for (let tries = 0; tries < 50; tries++) {
684
+ try {
685
+ const { rows, rowCount } = await pool.query(
686
+ `UPDATE platform_identity_accounts
687
+ SET handle = $2, updated_at = now()
688
+ WHERE github_id = $1 AND handle IS NULL
689
+ RETURNING handle`,
690
+ [githubId, cand],
691
+ );
692
+ if (rowCount === 0) break; // seeded concurrently (or no row) — read below
693
+ return rows[0].handle;
694
+ } catch (err) {
695
+ if (err && err.code === '23505') { cand = `${base}-${next++}`; continue; }
696
+ throw err;
697
+ }
698
+ }
699
+ const acct = await getAccount(githubId, { pool });
700
+ return acct ? acct.handle : null;
701
+ }
702
+
703
+ // The rename path (own-scoped; callers key by the AUTHENTICATED github_id).
704
+ // Canonical form only, reserved words refused, one rename per
705
+ // HANDLE_RENAME_WINDOW_MS, uniqueness arbitrated by the index (23505 → taken).
706
+ // On success the old handle drops into previous_handle — the single-slot 301
707
+ // window R05 resolves — and handle_changed_at starts the rate-limit clock
708
+ // (seeding never sets it, so the first rename is always free). Renaming to the
709
+ // current handle is a no-op that burns nothing. Returns { handle,
710
+ // previous_handle } or { error: 'invalid_handle' | 'reserved_handle' |
711
+ // 'no_account' | 'rate_limited' (+ retry_at) | 'handle_taken' }.
712
+ async function setAccountHandle({ githubId, handle }, { pool = defaultPool } = {}) {
713
+ if (!isNumericGithubId(githubId)) return { error: 'no_account' };
714
+ const norm = normalizeHandle(handle);
715
+ if (!norm) return { error: 'invalid_handle' };
716
+ if (isReservedHandle(norm)) return { error: 'reserved_handle' };
717
+ const acct = await getAccount(githubId, { pool });
718
+ if (!acct) return { error: 'no_account' };
719
+ if (acct.handle === norm) return { handle: acct.handle, previous_handle: acct.previous_handle || null };
720
+ const rateLimited = (a) => ({
721
+ error: 'rate_limited',
722
+ retry_at: new Date(new Date(a.handle_changed_at).getTime() + HANDLE_RENAME_WINDOW_MS).toISOString(),
723
+ });
724
+ const changedAt = acct.handle_changed_at ? new Date(acct.handle_changed_at).getTime() : 0;
725
+ if (changedAt && Date.now() - changedAt < HANDLE_RENAME_WINDOW_MS) return rateLimited(acct);
726
+ try {
727
+ // The window is re-checked IN the UPDATE so the read above can't race a
728
+ // concurrent rename past the rate limit — the DB arbitrates, exactly as the
729
+ // unique index arbitrates collisions.
730
+ const { rows, rowCount } = await pool.query(
731
+ `UPDATE platform_identity_accounts
732
+ SET previous_handle = handle, handle = $2, handle_changed_at = now(), updated_at = now()
733
+ WHERE github_id = $1
734
+ AND (handle_changed_at IS NULL
735
+ OR handle_changed_at <= now() - ($3::bigint * interval '1 millisecond'))
736
+ RETURNING handle, previous_handle`,
737
+ [githubId, norm, HANDLE_RENAME_WINDOW_MS],
738
+ );
739
+ if (rowCount) return rows[0];
740
+ // 0 rows: the account vanished, or a concurrent rename consumed the window
741
+ // between our read and this write — re-read to answer honestly.
742
+ const fresh = await getAccount(githubId, { pool });
743
+ if (!fresh) return { error: 'no_account' };
744
+ return fresh.handle_changed_at ? rateLimited(fresh) : { error: 'no_account' };
745
+ } catch (err) {
746
+ if (err && err.code === '23505') return { error: 'handle_taken' };
747
+ throw err;
748
+ }
749
+ }
750
+
751
+ // Resolve a public handle → its account (R05, task 1002281 — the /u/<handle>
752
+ // resolution). Case-insensitive. An exact current-handle match always wins;
753
+ // otherwise the single-slot previous_handle matches and answers a move to the
754
+ // current handle (the 301 window from P01). Only publicly addressable accounts
755
+ // resolve (profile_state = 'active', ADR 0171 D3) — provisional and unknown are
756
+ // the same null (the 404, indistinguishable by design). hide_stats deliberately
757
+ // does NOT gate resolution: hiding removes the rollup, not the page (D2 —
758
+ // addressability and hide are different invariants).
759
+ // Returns null | { moved_to } | { account: { github_id, github_login,
760
+ // display_name, avatar_url, handle, created_at } }.
761
+ async function resolveHandle(handle, { pool = defaultPool } = {}) {
762
+ const norm = normalizeHandle(handle);
763
+ if (!norm) return null;
764
+ const { rows } = await pool.query(
765
+ `SELECT github_id, github_login, display_name, avatar_url, handle, created_at,
766
+ (lower(handle) = $1) AS is_current
767
+ FROM platform_identity_accounts
768
+ WHERE profile_state = 'active'
769
+ AND (lower(handle) = $1 OR lower(previous_handle) = $1)
770
+ ORDER BY (lower(handle) = $1) DESC
771
+ LIMIT 1`,
772
+ [norm],
773
+ );
774
+ const row = rows[0];
775
+ if (!row) return null;
776
+ if (!row.is_current) return { moved_to: row.handle };
777
+ const { is_current, ...account } = row;
778
+ return { account };
779
+ }
780
+
781
+ // The one public read the canonical /u/<handle> page consumes (R05; the page
782
+ // itself is goal 1000048 R14, task 1002365 — exactly one page gets built, this
783
+ // is its read). Whitelist-shaped INSIDE the module: the identity-band fields
784
+ // plus the same cross_project payload the numeric instance profile route serves
785
+ // (null = hidden / none, indistinguishable — D4). profile_state and github_id
786
+ // never cross — a 200 IS the addressability signal, and the join key stays
787
+ // internal.
788
+ async function getPublicProfileByHandle(handle, { pool = defaultPool } = {}) {
789
+ const hit = await resolveHandle(handle, { pool });
790
+ if (!hit) return null;
791
+ if (hit.moved_to) return { moved_to: hit.moved_to };
792
+ const a = hit.account;
793
+ return {
794
+ account: {
795
+ handle: a.handle,
796
+ github_login: a.github_login,
797
+ display_name: a.display_name,
798
+ avatar_url: a.avatar_url,
799
+ member_since: a.created_at,
800
+ },
801
+ cross_project: await getPublicProfileExtras(a.github_id, { pool }),
802
+ };
622
803
  }
623
804
 
624
805
  // -------------------------------------------------------------------------
@@ -721,6 +902,16 @@ module.exports = {
721
902
  getPublicProfileExtras,
722
903
  getOwnProfileExtras,
723
904
  setAccountHideStats,
905
+ // builder handle (P01)
906
+ RESERVED_HANDLES,
907
+ HANDLE_RENAME_WINDOW_MS,
908
+ normalizeHandle,
909
+ isReservedHandle,
910
+ handleSeedBase,
911
+ ensureHandle,
912
+ setAccountHandle,
913
+ resolveHandle,
914
+ getPublicProfileByHandle,
724
915
  // codes
725
916
  mintAuthCode,
726
917
  redeemAuthCode,
@@ -13,6 +13,9 @@
13
13
  // /scouting): enforcement lives in the module's read
14
14
  // functions (D2) and the hub profile response is
15
15
  // no-store (D5), so there is no cache to outwait.
16
+ // PATCH /me/handle — rename the caller's own handle (P01, task 1002797;
17
+ // spec Decision 3). Low rate limit, previous handle
18
+ // kept for the 301 window.
16
19
  //
17
20
  // Reaches core only through the doorway (ADR 0083).
18
21
  'use strict';
@@ -32,7 +35,7 @@ module.exports = function profileRoutes() {
32
35
  // Core builders include system:* principals whose github_id is not numeric;
33
36
  // they have no platform identity — answer the empty shape, not a 500.
34
37
  if (!pi.isNumericGithubId(req.builder.github_id)) {
35
- return res.json({ hide_stats: false, profile_state: null, cross_project: null });
38
+ return res.json({ hide_stats: false, profile_state: null, handle: null, cross_project: null });
36
39
  }
37
40
  const [acct, extras] = await Promise.all([
38
41
  pi.getAccount(req.builder.github_id, { pool }),
@@ -41,6 +44,7 @@ module.exports = function profileRoutes() {
41
44
  res.json({
42
45
  hide_stats: !!(acct && acct.hide_stats),
43
46
  profile_state: acct ? acct.profile_state : null,
47
+ handle: (acct && acct.handle) || null,
44
48
  cross_project: extras,
45
49
  });
46
50
  } catch (err) {
@@ -73,5 +77,47 @@ module.exports = function profileRoutes() {
73
77
  }
74
78
  });
75
79
 
80
+ // rank: any-builder — rename the caller's OWN handle (own data, keyed on the
81
+ // authenticated github_id). Rules live in setAccountHandle: canonical form,
82
+ // reserved words refused, one rename per window, previous handle kept for the
83
+ // 301 window. Upsert-first (the setAccountHideStats precedent) so a hub
84
+ // builder who never federated can still claim a handle — never demotes consent.
85
+ router.patch('/me/handle', requireBuilder, async (req, res) => {
86
+ if (validateOrRespond(req, res, {
87
+ handle: { required: true, type: 'string', minLength: 1, maxLength: 39 },
88
+ })) return;
89
+ try {
90
+ if (!pi.isNumericGithubId(req.builder.github_id)) {
91
+ return res.fail('no_platform_identity', 400);
92
+ }
93
+ await pi.upsertAccount({
94
+ githubId: req.builder.github_id,
95
+ githubLogin: req.builder.github_login,
96
+ displayName: req.builder.display_name,
97
+ avatarUrl: req.builder.avatar_url,
98
+ }, { pool });
99
+ const out = await pi.setAccountHandle({ githubId: req.builder.github_id, handle: req.body.handle }, { pool });
100
+ if (out.error === 'invalid_handle') {
101
+ return res.fail('invalid_handle', { status: 400, message: 'a handle is 1–39 characters of a–z, 0–9, and hyphens (not at the ends)' });
102
+ }
103
+ if (out.error === 'reserved_handle') {
104
+ return res.fail('reserved_handle', { status: 400, message: 'that handle is a reserved word' });
105
+ }
106
+ if (out.error === 'handle_taken') {
107
+ return res.fail('handle_taken', { status: 409, message: 'that handle is already in use' });
108
+ }
109
+ if (out.error === 'rate_limited') {
110
+ return res.fail('handle_rate_limited', { status: 429, message: `handles change at most once per ${Math.round(pi.HANDLE_RENAME_WINDOW_MS / 86400000)} days — try again after ${out.retry_at}` });
111
+ }
112
+ if (out.error) {
113
+ return res.fail('handle_write_failed', { status: 500, message: 'internal error' });
114
+ }
115
+ res.json({ handle: out.handle, previous_handle: out.previous_handle || null });
116
+ } catch (err) {
117
+ console.error('[platform-identity] PATCH /me/handle', err);
118
+ res.fail('handle_write_failed', { status: 500, message: 'internal error' });
119
+ }
120
+ });
121
+
76
122
  return router;
77
123
  };
@@ -0,0 +1,49 @@
1
+ // modules/platform-identity/routes/public-profile.js — the public handle-based
2
+ // profile read (R05, task 1002281; profile-page spec Decisions 1/3, ADR 0171 D8).
3
+ //
4
+ // GET /profiles/:handle — resolve a handle to the public profile payload the
5
+ // canonical /u/<handle> page consumes (goal 1000048 R14 / task 1002365 builds
6
+ // the PAGE; this is its read — exactly one page gets built). Answers:
7
+ // 404 — unknown handle, or a provisional account (indistinguishable by
8
+ // design: fail-closed consent, ADR 0171 D3)
9
+ // 301 — the request named a previous handle inside P01's single-slot
10
+ // window; Location + body point at the current handle
11
+ // 200 — { account: identity-band fields, cross_project: … | null }
12
+ //
13
+ // Reaches core only through the doorway (ADR 0083).
14
+ 'use strict';
15
+
16
+ const express = require('express');
17
+ const api = require('../../../src/module-api');
18
+ const pi = require('../platform-identity');
19
+
20
+ const { pool, corsPublicGet } = api;
21
+
22
+ module.exports = function publicProfileRoutes() {
23
+ const router = express.Router();
24
+
25
+ router.use('/profiles/:handle', corsPublicGet('/profiles/:handle'));
26
+
27
+ // rank: public — a handle-addressed public profile. Every field is already
28
+ // world-readable on the numeric profile route + the leaderboard; the handle is
29
+ // just the rename-proof address (spec Decision 3). Whitelisting happens inside
30
+ // 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) => {
33
+ try {
34
+ const out = await pi.getPublicProfileByHandle(req.params.handle, { pool });
35
+ res.set('Cache-Control', 'no-store');
36
+ if (!out) return res.fail('profile_not_found', 404);
37
+ if (out.moved_to) {
38
+ res.set('Location', req.originalUrl.replace(/\/profiles\/[^/?]+/, `/profiles/${encodeURIComponent(out.moved_to)}`));
39
+ return res.status(301).json({ moved_to: out.moved_to });
40
+ }
41
+ res.json({ account: out.account, cross_project: out.cross_project });
42
+ } catch (err) {
43
+ console.error('[platform-identity] GET /profiles/:handle', err);
44
+ res.fail('profile_read_failed', { status: 500, message: 'internal error' });
45
+ }
46
+ });
47
+
48
+ return router;
49
+ };
@@ -141,7 +141,9 @@ await ta('upsertAccount upserts on github_id and returns the row (default: NOT a
141
141
  });
142
142
 
143
143
  await ta('upsertAccount consent upgrade is one-way (ADR 0171 D3): activate flips to active, default preserves the stored state', async () => {
144
- const pool = mockPool([{ rows: [{ id: '1' }] }, { rows: [{ id: '1' }] }]);
144
+ // handle present on the returned rows → the P01 seeding hook stays quiet, so
145
+ // the two upserts are the only two calls.
146
+ const pool = mockPool([{ rows: [{ id: '1', handle: 'octocat' }] }, { rows: [{ id: '1', handle: 'octocat' }] }]);
145
147
  await P.upsertAccount({ githubId: 42, githubLogin: 'octocat', activate: true }, { pool });
146
148
  await P.upsertAccount({ githubId: 42, githubLogin: 'octocat' }, { pool });
147
149
  const sql = pool.calls[0].text;
@@ -581,5 +583,194 @@ await ta('sweepExpiredCodes deletes expired + long-consumed rows', async () => {
581
583
  assert.match(pool.calls[0].text, /expires_at < now\(\)/);
582
584
  });
583
585
 
586
+ console.log('\nbuilder handle (P01, task 1002797):');
587
+
588
+ // A pool that throws queued errors (code-carrying) or returns queued responses —
589
+ // for the unique-violation retry paths mockPool can't express.
590
+ function throwingPool(script) {
591
+ const calls = [];
592
+ let i = 0;
593
+ return {
594
+ calls,
595
+ query: async (text, params) => {
596
+ calls.push({ text: String(text), params: params || [] });
597
+ const step = script[i++] || { rows: [], rowCount: 0 };
598
+ if (step.throwCode) { const e = new Error(`pg error ${step.throwCode}`); e.code = step.throwCode; throw e; }
599
+ return step;
600
+ },
601
+ };
602
+ }
603
+
604
+ t('normalizeHandle: canonical form only', () => {
605
+ assert.equal(P.normalizeHandle(' Jaxri '), 'jaxri');
606
+ assert.equal(P.normalizeHandle('a'), 'a');
607
+ assert.equal(P.normalizeHandle('a-b-1'), 'a-b-1');
608
+ assert.equal(P.normalizeHandle('has_underscore'), null);
609
+ assert.equal(P.normalizeHandle('-lead'), null);
610
+ assert.equal(P.normalizeHandle('trail-'), null);
611
+ assert.equal(P.normalizeHandle('x'.repeat(39)), 'x'.repeat(39));
612
+ assert.equal(P.normalizeHandle('x'.repeat(40)), null);
613
+ assert.equal(P.normalizeHandle(''), null);
614
+ assert.equal(P.normalizeHandle(42), null);
615
+ });
616
+
617
+ t('the reserved list is exactly the spec Decision 3 set', () => {
618
+ assert.deepEqual([...P.RESERVED_HANDLES].sort(), ['admin', 'api', 'edit', 'me', 'new', 'settings', 'u'].sort());
619
+ assert.equal(P.isReservedHandle('me'), true);
620
+ assert.equal(P.isReservedHandle('jaxri'), false);
621
+ });
622
+
623
+ t('handleSeedBase squeezes the login; contributes-nothing → builder-<id>', () => {
624
+ assert.equal(P.handleSeedBase({ githubId: 7, githubLogin: 'Jax_Ri!' }), 'jaxri');
625
+ assert.equal(P.handleSeedBase({ githubId: 7, githubLogin: '-Wrapped-' }), 'wrapped');
626
+ assert.equal(P.handleSeedBase({ githubId: 7, githubLogin: '___' }), 'builder-7');
627
+ assert.equal(P.handleSeedBase({ githubId: 7, githubLogin: null }), 'builder-7');
628
+ });
629
+
630
+ await ta('ensureHandle takes the bare base when free', async () => {
631
+ const pool = throwingPool([{ rows: [{ handle: 'jaxri' }], rowCount: 1 }]);
632
+ const h = await P.ensureHandle({ githubId: 7, githubLogin: 'jaxri' }, { pool });
633
+ assert.equal(h, 'jaxri');
634
+ assert.match(pool.calls[0].text, /SET handle = \$2/);
635
+ assert.match(pool.calls[0].text, /handle IS NULL/, 'seed must never clobber an existing handle');
636
+ assert.equal(pool.calls[0].params[1], 'jaxri');
637
+ });
638
+
639
+ await ta('ensureHandle dedupes a collision with the numeric suffix', async () => {
640
+ const pool = throwingPool([{ throwCode: '23505' }, { rows: [{ handle: 'jaxri-2' }], rowCount: 1 }]);
641
+ const h = await P.ensureHandle({ githubId: 7, githubLogin: 'jaxri' }, { pool });
642
+ assert.equal(h, 'jaxri-2');
643
+ assert.equal(pool.calls[1].params[1], 'jaxri-2');
644
+ });
645
+
646
+ await ta('ensureHandle never hands out a reserved word bare', async () => {
647
+ const pool = throwingPool([{ rows: [{ handle: 'admin-2' }], rowCount: 1 }]);
648
+ const h = await P.ensureHandle({ githubId: 7, githubLogin: 'admin' }, { pool });
649
+ assert.equal(h, 'admin-2');
650
+ assert.equal(pool.calls[0].params[1], 'admin-2', 'a reserved base starts at -2');
651
+ });
652
+
653
+ await ta('ensureHandle: non-numeric github_id (system:*) → null, no query', async () => {
654
+ const pool = throwingPool([]);
655
+ assert.equal(await P.ensureHandle({ githubId: 'system:bfg', githubLogin: 'bfg' }, { pool }), null);
656
+ assert.equal(pool.calls.length, 0);
657
+ });
658
+
659
+ await ta('setAccountHandle refuses invalid + reserved before touching the DB', async () => {
660
+ const pool = throwingPool([]);
661
+ assert.deepEqual(await P.setAccountHandle({ githubId: 7, handle: 'bad_handle' }, { pool }), { error: 'invalid_handle' });
662
+ assert.deepEqual(await P.setAccountHandle({ githubId: 7, handle: 'settings' }, { pool }), { error: 'reserved_handle' });
663
+ assert.equal(pool.calls.length, 0);
664
+ });
665
+
666
+ await ta('setAccountHandle renames: previous handle kept, clock started', async () => {
667
+ const acct = { github_id: 7, handle: 'old', previous_handle: null, handle_changed_at: null };
668
+ const pool = throwingPool([
669
+ { rows: [acct], rowCount: 1 },
670
+ { rows: [{ handle: 'newname', previous_handle: 'old' }], rowCount: 1 },
671
+ ]);
672
+ const out = await P.setAccountHandle({ githubId: 7, handle: 'NewName' }, { pool });
673
+ assert.deepEqual(out, { handle: 'newname', previous_handle: 'old' });
674
+ const upd = pool.calls[1];
675
+ assert.match(upd.text, /previous_handle = handle/, 'the old handle drops into the 301 slot');
676
+ assert.match(upd.text, /handle_changed_at = now\(\)/);
677
+ assert.match(upd.text, /handle_changed_at IS NULL\s+OR handle_changed_at <= now\(\)/,
678
+ 'the window is re-checked IN the UPDATE — the rate limit must not be check-then-write');
679
+ assert.equal(upd.params[1], 'newname', 'stored lowercased');
680
+ assert.equal(upd.params[2], P.HANDLE_RENAME_WINDOW_MS);
681
+ });
682
+
683
+ await ta('setAccountHandle: losing the window race → rate_limited, not a silent write', async () => {
684
+ // The read sees a free window, but the guarded UPDATE matches 0 rows (a
685
+ // concurrent rename consumed it) — the re-read answers rate_limited.
686
+ const before = { github_id: 7, handle: 'old', previous_handle: null, handle_changed_at: null };
687
+ const after = { ...before, handle: 'stolen', handle_changed_at: new Date().toISOString() };
688
+ const pool = throwingPool([
689
+ { rows: [before], rowCount: 1 },
690
+ { rows: [], rowCount: 0 },
691
+ { rows: [after], rowCount: 1 },
692
+ ]);
693
+ const out = await P.setAccountHandle({ githubId: 7, handle: 'newname' }, { pool });
694
+ assert.equal(out.error, 'rate_limited');
695
+ assert.ok(out.retry_at);
696
+ });
697
+
698
+ await ta('setAccountHandle: a rename inside the window is rate-limited', async () => {
699
+ const acct = { github_id: 7, handle: 'old', previous_handle: null, handle_changed_at: new Date().toISOString() };
700
+ const pool = throwingPool([{ rows: [acct], rowCount: 1 }]);
701
+ const out = await P.setAccountHandle({ githubId: 7, handle: 'newname' }, { pool });
702
+ assert.equal(out.error, 'rate_limited');
703
+ assert.ok(out.retry_at, 'carries when the window reopens');
704
+ assert.equal(pool.calls.length, 1, 'no UPDATE fired');
705
+ });
706
+
707
+ await ta('setAccountHandle: renaming to the current handle is a free no-op', async () => {
708
+ const acct = { github_id: 7, handle: 'same', previous_handle: 'older', handle_changed_at: new Date().toISOString() };
709
+ const pool = throwingPool([{ rows: [acct], rowCount: 1 }]);
710
+ const out = await P.setAccountHandle({ githubId: 7, handle: 'Same' }, { pool });
711
+ assert.deepEqual(out, { handle: 'same', previous_handle: 'older' });
712
+ assert.equal(pool.calls.length, 1, 'read only — no clock burn');
713
+ });
714
+
715
+ await ta('setAccountHandle: a taken handle → handle_taken', async () => {
716
+ const acct = { github_id: 7, handle: 'old', previous_handle: null, handle_changed_at: null };
717
+ const pool = throwingPool([{ rows: [acct], rowCount: 1 }, { throwCode: '23505' }]);
718
+ const out = await P.setAccountHandle({ githubId: 7, handle: 'wanted' }, { pool });
719
+ assert.deepEqual(out, { error: 'handle_taken' });
720
+ });
721
+
722
+ console.log('\nhandle resolution (R05, task 1002281):');
723
+
724
+ await ta('resolveHandle: active-only, current-beats-previous, lowercased lookup', async () => {
725
+ const row = { github_id: '7', github_login: 'x', display_name: 'X', avatar_url: null, handle: 'x', created_at: 'T', is_current: true };
726
+ const pool = mockPool([{ rows: [row], rowCount: 1 }]);
727
+ const out = await P.resolveHandle('X', { pool });
728
+ const c = pool.calls[0];
729
+ assert.match(c.text, /profile_state = 'active'/, 'provisional accounts are not addressable (ADR 0171 D3)');
730
+ assert.match(c.text, /ORDER BY \(lower\(handle\) = \$1\) DESC/, 'an exact current-handle match always wins over a previous one');
731
+ assert.doesNotMatch(c.text, /hide_stats/, 'hide gates the rollup, never addressability (D2)');
732
+ assert.equal(c.params[0], 'x', 'the lookup is lowercased');
733
+ assert.equal(out.account.handle, 'x');
734
+ assert.ok(!('is_current' in out.account), 'the SQL flag stays internal');
735
+ });
736
+
737
+ await ta('resolveHandle: a previous-handle hit answers the move to the current handle', async () => {
738
+ const row = { github_id: '7', github_login: 'x', display_name: 'X', avatar_url: null, handle: 'newname', created_at: 'T', is_current: false };
739
+ const pool = mockPool([{ rows: [row], rowCount: 1 }]);
740
+ assert.deepEqual(await P.resolveHandle('oldname', { pool }), { moved_to: 'newname' });
741
+ });
742
+
743
+ await ta('resolveHandle: an invalid handle → null without touching the DB', async () => {
744
+ const pool = mockPool([]);
745
+ assert.equal(await P.resolveHandle('not a handle!', { pool }), null);
746
+ assert.equal(await P.resolveHandle('', { pool }), null);
747
+ assert.equal(pool.calls.length, 0);
748
+ });
749
+
750
+ await ta('getPublicProfileByHandle: identity-band whitelist — github_id/profile_state never cross', async () => {
751
+ const row = { github_id: '7', github_login: 'x', display_name: 'X', avatar_url: 'a', handle: 'x', created_at: 'T', is_current: true };
752
+ const pool = mockPool([
753
+ { rows: [row], rowCount: 1 },
754
+ // getPublicProfileExtras' flags read: hidden → the extras stay null and no
755
+ // rollup query fires.
756
+ { rows: [{ profile_state: 'active', hide_stats: true }], rowCount: 1 },
757
+ ]);
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());
760
+ assert.equal(out.cross_project, null, 'hidden → null, indistinguishable from none');
761
+ const text = JSON.stringify(out);
762
+ assert.doesNotMatch(text, /github_id/, 'the join key stays internal');
763
+ assert.doesNotMatch(text, /profile_state/, 'a 200 IS the addressability signal');
764
+ assert.doesNotMatch(text, /hide_stats/);
765
+ });
766
+
767
+ await ta('getPublicProfileByHandle: unknown → null; moved passes through', async () => {
768
+ const pool1 = mockPool([{ rows: [], rowCount: 0 }]);
769
+ assert.equal(await P.getPublicProfileByHandle('ghost', { pool: pool1 }), null);
770
+ const moved = { github_id: '7', github_login: 'x', display_name: 'X', avatar_url: null, handle: 'now', created_at: 'T', is_current: false };
771
+ const pool2 = mockPool([{ rows: [moved], rowCount: 1 }]);
772
+ assert.deepEqual(await P.getPublicProfileByHandle('then', { pool: pool2 }), { moved_to: 'now' });
773
+ });
774
+
584
775
  console.log(`\nplatform-identity: ${passed} passed, ${failed} failed`);
585
776
  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.171",
3
+ "version": "1.19.173",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.171",
9
+ "version": "1.19.173",
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.171",
3
+ "version": "1.19.173",
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
@@ -769,8 +769,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
769
769
  // landed since 1.19.169 with no explicit bump. run 31965483417. (task 1002620)
770
770
  // 1.19.171 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
771
771
  // landed since 1.19.170 with no explicit bump. run 31967255167. (task 1002620)
772
+ // 1.19.172 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
773
+ // landed since 1.19.171 with no explicit bump. run 31968188738. (task 1002620)
774
+ // 1.19.173 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
775
+ // landed since 1.19.172 with no explicit bump. run 31968656001. (task 1002620)
772
776
  // ---------------------------------------------------------------------------
773
- const CORE_VERSION = '1.19.171'; // CI auto-patch carrier (ADR 0161); see changelog above
777
+ const CORE_VERSION = '1.19.173'; // CI auto-patch carrier (ADR 0161); see changelog above
774
778
 
775
779
  // A namespaced logger so a module's log lines are attributable + consistent.
776
780
  // Usage: const log = api.logger('dev-box'); log.info('mounted');