@bongos/core 1.19.170 → 1.19.172

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.
@@ -0,0 +1,70 @@
1
+ -- platform_identity_008_handle.sql — the builder handle (P01, task 1002797 /
2
+ -- goal 1000045; profile-page spec Decision 3, ratified ADR 0171 D8).
3
+ --
4
+ -- The canonical profile URL must not hang on github_login: a login is mutable,
5
+ -- so every GitHub rename would silently 404 the builder's shared links. The
6
+ -- handle is presentation only — github_id stays the join key everywhere.
7
+ --
8
+ -- handle — unique case-insensitively; seeded from github_login.
9
+ -- previous_handle — the single-slot 301 window: kept on rename until the
10
+ -- next rename. R05's resolver 301s it to the current
11
+ -- handle (an exact current-handle match always wins).
12
+ -- handle_changed_at — set on RENAME only, never on seed; the low rate limit
13
+ -- reads it (one rename per window — setAccountHandle).
14
+ --
15
+ -- Seeding walks accounts oldest-first with exactly the go-forward JS algorithm
16
+ -- (ensureHandle in platform-identity.js): lowercase the login, strip anything
17
+ -- outside [a-z0-9-], trim hyphens; empty → builder-<github_id>; reserved or
18
+ -- taken → numeric suffix -2, -3, … . Sequential plpgsql on purpose: a set-based
19
+ -- ROW_NUMBER pass can manufacture collisions against its own suffixes (a
20
+ -- literal foo-2 login vs foo's second seed), and a failed prod migration costs
21
+ -- more than a slow loop over a small table. The reserved list below mirrors
22
+ -- RESERVED_HANDLES in platform-identity.js — keep them in lockstep.
23
+ --
24
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent: the
25
+ -- handle IS NULL guard makes a re-run a no-op.
26
+
27
+ BEGIN;
28
+
29
+ ALTER TABLE platform_identity_accounts
30
+ ADD COLUMN IF NOT EXISTS handle text,
31
+ ADD COLUMN IF NOT EXISTS previous_handle text,
32
+ ADD COLUMN IF NOT EXISTS handle_changed_at timestamptz;
33
+
34
+ CREATE UNIQUE INDEX IF NOT EXISTS platform_identity_accounts_handle_uniq
35
+ ON platform_identity_accounts (lower(handle)) WHERE handle IS NOT NULL;
36
+
37
+ -- The 301-window lookup path (R05 resolves previous handles too).
38
+ CREATE INDEX IF NOT EXISTS platform_identity_accounts_prev_handle_idx
39
+ ON platform_identity_accounts (lower(previous_handle)) WHERE previous_handle IS NOT NULL;
40
+
41
+ DO $$
42
+ DECLARE
43
+ r RECORD;
44
+ base text;
45
+ cand text;
46
+ n int;
47
+ BEGIN
48
+ FOR r IN
49
+ SELECT github_id, github_login
50
+ FROM platform_identity_accounts
51
+ WHERE handle IS NULL
52
+ ORDER BY created_at, id
53
+ LOOP
54
+ base := trim(both '-' from regexp_replace(lower(coalesce(r.github_login, '')), '[^a-z0-9-]', '', 'g'));
55
+ IF base = '' THEN
56
+ base := 'builder-' || r.github_id;
57
+ END IF;
58
+ cand := base;
59
+ n := 1;
60
+ WHILE cand IN ('me', 'new', 'edit', 'settings', 'admin', 'api', 'u')
61
+ OR EXISTS (SELECT 1 FROM platform_identity_accounts WHERE lower(handle) = lower(cand))
62
+ LOOP
63
+ n := n + 1;
64
+ cand := base || '-' || n;
65
+ END LOOP;
66
+ UPDATE platform_identity_accounts SET handle = cand WHERE github_id = r.github_id;
67
+ END LOOP;
68
+ END $$;
69
+
70
+ COMMIT;
@@ -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,133 @@ 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
+ }
622
749
  }
623
750
 
624
751
  // -------------------------------------------------------------------------
@@ -721,6 +848,14 @@ module.exports = {
721
848
  getPublicProfileExtras,
722
849
  getOwnProfileExtras,
723
850
  setAccountHideStats,
851
+ // builder handle (P01)
852
+ RESERVED_HANDLES,
853
+ HANDLE_RENAME_WINDOW_MS,
854
+ normalizeHandle,
855
+ isReservedHandle,
856
+ handleSeedBase,
857
+ ensureHandle,
858
+ setAccountHandle,
724
859
  // codes
725
860
  mintAuthCode,
726
861
  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
  };
