@bongos/core 1.19.168 → 1.19.170
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 +45 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +8 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +128 -4
- package/docs/api-reference.md +4 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/settings.html +14 -0
- package/modules/hall-ui/public/settings.js +84 -0
- package/modules/platform-identity/migrations/platform_identity_007_profile_consent.sql +32 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +173 -9
- package/modules/platform-identity/routes/profile.js +77 -0
- package/modules/platform-identity/routes/scouting.js +7 -6
- package/modules/platform-identity/routes/sso.js +38 -0
- package/modules/platform-identity/tests/platform-identity.mjs +159 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +1 -1
- package/src/bongos/auth.js +18 -6
- package/src/bongos/routes/builders.js +25 -5
- package/src/module-api.js +5 -1
- package/tests/profile_rollup_consent.mjs +187 -0
- package/tests/profile_route.mjs +86 -1
- package/tests/scouting_rollup_fanout.mjs +6 -2
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- platform_identity_007_profile_consent.sql — profile consent state + the C2
|
|
2
|
+
-- hide flag (ADR 0171 D3/D4, task 1002278 / goal 1000045).
|
|
3
|
+
--
|
|
4
|
+
-- profile_state is FAIL-CLOSED consent: an account is 'active' iff a human was
|
|
5
|
+
-- demonstrably at the hub — the one path that proves it is a hub-session
|
|
6
|
+
-- /sso/authorize (where T&C acceptance can later be captured, task 1002913).
|
|
7
|
+
-- Device-poll and membership check-in create 'provisional' accounts; the
|
|
8
|
+
-- activity-rollup endpoint creates no account at all. Public surfaces (the
|
|
9
|
+
-- profile port, /people, /scouting) read only 'active' accounts.
|
|
10
|
+
--
|
|
11
|
+
-- Deliberately NO backfill joining core `builders`: that join is type-unsafe
|
|
12
|
+
-- ('system:bfg'-style text ids vs bigint) and would put a core-table read inside
|
|
13
|
+
-- a module migration (ADR 0083 boundary). All existing accounts start
|
|
14
|
+
-- 'provisional' and lazily upgrade on their next hub-session authorize.
|
|
15
|
+
--
|
|
16
|
+
-- hide_stats is the account-global C2 opt-out — enforced in the module's read
|
|
17
|
+
-- functions (ADR 0171 D2/D4), written by an own-scoped PATCH (task 1002279, R03).
|
|
18
|
+
--
|
|
19
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
|
|
20
|
+
|
|
21
|
+
BEGIN;
|
|
22
|
+
|
|
23
|
+
ALTER TABLE platform_identity_accounts
|
|
24
|
+
ADD COLUMN IF NOT EXISTS profile_state text NOT NULL DEFAULT 'provisional'
|
|
25
|
+
CHECK (profile_state IN ('provisional', 'active')),
|
|
26
|
+
ADD COLUMN IF NOT EXISTS hide_stats boolean NOT NULL DEFAULT false;
|
|
27
|
+
|
|
28
|
+
-- The public-surface predicate (profile_state = 'active' AND NOT hide_stats)
|
|
29
|
+
-- filters per-builder; accounts are looked up by github_id (already indexed via
|
|
30
|
+
-- the UNIQUE constraint), so no new index is needed.
|
|
31
|
+
|
|
32
|
+
COMMIT;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"maintainer": "Cloud Bongos maintainers",
|
|
11
11
|
"maintenance": { "status": "core-maintained" },
|
|
12
12
|
"contributes": {
|
|
13
|
-
"routes": ["sso", "projects", "my-projects", "scouting"],
|
|
13
|
+
"routes": ["sso", "projects", "my-projects", "scouting", "profile"],
|
|
14
14
|
"migrations": true,
|
|
15
15
|
"pollers": ["code-sweep"]
|
|
16
16
|
},
|
|
@@ -101,17 +101,24 @@ function redirectUriAllowed(client, redirectUri) {
|
|
|
101
101
|
|
|
102
102
|
// Upsert the platform account for a GitHub identity (keyed on the stable
|
|
103
103
|
// github_id). Called at /sso/authorize for the signed-in hub user. Identity only.
|
|
104
|
-
|
|
104
|
+
//
|
|
105
|
+
// `activate` is the ONE consent upgrade path (ADR 0171 D3): an account becomes
|
|
106
|
+
// profile_state='active' iff touched by a hub-session /sso/authorize — the path
|
|
107
|
+
// where a human is demonstrably at the hub. Every other caller (device-poll,
|
|
108
|
+
// membership check-in) leaves the fail-closed 'provisional' default; activation
|
|
109
|
+
// is one-way (a later non-activating upsert never demotes an active account).
|
|
110
|
+
async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, activate = false }, { pool = defaultPool } = {}) {
|
|
105
111
|
const { rows } = await pool.query(
|
|
106
|
-
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url)
|
|
107
|
-
VALUES ($1, $2, $3, $4)
|
|
112
|
+
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
|
|
113
|
+
VALUES ($1, $2, $3, $4, CASE WHEN $5::boolean THEN 'active' ELSE 'provisional' END)
|
|
108
114
|
ON CONFLICT (github_id) DO UPDATE SET
|
|
109
115
|
github_login = EXCLUDED.github_login,
|
|
110
116
|
display_name = EXCLUDED.display_name,
|
|
111
117
|
avatar_url = EXCLUDED.avatar_url,
|
|
118
|
+
profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END,
|
|
112
119
|
updated_at = now()
|
|
113
|
-
RETURNING id, github_id, github_login, display_name, avatar_url`,
|
|
114
|
-
[githubId, githubLogin, displayName ?? null, avatarUrl ?? null],
|
|
120
|
+
RETURNING id, github_id, github_login, display_name, avatar_url, profile_state`,
|
|
121
|
+
[githubId, githubLogin, displayName ?? null, avatarUrl ?? null, !!activate],
|
|
115
122
|
);
|
|
116
123
|
return rows[0];
|
|
117
124
|
}
|
|
@@ -131,7 +138,7 @@ async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
|
|
|
131
138
|
|
|
132
139
|
async function getAccount(githubId, { pool = defaultPool } = {}) {
|
|
133
140
|
const { rows } = await pool.query(
|
|
134
|
-
`SELECT id, github_id, github_login, display_name, avatar_url, created_at, updated_at
|
|
141
|
+
`SELECT id, github_id, github_login, display_name, avatar_url, profile_state, hide_stats, created_at, updated_at
|
|
135
142
|
FROM platform_identity_accounts WHERE github_id = $1`,
|
|
136
143
|
[githubId],
|
|
137
144
|
);
|
|
@@ -306,6 +313,19 @@ async function upsertMembership({ clientId, githubId, role = 'builder' }, { pool
|
|
|
306
313
|
return rows[0];
|
|
307
314
|
}
|
|
308
315
|
|
|
316
|
+
// One membership row for (project, builder), any role — or null. Backs the
|
|
317
|
+
// activity-rollup membership requirement (ADR 0171 D7): a client may only report
|
|
318
|
+
// activity for a builder who actually has a membership on that project.
|
|
319
|
+
async function getMembership(clientId, githubId, { pool = defaultPool } = {}) {
|
|
320
|
+
const { rows } = await pool.query(
|
|
321
|
+
`SELECT id, client_id, github_id, role, joined_at, last_active
|
|
322
|
+
FROM platform_identity_project_memberships
|
|
323
|
+
WHERE client_id = $1 AND github_id = $2`,
|
|
324
|
+
[clientId, githubId],
|
|
325
|
+
);
|
|
326
|
+
return rows[0] || null;
|
|
327
|
+
}
|
|
328
|
+
|
|
309
329
|
// All memberships for one account, newest-active first, joined to the project's
|
|
310
330
|
// display fields (name/origin from sso_clients). Powers "My Projects" (task #1002112).
|
|
311
331
|
async function listMembershipsForAccount(githubId, { pool = defaultPool } = {}) {
|
|
@@ -402,10 +422,23 @@ async function setClientStatus(clientId, status, { pool = defaultPool } = {}) {
|
|
|
402
422
|
// Cross-project builder activity (the scouting data plane — ADR 0141 §4)
|
|
403
423
|
// -------------------------------------------------------------------------
|
|
404
424
|
|
|
425
|
+
// Rollup payload caps (ADR 0171 D7): the reported rank/disciplines are third-party
|
|
426
|
+
// self-report headed for a public portfolio, so they are validated HERE, next to
|
|
427
|
+
// the data, where no caller can forget. Rank must be one of the four core rank
|
|
428
|
+
// keys (else stored null — a label is never invented); disciplines are string
|
|
429
|
+
// elements only, bounded in count and length.
|
|
430
|
+
const MAX_ROLLUP_DISCIPLINES = 8;
|
|
431
|
+
const MAX_DISCIPLINE_LENGTH = 32;
|
|
432
|
+
|
|
405
433
|
// Upsert one project's rollup for one builder (keyed by client_id + github_id).
|
|
406
434
|
// Descriptive analytics — never authority. Refreshes last_active on each report.
|
|
407
435
|
async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [] }, { pool = defaultPool } = {}) {
|
|
408
|
-
|
|
436
|
+
// api.LIVE_RANK_LADDER is a published doorway export (src/module-api.js, a
|
|
437
|
+
// lazy getter over db-kernel's canonical four-rank ladder).
|
|
438
|
+
const safeRank = api.LIVE_RANK_LADDER.includes(rank) ? rank : null;
|
|
439
|
+
const disc = (Array.isArray(disciplines) ? disciplines : [])
|
|
440
|
+
.filter((d) => typeof d === 'string' && d.length > 0 && d.length <= MAX_DISCIPLINE_LENGTH)
|
|
441
|
+
.slice(0, MAX_ROLLUP_DISCIPLINES);
|
|
409
442
|
const { rows } = await pool.query(
|
|
410
443
|
`INSERT INTO platform_identity_builder_project_activity
|
|
411
444
|
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, last_active, updated_at)
|
|
@@ -415,7 +448,7 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
|
|
|
415
448
|
rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
|
|
416
449
|
last_active = now(), updated_at = now()
|
|
417
450
|
RETURNING id, client_id, github_id`,
|
|
418
|
-
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0,
|
|
451
|
+
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc],
|
|
419
452
|
);
|
|
420
453
|
return rows[0];
|
|
421
454
|
}
|
|
@@ -430,6 +463,12 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
|
|
|
430
463
|
// (task 1002911). So scalars aggregate in `agg` over the un-fanned table, and the
|
|
431
464
|
// disciplines union is computed separately in `disc`. Proven against real SQL by
|
|
432
465
|
// tests/scouting_rollup_fanout.mjs.
|
|
466
|
+
//
|
|
467
|
+
// Consent by construction (ADR 0171 D2/D3): only builders whose account is
|
|
468
|
+
// profile_state='active' AND NOT hide_stats appear — /people and /scouting are
|
|
469
|
+
// live cross-project surfaces, so the predicate lives IN the query, not in any
|
|
470
|
+
// caller's memory. Provisional (no hub-session consent) and hidden builders drop
|
|
471
|
+
// out entirely; the projection stays an explicit whitelist (no raw-row spread).
|
|
433
472
|
async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
|
|
434
473
|
const { rows } = await pool.query(
|
|
435
474
|
`WITH agg AS (
|
|
@@ -440,6 +479,11 @@ async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
|
|
|
440
479
|
sum(a.karma)::int AS total_karma,
|
|
441
480
|
max(a.last_active) AS last_active
|
|
442
481
|
FROM platform_identity_builder_project_activity a
|
|
482
|
+
WHERE EXISTS (
|
|
483
|
+
SELECT 1 FROM platform_identity_accounts pa
|
|
484
|
+
WHERE pa.github_id = a.github_id
|
|
485
|
+
AND pa.profile_state = 'active'
|
|
486
|
+
AND NOT pa.hide_stats)
|
|
443
487
|
GROUP BY a.github_id
|
|
444
488
|
), disc AS (
|
|
445
489
|
SELECT x.github_id, array_agg(DISTINCT x.d) AS disciplines
|
|
@@ -463,6 +507,120 @@ async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
|
|
|
463
507
|
return rows;
|
|
464
508
|
}
|
|
465
509
|
|
|
510
|
+
// Is this a value we can safely bind against the bigint github_id column?
|
|
511
|
+
// Core builders include 'system:bfg'-style TEXT principals; binding one would
|
|
512
|
+
// throw a bigint cast error mid-request, so every profile read guards here and
|
|
513
|
+
// returns null instead (ADR 0171 D2).
|
|
514
|
+
function isNumericGithubId(githubId) {
|
|
515
|
+
return /^\d+$/.test(String(githubId ?? ''));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// One builder's cross-project record (ADR 0171 D2/D6/D7, task 1002278) — the
|
|
519
|
+
// whitelist-shaped aggregate the profile surfaces render. Aggregation mirrors
|
|
520
|
+
// listScoutingRollup's corrected shape: scalars over the UN-FANNED activity
|
|
521
|
+
// table, the disciplines union computed separately (never a pre-GROUP-BY unnest
|
|
522
|
+
// fan-out — bug task 1002911). Per-project rows join the module-internal
|
|
523
|
+
// client_id → sso_clients.origin → projects.origin two-hop; a project is NAMED
|
|
524
|
+
// only when its catalog row is publicly listed today (featured + active — the
|
|
525
|
+
// exact set listFeaturedProjects publishes; goal 1000046's visibility states
|
|
526
|
+
// will widen this predicate when they land). Everything else is counted in
|
|
527
|
+
// more_projects, never named ("the aggregate is not the leak, the name is").
|
|
528
|
+
// client_id never appears in the returned shape.
|
|
529
|
+
async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
530
|
+
if (!isNumericGithubId(githubId)) return null;
|
|
531
|
+
const { rows: aggRows } = await pool.query(
|
|
532
|
+
`WITH agg AS (
|
|
533
|
+
SELECT count(*)::int AS projects,
|
|
534
|
+
COALESCE(sum(a.credits), 0)::int AS credits,
|
|
535
|
+
COALESCE(sum(a.tasks_shipped), 0)::int AS works_shipped,
|
|
536
|
+
COALESCE(sum(a.karma), 0)::int AS karma,
|
|
537
|
+
max(a.last_active) AS last_active
|
|
538
|
+
FROM platform_identity_builder_project_activity a
|
|
539
|
+
WHERE a.github_id = $1
|
|
540
|
+
), disc AS (
|
|
541
|
+
SELECT array_agg(DISTINCT x.d) AS disciplines
|
|
542
|
+
FROM (SELECT unnest(a.disciplines) AS d
|
|
543
|
+
FROM platform_identity_builder_project_activity a
|
|
544
|
+
WHERE a.github_id = $1) x
|
|
545
|
+
WHERE x.d IS NOT NULL
|
|
546
|
+
)
|
|
547
|
+
SELECT agg.projects, agg.credits, agg.works_shipped, agg.karma,
|
|
548
|
+
agg.last_active, disc.disciplines
|
|
549
|
+
FROM agg LEFT JOIN disc ON true`,
|
|
550
|
+
[githubId],
|
|
551
|
+
);
|
|
552
|
+
const agg = aggRows[0];
|
|
553
|
+
const { rows: projRows } = await pool.query(
|
|
554
|
+
`SELECT p.name, p.origin, a.rank_held,
|
|
555
|
+
(p.id IS NOT NULL AND p.featured AND p.status = 'active') AS is_public
|
|
556
|
+
FROM platform_identity_builder_project_activity a
|
|
557
|
+
LEFT JOIN platform_identity_sso_clients c ON c.client_id = a.client_id
|
|
558
|
+
LEFT JOIN platform_identity_projects p ON p.origin = c.origin
|
|
559
|
+
WHERE a.github_id = $1
|
|
560
|
+
ORDER BY a.last_active DESC`,
|
|
561
|
+
[githubId],
|
|
562
|
+
);
|
|
563
|
+
const named = projRows
|
|
564
|
+
.filter((r) => r.is_public)
|
|
565
|
+
.map((r) => ({ name: r.name, origin: r.origin, rank_held: r.rank_held }));
|
|
566
|
+
return {
|
|
567
|
+
totals: {
|
|
568
|
+
projects: agg.projects,
|
|
569
|
+
credits: agg.credits,
|
|
570
|
+
works_shipped: agg.works_shipped,
|
|
571
|
+
karma: agg.karma,
|
|
572
|
+
},
|
|
573
|
+
disciplines: agg.disciplines || [],
|
|
574
|
+
last_active: agg.last_active,
|
|
575
|
+
projects: named,
|
|
576
|
+
more_projects: projRows.length - named.length,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// The PUBLIC read behind the 'platform-identity.hub' port (ADR 0171 D1/D2):
|
|
581
|
+
// null unless the account exists, consented (profile_state='active') and is not
|
|
582
|
+
// hidden (hide_stats) — the fail-closed rule every public surface inherits by
|
|
583
|
+
// construction. A null is deliberately indistinguishable between "no account",
|
|
584
|
+
// "provisional" and "hidden" (no partial numbers; a count plus a hidden total is
|
|
585
|
+
// a leak by subtraction).
|
|
586
|
+
async function getPublicProfileExtras(githubId, { pool = defaultPool } = {}) {
|
|
587
|
+
if (!isNumericGithubId(githubId)) return null;
|
|
588
|
+
const { rows } = await pool.query(
|
|
589
|
+
`SELECT profile_state, hide_stats FROM platform_identity_accounts WHERE github_id = $1`,
|
|
590
|
+
[githubId],
|
|
591
|
+
);
|
|
592
|
+
const acct = rows[0];
|
|
593
|
+
if (!acct || acct.profile_state !== 'active' || acct.hide_stats) return null;
|
|
594
|
+
return getRollupForBuilder(githubId, { pool });
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// The SELF read (ADR 0171 D2's first narrow side door): a builder inspects their
|
|
598
|
+
// own numbers in Settings regardless of profile_state/hide_stats. Callers MUST
|
|
599
|
+
// key this by the authenticated session's github_id, never a caller-named one.
|
|
600
|
+
async function getOwnProfileExtras(githubId, { pool = defaultPool } = {}) {
|
|
601
|
+
return getRollupForBuilder(githubId, { pool });
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// The C2 hide-stats WRITE (ADR 0171 D4, task 1002279): own-scoped — callers key
|
|
605
|
+
// this by the AUTHENTICATED session's github_id. Upserts so a hub builder who
|
|
606
|
+
// never federated (no account row yet) can still hide ahead of time: the insert
|
|
607
|
+
// creates a fail-closed provisional account already carrying the flag; the
|
|
608
|
+
// UPDATE branch touches ONLY hide_stats (never identity fields, never
|
|
609
|
+
// profile_state — activation stays /sso/authorize's alone, D3).
|
|
610
|
+
async function setAccountHideStats({ githubId, githubLogin, displayName, avatarUrl, hide }, { pool = defaultPool } = {}) {
|
|
611
|
+
if (!isNumericGithubId(githubId)) return null;
|
|
612
|
+
const { rows } = await pool.query(
|
|
613
|
+
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, hide_stats)
|
|
614
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
615
|
+
ON CONFLICT (github_id) DO UPDATE SET
|
|
616
|
+
hide_stats = EXCLUDED.hide_stats,
|
|
617
|
+
updated_at = now()
|
|
618
|
+
RETURNING github_id, github_login, profile_state, hide_stats`,
|
|
619
|
+
[githubId, githubLogin, displayName ?? null, avatarUrl ?? null, !!hide],
|
|
620
|
+
);
|
|
621
|
+
return rows[0];
|
|
622
|
+
}
|
|
623
|
+
|
|
466
624
|
// -------------------------------------------------------------------------
|
|
467
625
|
// Authorization codes (single-use, short-lived, bound)
|
|
468
626
|
// -------------------------------------------------------------------------
|
|
@@ -548,15 +706,21 @@ module.exports = {
|
|
|
548
706
|
// memberships
|
|
549
707
|
MEMBERSHIP_ROLES,
|
|
550
708
|
upsertMembership,
|
|
709
|
+
getMembership,
|
|
551
710
|
listMembershipsForAccount,
|
|
552
711
|
removeMembership,
|
|
553
712
|
// invitations
|
|
554
713
|
inviteToProject,
|
|
555
714
|
listPendingInvitesForAccount,
|
|
556
715
|
resolveInvite,
|
|
557
|
-
// cross-project activity + scouting
|
|
716
|
+
// cross-project activity + scouting + profile rollup
|
|
558
717
|
upsertActivity,
|
|
559
718
|
listScoutingRollup,
|
|
719
|
+
isNumericGithubId,
|
|
720
|
+
getRollupForBuilder,
|
|
721
|
+
getPublicProfileExtras,
|
|
722
|
+
getOwnProfileExtras,
|
|
723
|
+
setAccountHideStats,
|
|
560
724
|
// codes
|
|
561
725
|
mintAuthCode,
|
|
562
726
|
redeemAuthCode,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// modules/platform-identity/routes/profile.js — the builder's OWN cross-project
|
|
2
|
+
// profile controls (ADR 0171 D4, task 1002279 / goal 1000045). Two own-scoped
|
|
3
|
+
// endpoints behind a live session; both key on req.builder.github_id — never a
|
|
4
|
+
// caller-named id — so a builder can only ever read or flip their own flag.
|
|
5
|
+
//
|
|
6
|
+
// GET /me/cross-project — the Settings self view: the hide flag + consent
|
|
7
|
+
// state + the builder's own rollup, served regardless
|
|
8
|
+
// of flags (getOwnProfileExtras — a hidden builder
|
|
9
|
+
// inspects their numbers HERE, not on the public
|
|
10
|
+
// route, which stays viewer-independent).
|
|
11
|
+
// PATCH /me/cross-project — flip hide_stats. Takes effect immediately on every
|
|
12
|
+
// public surface (profile cross_project, /people,
|
|
13
|
+
// /scouting): enforcement lives in the module's read
|
|
14
|
+
// functions (D2) and the hub profile response is
|
|
15
|
+
// no-store (D5), so there is no cache to outwait.
|
|
16
|
+
//
|
|
17
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const express = require('express');
|
|
21
|
+
const api = require('../../../src/module-api');
|
|
22
|
+
const pi = require('../platform-identity');
|
|
23
|
+
|
|
24
|
+
const { pool, requireBuilder, validateOrRespond } = api;
|
|
25
|
+
|
|
26
|
+
module.exports = function profileRoutes() {
|
|
27
|
+
const router = express.Router();
|
|
28
|
+
|
|
29
|
+
// rank: any-builder — the caller's OWN cross-project record + hide flag (own data).
|
|
30
|
+
router.get('/me/cross-project', requireBuilder, async (req, res) => {
|
|
31
|
+
try {
|
|
32
|
+
// Core builders include system:* principals whose github_id is not numeric;
|
|
33
|
+
// they have no platform identity — answer the empty shape, not a 500.
|
|
34
|
+
if (!pi.isNumericGithubId(req.builder.github_id)) {
|
|
35
|
+
return res.json({ hide_stats: false, profile_state: null, cross_project: null });
|
|
36
|
+
}
|
|
37
|
+
const [acct, extras] = await Promise.all([
|
|
38
|
+
pi.getAccount(req.builder.github_id, { pool }),
|
|
39
|
+
pi.getOwnProfileExtras(req.builder.github_id, { pool }),
|
|
40
|
+
]);
|
|
41
|
+
res.json({
|
|
42
|
+
hide_stats: !!(acct && acct.hide_stats),
|
|
43
|
+
profile_state: acct ? acct.profile_state : null,
|
|
44
|
+
cross_project: extras,
|
|
45
|
+
});
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error('[platform-identity] GET /me/cross-project', err);
|
|
48
|
+
res.fail('cross_project_read_failed', { status: 500, message: 'internal error' });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// rank: any-builder — flip the caller's OWN hide flag (C2's self-serve opt-out;
|
|
53
|
+
// own data, keyed on the authenticated github_id).
|
|
54
|
+
router.patch('/me/cross-project', requireBuilder, async (req, res) => {
|
|
55
|
+
if (validateOrRespond(req, res, {
|
|
56
|
+
hide_stats: { required: true, type: 'boolean' },
|
|
57
|
+
})) return;
|
|
58
|
+
try {
|
|
59
|
+
if (!pi.isNumericGithubId(req.builder.github_id)) {
|
|
60
|
+
return res.fail('no_platform_identity', 400);
|
|
61
|
+
}
|
|
62
|
+
const row = await pi.setAccountHideStats({
|
|
63
|
+
githubId: req.builder.github_id,
|
|
64
|
+
githubLogin: req.builder.github_login,
|
|
65
|
+
displayName: req.builder.display_name,
|
|
66
|
+
avatarUrl: req.builder.avatar_url,
|
|
67
|
+
hide: req.body.hide_stats,
|
|
68
|
+
}, { pool });
|
|
69
|
+
res.json({ hide_stats: !!(row && row.hide_stats) });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error('[platform-identity] PATCH /me/cross-project', err);
|
|
72
|
+
res.fail('cross_project_write_failed', { status: 500, message: 'internal error' });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return router;
|
|
77
|
+
};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// modules/platform-identity/routes/scouting.js — the INTERNAL
|
|
2
|
-
//
|
|
3
|
-
// ALL hosted projects from platform_identity_builder_project_activity.
|
|
4
|
-
// talent tool for the platform operator — NOT
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// modules/platform-identity/routes/scouting.js — the INTERNAL cross-project
|
|
2
|
+
// scouting view (ADR 0141 §4, task #1002114; Metic floor per ADR 0157). Ranks
|
|
3
|
+
// builders across ALL hosted projects from platform_identity_builder_project_activity.
|
|
4
|
+
// A talent tool for the platform operator — NOT authority (the rollup is
|
|
5
|
+
// descriptive; authority stays local, ADR 0141 / ADR 0016). Consent is enforced
|
|
6
|
+
// IN listScoutingRollup itself (ADR 0171 D2/D3): only profile_state='active',
|
|
7
|
+
// non-hidden builders appear, so this surface honors hide by construction.
|
|
7
8
|
//
|
|
8
9
|
// Reaches core only through the doorway (ADR 0083).
|
|
9
10
|
'use strict';
|
|
@@ -94,7 +94,26 @@ async function fanOutBackchannelLogout(githubId) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Register the `platform-identity.hub` kernel PORT (ADR 0171 D1/D2) — the
|
|
98
|
+
// declared-but-previously-unwired port from module.json `provides`. The core
|
|
99
|
+
// profile route (src/bongos/routes/builders.js) resolves it beside 'reward' to
|
|
100
|
+
// attach the cross_project section; on any instance without this module the
|
|
101
|
+
// resolve returns null and the section is absent (degrade, don't 500). Shapes
|
|
102
|
+
// are whitelisted INSIDE the module (client_id never crosses the port).
|
|
103
|
+
// Registered from the route factory (the one boot hook that runs both live AND
|
|
104
|
+
// when a test rebuilds the GDS router), hasProvider-guarded so re-instantiation
|
|
105
|
+
// never trips the single-provider guard — the builder-settings pattern.
|
|
106
|
+
function registerSeams() {
|
|
107
|
+
if (!api.hasProvider('platform-identity.hub')) {
|
|
108
|
+
api.registerProvider('platform-identity.hub', {
|
|
109
|
+
getPublicProfileExtras: (githubId) => pi.getPublicProfileExtras(githubId, { pool }),
|
|
110
|
+
getOwnProfileExtras: (githubId) => pi.getOwnProfileExtras(githubId, { pool }),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
module.exports = function ssoRoutes() {
|
|
116
|
+
registerSeams();
|
|
98
117
|
const router = express.Router();
|
|
99
118
|
|
|
100
119
|
// Wire the federated single-logout fan-out to core's `builder.logout` event,
|
|
@@ -170,12 +189,16 @@ module.exports = function ssoRoutes() {
|
|
|
170
189
|
|
|
171
190
|
// A live hub session — record/refresh the platform account and mint a
|
|
172
191
|
// single-use code bound to (client_id, github_id, redirect_uri).
|
|
192
|
+
// `activate: true` — this is THE consent upgrade path (ADR 0171 D3): a
|
|
193
|
+
// hub-session authorize is the one place a human is demonstrably at the hub,
|
|
194
|
+
// so the account's profile_state lazily flips provisional → active here.
|
|
173
195
|
try {
|
|
174
196
|
await pi.upsertAccount({
|
|
175
197
|
githubId: session.github_id,
|
|
176
198
|
githubLogin: session.github_login,
|
|
177
199
|
displayName: session.display_name,
|
|
178
200
|
avatarUrl: session.avatar_url,
|
|
201
|
+
activate: true,
|
|
179
202
|
}, { pool });
|
|
180
203
|
const code = await pi.mintAuthCode({
|
|
181
204
|
clientId,
|
|
@@ -382,6 +405,21 @@ module.exports = function ssoRoutes() {
|
|
|
382
405
|
if (!client || client.status !== 'active' || !pi.clientSecretValid(client, b.client_secret)) {
|
|
383
406
|
return res.fail('invalid_client', 401);
|
|
384
407
|
}
|
|
408
|
+
// Impersonation hardening (ADR 0171 D7) — this data now feeds a PUBLIC
|
|
409
|
+
// portfolio, so a client may only report activity for a builder who
|
|
410
|
+
// actually holds a membership on that project. Non-numeric github_id is
|
|
411
|
+
// rejected before it can hit the bigint column; a 'pending' row is an
|
|
412
|
+
// invite pre-seed, not a membership (listMembershipsForAccount excludes
|
|
413
|
+
// it), so it does not satisfy the requirement. The instance sequences its
|
|
414
|
+
// check-in BEFORE this rollup (src/bongos/auth.js), so the legit first
|
|
415
|
+
// sign-in always has its membership row by the time this arrives.
|
|
416
|
+
if (!pi.isNumericGithubId(b.github_id)) {
|
|
417
|
+
return res.fail('invalid_github_id', 400);
|
|
418
|
+
}
|
|
419
|
+
const membership = await pi.getMembership(b.client_id, b.github_id, { pool });
|
|
420
|
+
if (!membership || membership.role === 'pending') {
|
|
421
|
+
return res.fail('membership_required', 409);
|
|
422
|
+
}
|
|
385
423
|
await pi.upsertActivity({
|
|
386
424
|
clientId: b.client_id,
|
|
387
425
|
githubId: b.github_id,
|
|
@@ -128,8 +128,8 @@ t('a service principal (system_role set) may NOT federate', () => {
|
|
|
128
128
|
|
|
129
129
|
console.log('\naccounts:');
|
|
130
130
|
|
|
131
|
-
await ta('upsertAccount upserts on github_id and returns the row', async () => {
|
|
132
|
-
const row = { id: '1', github_id: '42', github_login: 'octocat', display_name: 'Octo', avatar_url: 'a' };
|
|
131
|
+
await ta('upsertAccount upserts on github_id and returns the row (default: NOT activating)', async () => {
|
|
132
|
+
const row = { id: '1', github_id: '42', github_login: 'octocat', display_name: 'Octo', avatar_url: 'a', profile_state: 'provisional' };
|
|
133
133
|
const pool = mockPool([{ rows: [row] }]);
|
|
134
134
|
const out = await P.upsertAccount(
|
|
135
135
|
{ githubId: 42, githubLogin: 'octocat', displayName: 'Octo', avatarUrl: 'a' }, { pool });
|
|
@@ -137,7 +137,20 @@ await ta('upsertAccount upserts on github_id and returns the row', async () => {
|
|
|
137
137
|
const c = pool.calls[0];
|
|
138
138
|
assert.match(c.text, /INSERT INTO platform_identity_accounts/);
|
|
139
139
|
assert.match(c.text, /ON CONFLICT \(github_id\) DO UPDATE/);
|
|
140
|
-
assert.deepEqual(c.params, [42, 'octocat', 'Octo', 'a']);
|
|
140
|
+
assert.deepEqual(c.params, [42, 'octocat', 'Octo', 'a', false]);
|
|
141
|
+
});
|
|
142
|
+
|
|
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' }] }]);
|
|
145
|
+
await P.upsertAccount({ githubId: 42, githubLogin: 'octocat', activate: true }, { pool });
|
|
146
|
+
await P.upsertAccount({ githubId: 42, githubLogin: 'octocat' }, { pool });
|
|
147
|
+
const sql = pool.calls[0].text;
|
|
148
|
+
// INSERT: activate decides active vs the fail-closed provisional default.
|
|
149
|
+
assert.match(sql, /CASE WHEN \$5::boolean THEN 'active' ELSE 'provisional' END/);
|
|
150
|
+
// UPDATE: a non-activating upsert must KEEP the stored profile_state (never demote).
|
|
151
|
+
assert.match(sql, /CASE WHEN \$5::boolean THEN 'active' ELSE platform_identity_accounts\.profile_state END/);
|
|
152
|
+
assert.equal(pool.calls[0].params[4], true, 'activate:true passes true');
|
|
153
|
+
assert.equal(pool.calls[1].params[4], false, 'default passes false');
|
|
141
154
|
});
|
|
142
155
|
|
|
143
156
|
console.log('\nSSO client registry:');
|
|
@@ -334,6 +347,44 @@ await ta('upsertActivity upserts per (client_id, github_id) with the talent snap
|
|
|
334
347
|
assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer']]);
|
|
335
348
|
});
|
|
336
349
|
|
|
350
|
+
await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-ladder rank → null, disciplines validated + capped', async () => {
|
|
351
|
+
const pool = mockPool([{ rows: [{ id: '1' }] }]);
|
|
352
|
+
await P.upsertActivity({
|
|
353
|
+
clientId: 'c1', githubId: 42,
|
|
354
|
+
rank: 'Supreme Overlord', // not one of the four core rank keys → stored null
|
|
355
|
+
disciplines: [
|
|
356
|
+
'engineer', 42, null, { evil: true }, // non-strings dropped
|
|
357
|
+
'x'.repeat(33), // over-length dropped
|
|
358
|
+
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', // valid tail — capped to 8 total
|
|
359
|
+
],
|
|
360
|
+
}, { pool });
|
|
361
|
+
const c = pool.calls[0];
|
|
362
|
+
assert.equal(c.params[5], null, 'an invented rank label is never stored');
|
|
363
|
+
const disc = c.params[6];
|
|
364
|
+
assert.ok(disc.length <= 8, `disciplines capped at 8 (got ${disc.length})`);
|
|
365
|
+
assert.ok(disc.every((d) => typeof d === 'string' && d.length > 0 && d.length <= 32),
|
|
366
|
+
'only bounded string elements survive');
|
|
367
|
+
assert.equal(disc[0], 'engineer', 'valid elements pass through in order');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await ta('upsertActivity keeps a legitimate core rank key', async () => {
|
|
371
|
+
const pool = mockPool([{ rows: [{ id: '1' }] }]);
|
|
372
|
+
await P.upsertActivity({ clientId: 'c1', githubId: 42, rank: 'archon', disciplines: [] }, { pool });
|
|
373
|
+
assert.equal(pool.calls[0].params[5], 'archon');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
await ta('getMembership reads one (client, builder) row — the rollup membership requirement', async () => {
|
|
377
|
+
const row = { id: '9', client_id: 'c1', github_id: '42', role: 'builder' };
|
|
378
|
+
const pool = mockPool([{ rows: [row] }]);
|
|
379
|
+
const out = await P.getMembership('c1', 42, { pool });
|
|
380
|
+
assert.deepEqual(out, row);
|
|
381
|
+
const c = pool.calls[0];
|
|
382
|
+
assert.match(c.text, /FROM platform_identity_project_memberships/);
|
|
383
|
+
assert.deepEqual(c.params, ['c1', 42]);
|
|
384
|
+
const miss = await P.getMembership('c1', 43, { pool });
|
|
385
|
+
assert.equal(miss, null);
|
|
386
|
+
});
|
|
387
|
+
|
|
337
388
|
await ta('listScoutingRollup aggregates per builder across projects, ranked, joined to accounts', async () => {
|
|
338
389
|
const pool = mockPool([{ rows: [{ github_id: '42', github_login: 'octo', total_credits: 250, project_count: 2 }] }]);
|
|
339
390
|
const out = await P.listScoutingRollup({ pool, limit: 50 });
|
|
@@ -363,6 +414,111 @@ await ta('listScoutingRollup scalar aggregation is fan-out-free (task 1002911)',
|
|
|
363
414
|
assert.match(discCte, /array_agg\(DISTINCT x\.d\)/, 'disciplines are deduped across projects');
|
|
364
415
|
});
|
|
365
416
|
|
|
417
|
+
await ta('listScoutingRollup enforces consent IN the query (ADR 0171 D2/D3)', async () => {
|
|
418
|
+
// /people and /scouting must honor hide BY CONSTRUCTION: the predicate lives
|
|
419
|
+
// in the scalar-aggregation CTE, not in any caller. Semantics proven against
|
|
420
|
+
// real SQL by tests/profile_rollup_consent.mjs.
|
|
421
|
+
const pool = mockPool([{ rows: [] }]);
|
|
422
|
+
await P.listScoutingRollup({ pool, limit: 10 });
|
|
423
|
+
const aggCte = pool.calls[0].text.slice(0, pool.calls[0].text.indexOf('disc AS'));
|
|
424
|
+
assert.match(aggCte, /profile_state = 'active'/, 'provisional accounts drop out');
|
|
425
|
+
assert.match(aggCte, /NOT pa\.hide_stats/, 'hidden builders drop out');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
console.log('\nprofile rollup port (ADR 0171, task 1002278):');
|
|
429
|
+
|
|
430
|
+
await ta('getRollupForBuilder guards non-numeric github_id BEFORE any query (no bigint cast error)', async () => {
|
|
431
|
+
const pool = mockPool([]);
|
|
432
|
+
for (const bad of ['system:bfg', 'system:grader', '', null, undefined, '42abc']) {
|
|
433
|
+
assert.equal(await P.getRollupForBuilder(bad, { pool }), null, `"${bad}" → null`);
|
|
434
|
+
}
|
|
435
|
+
assert.equal(pool.calls.length, 0, 'no query is ever issued for a non-numeric id');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await ta('getRollupForBuilder aggregates un-fanned + names only publicly-catalogued projects', async () => {
|
|
439
|
+
const pool = mockPool([
|
|
440
|
+
{ rows: [{ projects: 2, credits: 150, works_shipped: 7, karma: 4, last_active: 'T', disciplines: ['engineer', 'ui'] }] },
|
|
441
|
+
{ rows: [
|
|
442
|
+
{ name: 'Public Proj', origin: 'https://pub.example', rank_held: 'metic', is_public: true },
|
|
443
|
+
{ name: 'Secret Proj', origin: 'https://sec.example', rank_held: 'archon', is_public: false },
|
|
444
|
+
] },
|
|
445
|
+
]);
|
|
446
|
+
const out = await P.getRollupForBuilder(42, { pool });
|
|
447
|
+
assert.deepEqual(out.totals, { projects: 2, credits: 150, works_shipped: 7, karma: 4 });
|
|
448
|
+
assert.deepEqual(out.disciplines, ['engineer', 'ui']);
|
|
449
|
+
assert.equal(out.projects.length, 1, 'only the public catalog row is named');
|
|
450
|
+
assert.deepEqual(out.projects[0], { name: 'Public Proj', origin: 'https://pub.example', rank_held: 'metic' });
|
|
451
|
+
assert.equal(out.more_projects, 1, 'the non-public project is counted, never named');
|
|
452
|
+
// The whitelist holds structurally: client_id never appears in the shape.
|
|
453
|
+
assert.ok(!JSON.stringify(out).includes('client_id'), 'client_id must not cross the port');
|
|
454
|
+
// The scalar aggregation must be un-fanned (the 1002911 rule), disciplines separate.
|
|
455
|
+
const aggSql = pool.calls[0].text;
|
|
456
|
+
const aggCte = aggSql.slice(aggSql.indexOf('WITH agg AS'), aggSql.indexOf('disc AS'));
|
|
457
|
+
assert.doesNotMatch(aggCte, /unnest/i, 'scalar CTE must not fan rows out');
|
|
458
|
+
assert.match(aggSql.slice(aggSql.indexOf('disc AS')), /array_agg\(DISTINCT x\.d\)/);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
await ta('getPublicProfileExtras is fail-closed: null for missing / provisional / hidden accounts', async () => {
|
|
462
|
+
// No account row at all.
|
|
463
|
+
assert.equal(await P.getPublicProfileExtras(42, { pool: mockPool([{ rows: [] }]) }), null);
|
|
464
|
+
// Provisional (no hub-session consent yet).
|
|
465
|
+
assert.equal(await P.getPublicProfileExtras(42, {
|
|
466
|
+
pool: mockPool([{ rows: [{ profile_state: 'provisional', hide_stats: false }] }]) }), null);
|
|
467
|
+
// Active but hidden.
|
|
468
|
+
assert.equal(await P.getPublicProfileExtras(42, {
|
|
469
|
+
pool: mockPool([{ rows: [{ profile_state: 'active', hide_stats: true }] }]) }), null);
|
|
470
|
+
// Non-numeric principal — null before any query.
|
|
471
|
+
const silent = mockPool([]);
|
|
472
|
+
assert.equal(await P.getPublicProfileExtras('system:bfg', { pool: silent }), null);
|
|
473
|
+
assert.equal(silent.calls.length, 0);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
await ta('getPublicProfileExtras returns the rollup for an active, visible account', async () => {
|
|
477
|
+
const pool = mockPool([
|
|
478
|
+
{ rows: [{ profile_state: 'active', hide_stats: false }] },
|
|
479
|
+
{ rows: [{ projects: 1, credits: 10, works_shipped: 1, karma: 0, last_active: 'T', disciplines: null }] },
|
|
480
|
+
{ rows: [] },
|
|
481
|
+
]);
|
|
482
|
+
const out = await P.getPublicProfileExtras(42, { pool });
|
|
483
|
+
assert.deepEqual(out.totals, { projects: 1, credits: 10, works_shipped: 1, karma: 0 });
|
|
484
|
+
assert.deepEqual(out.disciplines, [], 'null disciplines surface as an empty array');
|
|
485
|
+
assert.deepEqual(out.projects, []);
|
|
486
|
+
assert.equal(out.more_projects, 0);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await ta('setAccountHideStats flips ONLY the hide flag (never identity, never profile_state)', async () => {
|
|
490
|
+
const pool = mockPool([{ rows: [{ github_id: '42', hide_stats: true, profile_state: 'provisional' }] }]);
|
|
491
|
+
const out = await P.setAccountHideStats(
|
|
492
|
+
{ githubId: 42, githubLogin: 'octo', displayName: 'Octo', avatarUrl: 'a', hide: true }, { pool });
|
|
493
|
+
assert.equal(out.hide_stats, true);
|
|
494
|
+
const c = pool.calls[0];
|
|
495
|
+
assert.match(c.text, /ON CONFLICT \(github_id\) DO UPDATE/);
|
|
496
|
+
// The UPDATE branch may set hide_stats + updated_at and NOTHING else — a
|
|
497
|
+
// settings toggle is not an identity refresh and must never touch consent.
|
|
498
|
+
const updateBranch = c.text.slice(c.text.indexOf('DO UPDATE'), c.text.indexOf('RETURNING'));
|
|
499
|
+
assert.match(updateBranch, /hide_stats = EXCLUDED\.hide_stats/);
|
|
500
|
+
assert.doesNotMatch(updateBranch, /github_login|display_name|avatar_url|profile_state/,
|
|
501
|
+
'the update branch must not clobber identity or consent');
|
|
502
|
+
assert.equal(c.params[4], true, 'hide passed as a boolean');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
await ta('setAccountHideStats guards non-numeric github_id (system:* principals) → null, no query', async () => {
|
|
506
|
+
const pool = mockPool([]);
|
|
507
|
+
assert.equal(await P.setAccountHideStats({ githubId: 'system:bfg', hide: true }, { pool }), null);
|
|
508
|
+
assert.equal(pool.calls.length, 0);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
await ta('getOwnProfileExtras serves the self view regardless of flags (no flags lookup)', async () => {
|
|
512
|
+
const pool = mockPool([
|
|
513
|
+
{ rows: [{ projects: 1, credits: 10, works_shipped: 1, karma: 0, last_active: 'T', disciplines: null }] },
|
|
514
|
+
{ rows: [] },
|
|
515
|
+
]);
|
|
516
|
+
const out = await P.getOwnProfileExtras(42, { pool });
|
|
517
|
+
assert.ok(out && out.totals, 'self view returns the rollup');
|
|
518
|
+
assert.ok(!pool.calls.some((c) => /profile_state/.test(c.text)),
|
|
519
|
+
'the self read never consults profile_state/hide_stats');
|
|
520
|
+
});
|
|
521
|
+
|
|
366
522
|
console.log('\nauthorization codes:');
|
|
367
523
|
|
|
368
524
|
await ta('mintAuthCode stores the HASH of the code (never the plaintext) + binds it', async () => {
|