@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.
- package/.bongos-core.json +37 -22
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +10 -1
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +136 -3
- package/docs/api-reference.md +9 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/platform-identity/migrations/platform_identity_008_handle.sql +70 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +196 -5
- package/modules/platform-identity/routes/profile.js +47 -1
- package/modules/platform-identity/routes/public-profile.js +49 -0
- package/modules/platform-identity/tests/platform-identity.mjs +192 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/handle_machinery.mjs +143 -0
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|