@@ -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,141 @@ 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
+
584
722
  console.log(`\nplatform-identity: ${passed} passed, ${failed} failed`);
585
723
  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.170",
3
+ "version": "1.19.172",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.170",
9
+ "version": "1.19.172",
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.170",
3
+ "version": "1.19.172",
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",
@@ -81,6 +81,7 @@ const FIXTURE_FALLBACKS = [
81
81
  [/^blockers\/\d+$/, 'blockers__detail'],
82
82
  [/^analytics\/builder\/\d+$/, 'analytics__builder'],
83
83
  [/^builders\/\d+\/active-claims$/, 'builders__active-claims'],
84
+ [/^builders\/\d+\/profile$/, 'builders__profile'],
84
85
  ];
85
86
 
86
87
  function loadFixture(name) {
package/src/module-api.js CHANGED
@@ -767,8 +767,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
767
767
  // landed since 1.19.168 with no explicit bump. run 31964702865. (task 1002620)
768
768
  // 1.19.170 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
769
769
  // landed since 1.19.169 with no explicit bump. run 31965483417. (task 1002620)
770
+ // 1.19.171 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
770
774
  // ---------------------------------------------------------------------------
771
- const CORE_VERSION = '1.19.170'; // CI auto-patch carrier (ADR 0161); see changelog above
775
+ const CORE_VERSION = '1.19.172'; // CI auto-patch carrier (ADR 0161); see changelog above
772
776
 
773
777
  // A namespaced logger so a module's log lines are attributable + consistent.
774
778
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,115 @@
1
+ // tests/handle_machinery.mjs — P01, task 1002797 (goal 1000045; real DB).
2
+ //
3
+ // Proves migration 008's seeding + the go-forward handle machinery against real
4
+ // SQL (the plpgsql seeding loop and the partial unique index cannot run on
5
+ // pg-mem — the tests/profile_rollup_consent.mjs precedent):
6
+ // 1. seeding dedupes case-colliding logins oldest-first with -2, -3 suffixes;
7
+ // reserved logins never seed bare; an unusable login → builder-<github_id>
8
+ // 2. upsertAccount seeds a NEW account's handle, suffixing past taken ones
9
+ // 3. ensureHandle never clobbers a seeded handle
10
+ // 4. setAccountHandle: rename keeps the old handle in previous_handle and
11
+ // starts the rate-limit clock; a second rename inside the window is
12
+ // refused; an expired window allows; a case-insensitive collision → taken;
13
+ // reserved words are refused
14
+ //
15
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
16
+ // INTEGRATION set so the default DB-free lane never runs it.
17
+ //
18
+ // Run: DATABASE_URL=... node tests/handle_machinery.mjs
19
+
20
+ import { createRequire } from 'node:module';
21
+ import { readFileSync } from 'node:fs';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { dirname, join } from 'node:path';
24
+ import { strict as assert } from 'node:assert';
25
+ const require = createRequire(import.meta.url);
26
+ const { Pool } = require('pg');
27
+ const pi = require('../modules/platform-identity/platform-identity.js');
28
+
29
+ const here = dirname(fileURLToPath(import.meta.url));
30
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
31
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
32
+
33
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
34
+ try {
35
+ await pool.query('SELECT 1');
36
+ } catch (e) {
37
+ console.log(`handle_machinery.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
38
+ await pool.end().catch(() => {});
39
+ process.exit(0);
40
+ }
41
+
42
+ // High-numbered fixture ids so a shared DB can never collide with real data.
43
+ const TWIN_A = 9002797001, TWIN_B = 9002797002, RESV = 9002797003, JUNK = 9002797004, FRESH = 9002797005;
44
+ const ALL_IDS = [TWIN_A, TWIN_B, RESV, JUNK, FRESH];
45
+
46
+ const q = (sql, params) => pool.query(sql, params);
47
+ async function cleanup() {
48
+ await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
49
+ }
50
+
51
+ let pass = 0;
52
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
53
+ const handleOf = async (id) => (await pi.getAccount(id, { pool })).handle;
54
+
55
+ try {
56
+ // The module's actual migrations (idempotent). 008 runs AFTER the fixture
57
+ // rows exist so its seeding loop has NULL handles to seed — exactly the
58
+ // legacy-rows-at-deploy shape.
59
+ await q(migrationSql('platform_identity_001_tables.sql'));
60
+ await q(migrationSql('platform_identity_007_profile_consent.sql'));
61
+ // On a DB where 008 already ran the columns exist and the raw inserts below
62
+ // simply land with handle NULL — the re-run seeds only those. Idempotence is
63
+ // part of what this proves.
64
+ await q(migrationSql('platform_identity_008_handle.sql')).catch(() => {});
65
+ await cleanup();
66
+
67
+ await q(
68
+ `INSERT INTO platform_identity_accounts (github_id, github_login, created_at)
69
+ VALUES ($1, 'P01SeedTwin', now() - interval '10 minutes'),
70
+ ($2, 'p01seedtwin', now() - interval '9 minutes'),
71
+ ($3, 'admin', now() - interval '8 minutes'),
72
+ ($4, '___', now() - interval '7 minutes')`,
73
+ [TWIN_A, TWIN_B, RESV, JUNK],
74
+ );
75
+ await q(migrationSql('platform_identity_008_handle.sql'));
76
+
77
+ // --- 1. seeding ----------------------------------------------------------
78
+ ok((await handleOf(TWIN_A)) === 'p01seedtwin', 'oldest of a case-colliding pair seeds the bare handle');
79
+ ok((await handleOf(TWIN_B)) === 'p01seedtwin-2', 'the younger twin gets the -2 suffix');
80
+ ok((await handleOf(RESV)) === 'admin-2', 'a reserved login never seeds bare — suffixed from -2');
81
+ ok((await handleOf(JUNK)) === `builder-${JUNK}`, 'a login that contributes nothing seeds builder-<github_id>');
82
+
83
+ // --- 2. go-forward seeding via upsertAccount ------------------------------
84
+ const fresh = await pi.upsertAccount({ githubId: FRESH, githubLogin: 'P01SeedTwin' }, { pool });
85
+ ok(fresh.handle === 'p01seedtwin-3', 'a new account collides past every taken suffix');
86
+
87
+ // --- 3. ensureHandle never clobbers ---------------------------------------
88
+ const again = await pi.ensureHandle({ githubId: FRESH, githubLogin: 'renamed-login' }, { pool });
89
+ ok(again === 'p01seedtwin-3', 'ensureHandle on a seeded account returns the existing handle untouched');
90
+
91
+ // --- 4. renames -----------------------------------------------------------
92
+ const r1 = await pi.setAccountHandle({ githubId: FRESH, handle: 'P01-Renamed' }, { pool });
93
+ ok(r1.handle === 'p01-renamed' && r1.previous_handle === 'p01seedtwin-3',
94
+ 'rename lowercases, keeps the old handle in the 301 slot');
95
+
96
+ const r2 = await pi.setAccountHandle({ githubId: FRESH, handle: 'p01-again' }, { pool });
97
+ ok(r2.error === 'rate_limited' && !!r2.retry_at, 'a second rename inside the window is refused with retry_at');
98
+ ok((await handleOf(FRESH)) === 'p01-renamed', 'the refused rename changed nothing');
99
+
100
+ await q(`UPDATE platform_identity_accounts SET handle_changed_at = now() - interval '8 days' WHERE github_id = $1`, [FRESH]);
101
+ const r3 = await pi.setAccountHandle({ githubId: FRESH, handle: 'p01-again' }, { pool });
102
+ ok(r3.handle === 'p01-again' && r3.previous_handle === 'p01-renamed', 'an expired window allows the rename; the 301 slot rolls forward');
103
+
104
+ await q(`UPDATE platform_identity_accounts SET handle_changed_at = now() - interval '8 days' WHERE github_id = $1`, [FRESH]);
105
+ const r4 = await pi.setAccountHandle({ githubId: FRESH, handle: 'P01SEEDTWIN' }, { pool });
106
+ ok(r4.error === 'handle_taken', 'a case-insensitive collision with a live handle is refused');
107
+
108
+ const r5 = await pi.setAccountHandle({ githubId: FRESH, handle: 'u' }, { pool });
109
+ ok(r5.error === 'reserved_handle', 'reserved words are refused on rename');
110
+
111
+ console.log(`\nhandle_machinery: ${pass} assertions passed`);
112
+ } finally {
113
+ await cleanup();
114
+ await pool.end().catch(() => {});
115
+ }