@bongos/core 1.19.408 → 1.19.409
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 +28 -18
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +2 -1
- package/docs/api-reference.md +1 -1
- package/modules/platform-identity/migrations/platform_identity_012_recent_ships.sql +40 -0
- package/modules/platform-identity/platform-identity.js +87 -6
- package/modules/platform-identity/routes/sso.js +10 -0
- package/modules/platform-identity/tests/platform-identity.mjs +7 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth-admission.js +89 -8
- package/src/bongos/routes/auth.js +21 -0
- package/src/module-api.js +3 -1
- package/tests/hub_recent_ships.mjs +373 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.409",
|
|
6
|
+
"core_contract": "1.19.409",
|
|
7
|
+
"source_commit": "752e2e352e3d3819e05eabef5b09b20aeaeeb90b",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-28T22:
|
|
9
|
+
"built_at": "2026-08-28T22:31:01.892Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 352,
|
|
13
13
|
"agent_docs_stubbed": 23,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1666,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2041,
|
|
20
|
+
"tree_sha256": "4b1590fda72bea96890647cd43a22d83b6b988b39e9204eb1d0a29829611f473",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -372,7 +372,7 @@
|
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.d.ts",
|
|
374
374
|
"mode": "0000644",
|
|
375
|
-
"sha256": "
|
|
375
|
+
"sha256": "edef54ed26867eacdde4fd852efdca9c5c97dcc046e68ffbcf205a037697155b"
|
|
376
376
|
},
|
|
377
377
|
{
|
|
378
378
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1522,12 +1522,12 @@
|
|
|
1522
1522
|
{
|
|
1523
1523
|
"path": "docs/api-reference.md",
|
|
1524
1524
|
"mode": "0000644",
|
|
1525
|
-
"sha256": "
|
|
1525
|
+
"sha256": "6eb3ca8810c05c7ba1c8756b1694a45872b12ae873bc7ab007ab777405944cae"
|
|
1526
1526
|
},
|
|
1527
1527
|
{
|
|
1528
1528
|
"path": "docs/api/openapi.json",
|
|
1529
1529
|
"mode": "0000644",
|
|
1530
|
-
"sha256": "
|
|
1530
|
+
"sha256": "72e10f3ed2ed7c3dca02d80aa6477752af7168548c53ed969aec7ec4a0467a81"
|
|
1531
1531
|
},
|
|
1532
1532
|
{
|
|
1533
1533
|
"path": "docs/architecture.md",
|
|
@@ -4759,6 +4759,11 @@
|
|
|
4759
4759
|
"mode": "0000644",
|
|
4760
4760
|
"sha256": "48941621e5230a23785b832fa840e0c7f7b47f3dd1b01f7d254157482c4e3e6a"
|
|
4761
4761
|
},
|
|
4762
|
+
{
|
|
4763
|
+
"path": "modules/platform-identity/migrations/platform_identity_012_recent_ships.sql",
|
|
4764
|
+
"mode": "0000644",
|
|
4765
|
+
"sha256": "297c6a01f7de133dd9b97daa082ed64465c32349d90ef6ddc6cbfacfac034da3"
|
|
4766
|
+
},
|
|
4762
4767
|
{
|
|
4763
4768
|
"path": "modules/platform-identity/module.json",
|
|
4764
4769
|
"mode": "0000644",
|
|
@@ -4767,7 +4772,7 @@
|
|
|
4767
4772
|
{
|
|
4768
4773
|
"path": "modules/platform-identity/platform-identity.js",
|
|
4769
4774
|
"mode": "0000644",
|
|
4770
|
-
"sha256": "
|
|
4775
|
+
"sha256": "ca7996877566727335979bf55fc6157ae130be9c54cdbf0210d3b906a3ac5f53"
|
|
4771
4776
|
},
|
|
4772
4777
|
{
|
|
4773
4778
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -4807,12 +4812,12 @@
|
|
|
4807
4812
|
{
|
|
4808
4813
|
"path": "modules/platform-identity/routes/sso.js",
|
|
4809
4814
|
"mode": "0000644",
|
|
4810
|
-
"sha256": "
|
|
4815
|
+
"sha256": "70e4d1d35964603ac40b459b9cb40a480bb9ec6aeb4cb6c0e93368fcc514f2dd"
|
|
4811
4816
|
},
|
|
4812
4817
|
{
|
|
4813
4818
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
4814
4819
|
"mode": "0000644",
|
|
4815
|
-
"sha256": "
|
|
4820
|
+
"sha256": "5f1f05910d01d4a83e2134149311a9333aef8d93561cb37bcee1566fb2cfc1c7"
|
|
4816
4821
|
},
|
|
4817
4822
|
{
|
|
4818
4823
|
"path": "modules/platform-identity/ui/.gitkeep",
|
|
@@ -5347,12 +5352,12 @@
|
|
|
5347
5352
|
{
|
|
5348
5353
|
"path": "package-lock.json",
|
|
5349
5354
|
"mode": "0000644",
|
|
5350
|
-
"sha256": "
|
|
5355
|
+
"sha256": "86dacd8d1318040e57b9cdbaba495abd1bcfdac7cf834396d2f3e8d020eddfcc"
|
|
5351
5356
|
},
|
|
5352
5357
|
{
|
|
5353
5358
|
"path": "package.json",
|
|
5354
5359
|
"mode": "0000644",
|
|
5355
|
-
"sha256": "
|
|
5360
|
+
"sha256": "ab3f6f59ed390c9463e9e3242542791067e7c881ef46cef351030b76a561f24a"
|
|
5356
5361
|
},
|
|
5357
5362
|
{
|
|
5358
5363
|
"path": "public-docs/index.html",
|
|
@@ -7092,7 +7097,7 @@
|
|
|
7092
7097
|
{
|
|
7093
7098
|
"path": "src/bongos/auth-admission.js",
|
|
7094
7099
|
"mode": "0000644",
|
|
7095
|
-
"sha256": "
|
|
7100
|
+
"sha256": "46dc4adc21f138b4d9fdfe06c85f71d591a81357050d48d72b6272ba86a09d3b"
|
|
7096
7101
|
},
|
|
7097
7102
|
{
|
|
7098
7103
|
"path": "src/bongos/auth-config.js",
|
|
@@ -7257,7 +7262,7 @@
|
|
|
7257
7262
|
{
|
|
7258
7263
|
"path": "src/bongos/routes/auth.js",
|
|
7259
7264
|
"mode": "0000644",
|
|
7260
|
-
"sha256": "
|
|
7265
|
+
"sha256": "ba9fbc7fb9d210d850578b81dd54bbbe777634988107e8d5791b37acd0fffa0d"
|
|
7261
7266
|
},
|
|
7262
7267
|
{
|
|
7263
7268
|
"path": "src/bongos/routes/backup.js",
|
|
@@ -7367,7 +7372,7 @@
|
|
|
7367
7372
|
{
|
|
7368
7373
|
"path": "src/module-api.js",
|
|
7369
7374
|
"mode": "0000644",
|
|
7370
|
-
"sha256": "
|
|
7375
|
+
"sha256": "c29f8b33a65a1a7ecc1fbe105df9e97c7960be6c785546f32205f1fd612d14a2"
|
|
7371
7376
|
},
|
|
7372
7377
|
{
|
|
7373
7378
|
"path": "src/module-loader/catalog.js",
|
|
@@ -8749,6 +8754,11 @@
|
|
|
8749
8754
|
"mode": "0000644",
|
|
8750
8755
|
"sha256": "ea992d1ab2ddedc27aaa5348a397356bef91a4c1ad9b295eb1a0ed1c4fc707e9"
|
|
8751
8756
|
},
|
|
8757
|
+
{
|
|
8758
|
+
"path": "tests/hub_recent_ships.mjs",
|
|
8759
|
+
"mode": "0000644",
|
|
8760
|
+
"sha256": "9eff8102b1fce3f98ae2cc0f0fb7a640c3e13fba0d89814c2da3565445487f9e"
|
|
8761
|
+
},
|
|
8752
8762
|
{
|
|
8753
8763
|
"path": "tests/idea_capture_grades.mjs",
|
|
8754
8764
|
"mode": "0000644",
|
|
@@ -324,7 +324,7 @@ export interface PostSecurityScansResponse { ok: boolean; scan: unknown }
|
|
|
324
324
|
export interface PostSessionOptimizeRequest { mode?: string; time_budget_min?: unknown; max_parallel?: unknown }
|
|
325
325
|
export interface PostSessionsRequest { session_id: string; task_ids?: number[]; total_tokens?: number; duration_seconds?: number; started_at?: string; ended_at?: string; detail?: unknown[]; metrics?: Record<string, unknown>; model_usage?: Record<string, unknown> }
|
|
326
326
|
export interface PostSessionsResponse { skipped?: boolean; reason?: unknown; session?: unknown; token_reward?: unknown }
|
|
327
|
-
export interface PostSsoActivityRollupRequest { client_id: string; client_secret: string; github_id: StringifiableId; credits?: number; tasks_shipped?: number; karma?: number; rank?: string; disciplines?: unknown }
|
|
327
|
+
export interface PostSsoActivityRollupRequest { client_id: string; client_secret: string; github_id: StringifiableId; credits?: number; tasks_shipped?: number; karma?: number; rank?: string; disciplines?: unknown; recent_ships?: unknown }
|
|
328
328
|
export interface PostSsoActivityRollupResponse { ok: boolean }
|
|
329
329
|
export interface PostSsoDevicePollRequest { device_code: string }
|
|
330
330
|
export interface PostSsoDeviceStartRequest { origin: string }
|
package/docs/api/openapi.json
CHANGED
|
@@ -19660,7 +19660,8 @@
|
|
|
19660
19660
|
"type": "string",
|
|
19661
19661
|
"maxLength": 32
|
|
19662
19662
|
},
|
|
19663
|
-
"disciplines": {}
|
|
19663
|
+
"disciplines": {},
|
|
19664
|
+
"recent_ships": {}
|
|
19664
19665
|
},
|
|
19665
19666
|
"required": [
|
|
19666
19667
|
"client_id",
|
package/docs/api-reference.md
CHANGED
|
@@ -515,7 +515,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
515
515
|
|
|
516
516
|
| Method | Path | Rank | Body | Description |
|
|
517
517
|
|---|---|---|---|---|
|
|
518
|
-
| POST | `/api/bongos/sso/activity/rollup` | `public` | `client_id`, `client_secret`, `github_id`, `credits`, `tasks_shipped`, `karma`, `rank`, `disciplines` | POST /sso/activity/rollup — a federated instance reports a builder's activity snapshot (ADR 0141 §4). |
|
|
518
|
+
| POST | `/api/bongos/sso/activity/rollup` | `public` | `client_id`, `client_secret`, `github_id`, `credits`, `tasks_shipped`, `karma`, `rank`, `disciplines`, `recent_ships` | POST /sso/activity/rollup — a federated instance reports a builder's activity snapshot (ADR 0141 §4). |
|
|
519
519
|
| GET | `/api/bongos/sso/authorize` | `public` | — | GET /sso/authorize?client_id=&redirect_uri=&state= — the browser authorize leg. |
|
|
520
520
|
| POST | `/api/bongos/sso/device/poll` | `public` | `device_code` | POST /sso/device/poll — poll the hub device flow. |
|
|
521
521
|
| POST | `/api/bongos/sso/device/start` | `public` | `origin` | POST /sso/device/start — begin CLI federation. |
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- platform_identity_012_recent_ships.sql — the hub's "recent works" store
|
|
2
|
+
-- (BV1 goal 1000045 P07, task 1002803; profile-page spec "The page spec" row 4 +
|
|
3
|
+
-- ADR 0171 D7).
|
|
4
|
+
--
|
|
5
|
+
-- WHY A COLUMN AND NOT A TABLE. The hub has no `tasks` table for other projects,
|
|
6
|
+
-- so a builder's last few shipped works can only reach it as data the project
|
|
7
|
+
-- PUSHES. The existing pull pattern (modules/platform-identity/project-stats.js)
|
|
8
|
+
-- fans out to each project's public API — fine for a builder count on a catalog
|
|
9
|
+
-- page, wrong once per profile view, and useless for a CLI-only project that
|
|
10
|
+
-- serves no public API at all. The push already exists: POST /sso/activity/rollup
|
|
11
|
+
-- upserts exactly one row per (client_id, github_id), refreshed on every federated
|
|
12
|
+
-- sign-in AND (task 1002803) on every ship. A capped list of at most 3 works
|
|
13
|
+
-- belongs ON that row: it is written and read as one unit, it is never queried
|
|
14
|
+
-- across rows, and a child table would add a join and a delete-orphan path to
|
|
15
|
+
-- store nine values.
|
|
16
|
+
--
|
|
17
|
+
-- WHAT IS IN IT. At most 3 objects, each `{ id, title, shipped_at, url }` —
|
|
18
|
+
-- sanitized in upsertActivity BEFORE the write (the rank/disciplines precedent,
|
|
19
|
+
-- ADR 0171 D7: a reporting instance is third-party self-report and is never
|
|
20
|
+
-- trusted to respect its own cap). `url` may only point back into the reporting
|
|
21
|
+
-- client's own registered origin, so a ship link on a public portfolio can never
|
|
22
|
+
-- be turned into a phishing hop. `'[]'` — never NULL — so every reader gets a
|
|
23
|
+
-- list without a coalesce.
|
|
24
|
+
--
|
|
25
|
+
-- DESCRIPTIVE ANALYTICS ONLY (ADR 0016 / 0141): a ship link is a label, never an
|
|
26
|
+
-- access grant. Reading it confers nothing on the instance it points at.
|
|
27
|
+
--
|
|
28
|
+
-- Additive + `platform_identity_`-namespaced (ADR 0083 §5). Forward-safe: ADD
|
|
29
|
+
-- COLUMN with a DEFAULT, so the previous code release still runs against this
|
|
30
|
+
-- schema (it simply never writes the column). Idempotent.
|
|
31
|
+
|
|
32
|
+
BEGIN;
|
|
33
|
+
|
|
34
|
+
ALTER TABLE platform_identity_builder_project_activity
|
|
35
|
+
ADD COLUMN IF NOT EXISTS recent_ships jsonb NOT NULL DEFAULT '[]'::jsonb;
|
|
36
|
+
|
|
37
|
+
COMMENT ON COLUMN platform_identity_builder_project_activity.recent_ships IS
|
|
38
|
+
'Up to 3 recently shipped works this project reported for this builder, pushed on POST /sso/activity/rollup (task 1002803): [{id, title, shipped_at, url}]. Third-party self-report — capped, type-checked and same-origin-checked in upsertActivity, never trusted as written. Surfaced only on a project the catalog names publicly (getRollupForBuilder), so a non-public project''s work titles stay counted-but-unnamed like its name does.';
|
|
39
|
+
|
|
40
|
+
COMMIT;
|
|
@@ -532,25 +532,96 @@ async function setClientStatus(clientId, status, { pool = defaultPool } = {}) {
|
|
|
532
532
|
const MAX_ROLLUP_DISCIPLINES = 8;
|
|
533
533
|
const MAX_DISCIPLINE_LENGTH = 32;
|
|
534
534
|
|
|
535
|
+
// "Recent works" caps (task 1002803). The hub has no tasks table for other
|
|
536
|
+
// projects, so a builder's last few shipped works arrive on the rollup push
|
|
537
|
+
// instead of being fanned out for at read time. THREE PER PROJECT, hard-capped
|
|
538
|
+
// HERE and not only at the sender: the cap is a property of what the hub will
|
|
539
|
+
// store, and a reporting instance is third-party self-report that may be old,
|
|
540
|
+
// buggy or hostile.
|
|
541
|
+
const MAX_RECENT_SHIPS = 3;
|
|
542
|
+
const MAX_SHIP_TITLE_LENGTH = 200;
|
|
543
|
+
const MAX_SHIP_URL_LENGTH = 500;
|
|
544
|
+
// How far into a pushed array the hub will even LOOK. Validating first and
|
|
545
|
+
// capping second is what stops a few malformed entries from crowding the good
|
|
546
|
+
// ones out of the three slots; bounding the scan is what stops an unbounded
|
|
547
|
+
// array from buying unbounded work with one request.
|
|
548
|
+
const MAX_RECENT_SHIPS_SCANNED = 20;
|
|
549
|
+
|
|
550
|
+
// May this ship link be stored? A ship link is the ONLY free-text URL on a public
|
|
551
|
+
// builder portfolio, which makes it the one place a client-secret holder could
|
|
552
|
+
// plant a phishing hop under someone else's name. So it must point AT OR BELOW
|
|
553
|
+
// the instance that reported it: the client's own REGISTERED origin host, or a
|
|
554
|
+
// subdomain of it (an instance's hall may live at builders.<domain> while its
|
|
555
|
+
// registered origin is <domain>). Anything else — another site, a non-http
|
|
556
|
+
// scheme, embedded credentials — stores no link rather than a wrong one.
|
|
557
|
+
//
|
|
558
|
+
// DOWNWARD ONLY, deliberately. The mirror rule ("the link host is an ANCESTOR of
|
|
559
|
+
// the registered origin") looks symmetric and is not: on a shared multi-tenant
|
|
560
|
+
// apex it would let a client registered at foo.github.io publish links on bare
|
|
561
|
+
// github.io, which it does not control. Nothing needs the upward direction — a
|
|
562
|
+
// registered origin is the instance's own public origin — so it is not offered.
|
|
563
|
+
function shipUrlWithinClientOrigin(raw, clientOrigin) {
|
|
564
|
+
if (typeof raw !== 'string' || !raw || raw.length > MAX_SHIP_URL_LENGTH) return null;
|
|
565
|
+
let u, o;
|
|
566
|
+
try { u = new URL(raw); o = new URL(String(clientOrigin || '')); } catch { return null; }
|
|
567
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
568
|
+
if (u.username || u.password) return null;
|
|
569
|
+
const host = u.hostname.toLowerCase(), registered = o.hostname.toLowerCase();
|
|
570
|
+
if (host !== registered && !host.endsWith(`.${registered}`)) return null;
|
|
571
|
+
return raw;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Shape and bound the pushed recent_ships[] before it is stored (the
|
|
575
|
+
// upsertActivity rank/disciplines precedent, ADR 0171 D7 — validation lives next
|
|
576
|
+
// to the data where no route can forget it). Returns AT MOST MAX_RECENT_SHIPS
|
|
577
|
+
// entries of exactly `{ id, title, shipped_at, url }`; a row with no usable id or
|
|
578
|
+
// title is dropped entirely rather than stored half-empty, and a bad date or an
|
|
579
|
+
// off-origin link becomes null on an otherwise good row. Always an array, never
|
|
580
|
+
// null — every reader gets a list without a coalesce.
|
|
581
|
+
function sanitizeRecentShips(raw, clientOrigin) {
|
|
582
|
+
return (Array.isArray(raw) ? raw : [])
|
|
583
|
+
.slice(0, MAX_RECENT_SHIPS_SCANNED)
|
|
584
|
+
.filter((s) => s && typeof s === 'object' && !Array.isArray(s))
|
|
585
|
+
.map((s) => {
|
|
586
|
+
const id = Number(s.id);
|
|
587
|
+
const at = typeof s.shipped_at === 'string' ? Date.parse(s.shipped_at) : NaN;
|
|
588
|
+
return {
|
|
589
|
+
id: Number.isSafeInteger(id) && id > 0 ? id : null,
|
|
590
|
+
title: typeof s.title === 'string' && s.title.trim() ? s.title.slice(0, MAX_SHIP_TITLE_LENGTH) : null,
|
|
591
|
+
shipped_at: Number.isFinite(at) ? new Date(at).toISOString() : null,
|
|
592
|
+
url: shipUrlWithinClientOrigin(s.url, clientOrigin),
|
|
593
|
+
};
|
|
594
|
+
})
|
|
595
|
+
.filter((s) => s.id !== null && s.title !== null)
|
|
596
|
+
.slice(0, MAX_RECENT_SHIPS);
|
|
597
|
+
}
|
|
598
|
+
|
|
535
599
|
// Upsert one project's rollup for one builder (keyed by client_id + github_id).
|
|
536
600
|
// Descriptive analytics — never authority. Refreshes last_active on each report.
|
|
537
|
-
|
|
601
|
+
// `clientOrigin` is the AUTHENTICATED client's registered origin (never a
|
|
602
|
+
// caller-named one) and exists only to bound the ship links — see
|
|
603
|
+
// shipUrlWithinClientOrigin.
|
|
604
|
+
async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], clientOrigin = null }, { pool = defaultPool } = {}) {
|
|
538
605
|
// api.LIVE_RANK_LADDER is a published doorway export (src/module-api.js, a
|
|
539
606
|
// lazy getter over db-kernel's canonical four-rank ladder).
|
|
540
607
|
const safeRank = api.LIVE_RANK_LADDER.includes(rank) ? rank : null;
|
|
541
608
|
const disc = (Array.isArray(disciplines) ? disciplines : [])
|
|
542
609
|
.filter((d) => typeof d === 'string' && d.length > 0 && d.length <= MAX_DISCIPLINE_LENGTH)
|
|
543
610
|
.slice(0, MAX_ROLLUP_DISCIPLINES);
|
|
611
|
+
const ships = sanitizeRecentShips(recentShips, clientOrigin);
|
|
544
612
|
const { rows } = await pool.query(
|
|
545
613
|
`INSERT INTO platform_identity_builder_project_activity
|
|
546
|
-
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, last_active, updated_at)
|
|
547
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
|
|
614
|
+
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at)
|
|
615
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now())
|
|
548
616
|
ON CONFLICT (client_id, github_id) DO UPDATE SET
|
|
549
617
|
credits = EXCLUDED.credits, tasks_shipped = EXCLUDED.tasks_shipped, karma = EXCLUDED.karma,
|
|
550
618
|
rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
|
|
619
|
+
recent_ships = EXCLUDED.recent_ships,
|
|
551
620
|
last_active = now(), updated_at = now()
|
|
552
621
|
RETURNING id, client_id, github_id`,
|
|
553
|
-
|
|
622
|
+
// jsonb is bound as a JSON STRING: node-postgres serializes a JS array as a
|
|
623
|
+
// Postgres ARRAY literal, which the jsonb column would reject.
|
|
624
|
+
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships)],
|
|
554
625
|
);
|
|
555
626
|
return rows[0];
|
|
556
627
|
}
|
|
@@ -653,7 +724,7 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
|
653
724
|
);
|
|
654
725
|
const agg = aggRows[0];
|
|
655
726
|
const { rows: projRows } = await pool.query(
|
|
656
|
-
`SELECT p.name, p.origin, a.rank_held,
|
|
727
|
+
`SELECT p.name, p.origin, a.rank_held, a.recent_ships,
|
|
657
728
|
(p.id IS NOT NULL AND p.featured AND p.status = 'active') AS is_public
|
|
658
729
|
FROM platform_identity_builder_project_activity a
|
|
659
730
|
LEFT JOIN platform_identity_sso_clients c ON c.client_id = a.client_id
|
|
@@ -662,9 +733,19 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
|
662
733
|
ORDER BY a.last_active DESC`,
|
|
663
734
|
[githubId],
|
|
664
735
|
);
|
|
736
|
+
// recent_ships rides the project row it belongs to (task 1002803) — which is
|
|
737
|
+
// also what keeps it honest: a work title is at least as identifying as the
|
|
738
|
+
// project name it came from, so it may only appear where the name already
|
|
739
|
+
// may. A non-public project stays counted in more_projects and its work
|
|
740
|
+
// titles never leave the hub, exactly as its name doesn't.
|
|
665
741
|
const named = projRows
|
|
666
742
|
.filter((r) => r.is_public)
|
|
667
|
-
.map((r) => ({
|
|
743
|
+
.map((r) => ({
|
|
744
|
+
name: r.name,
|
|
745
|
+
origin: r.origin,
|
|
746
|
+
rank_held: r.rank_held,
|
|
747
|
+
recent_ships: Array.isArray(r.recent_ships) ? r.recent_ships : [],
|
|
748
|
+
}));
|
|
668
749
|
return {
|
|
669
750
|
totals: {
|
|
670
751
|
projects: agg.projects,
|
|
@@ -426,6 +426,11 @@ module.exports = function ssoRoutes() {
|
|
|
426
426
|
karma: { type: 'number' },
|
|
427
427
|
rank: { type: 'string', maxLength: 32 },
|
|
428
428
|
disciplines: {},
|
|
429
|
+
// "Recent works" (task 1002803): up to 3 of this builder's latest shipped
|
|
430
|
+
// works on THIS project — [{ id, title, shipped_at, url }]. Left untyped
|
|
431
|
+
// here, like disciplines: the shape is checked and hard-capped in
|
|
432
|
+
// upsertActivity, next to the data, so no route can forget it.
|
|
433
|
+
recent_ships: {},
|
|
429
434
|
})) return;
|
|
430
435
|
const b = req.body || {};
|
|
431
436
|
try {
|
|
@@ -456,6 +461,11 @@ module.exports = function ssoRoutes() {
|
|
|
456
461
|
karma: b.karma,
|
|
457
462
|
rank: b.rank || null,
|
|
458
463
|
disciplines: Array.isArray(b.disciplines) ? b.disciplines : [],
|
|
464
|
+
recentShips: b.recent_ships,
|
|
465
|
+
// The AUTHENTICATED client's registered origin — the bound every pushed
|
|
466
|
+
// ship link must fall inside (task 1002803). Taken from the client row,
|
|
467
|
+
// never from the body, for the same reason client_id is.
|
|
468
|
+
clientOrigin: client.origin,
|
|
459
469
|
}, { pool });
|
|
460
470
|
res.json({ ok: true });
|
|
461
471
|
} catch (err) {
|
|
@@ -494,7 +494,10 @@ await ta('upsertActivity upserts per (client_id, github_id) with the talent snap
|
|
|
494
494
|
const c = pool.calls[0];
|
|
495
495
|
assert.match(c.text, /INSERT INTO platform_identity_builder_project_activity/);
|
|
496
496
|
assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
|
|
497
|
-
|
|
497
|
+
// recent_ships (task 1002803) rides the same row as a jsonb STRING param —
|
|
498
|
+
// an empty list when the sender reported none, never NULL. Its own cap,
|
|
499
|
+
// shape and link bound are covered by tests/hub_recent_ships.mjs.
|
|
500
|
+
assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer'], '[]']);
|
|
498
501
|
});
|
|
499
502
|
|
|
500
503
|
await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-ladder rank → null, disciplines validated + capped', async () => {
|
|
@@ -597,7 +600,9 @@ await ta('getRollupForBuilder aggregates un-fanned + names only publicly-catalog
|
|
|
597
600
|
assert.deepEqual(out.totals, { projects: 2, credits: 150, works_shipped: 7, karma: 4 });
|
|
598
601
|
assert.deepEqual(out.disciplines, ['engineer', 'ui']);
|
|
599
602
|
assert.equal(out.projects.length, 1, 'only the public catalog row is named');
|
|
600
|
-
|
|
603
|
+
// recent_ships rides the project row it belongs to (task 1002803), so a work
|
|
604
|
+
// title can only surface where the project name already may.
|
|
605
|
+
assert.deepEqual(out.projects[0], { name: 'Public Proj', origin: 'https://pub.example', rank_held: 'metic', recent_ships: [] });
|
|
601
606
|
assert.equal(out.more_projects, 1, 'the non-public project is counted, never named');
|
|
602
607
|
// The whitelist holds structurally: client_id never appears in the shape.
|
|
603
608
|
assert.ok(!JSON.stringify(out).includes('client_id'), 'client_id must not cross the port');
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.409",
|
|
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.409",
|
|
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.409",
|
|
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",
|
|
@@ -77,36 +77,98 @@ async function reportMembershipCheckIn({ githubId, githubLogin, displayName, ava
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// How many recently shipped works ride the rollup (task 1002803). The hub caps
|
|
81
|
+
// this again on arrival — a sender is never trusted to respect its own cap — so
|
|
82
|
+
// this constant is the polite half of the contract, not the enforcement.
|
|
83
|
+
const ROLLUP_RECENT_SHIPS = 3;
|
|
84
|
+
|
|
85
|
+
// A public deep link to one task in THIS instance's builders' hall, for the
|
|
86
|
+
// "recent works" list on the builder's cross-project profile (task 1002803). It
|
|
87
|
+
// is built from branding's STABLE public hall origin, never an env API base: the
|
|
88
|
+
// link is stored on another host and rendered to strangers, so a local dev
|
|
89
|
+
// override must never end up on a public portfolio. No origin (an instance that
|
|
90
|
+
// never declared one) → no link, and the work still reports with its title.
|
|
91
|
+
function hallTaskUrl(taskId) {
|
|
92
|
+
try {
|
|
93
|
+
const d = (branding().domains) || {};
|
|
94
|
+
const origin = String(d.buildersOrigin || d.publicOrigin || '').replace(/\/+$/, '');
|
|
95
|
+
return origin ? `${origin}/builders#/task/${taskId}` : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// This builder's most recent shipped works, for the rollup's recent_ships[]
|
|
102
|
+
// (task 1002803). The predicate is deliberately IDENTICAL to the lifecycle
|
|
103
|
+
// module's recentShippedTasksByBuilder — the read behind this instance's own
|
|
104
|
+
// unauthenticated GET /builders/:id/profile — so the push can never publish a
|
|
105
|
+
// work the instance does not already publish itself. (Neither surface filters by
|
|
106
|
+
// goal visibility today: ADR 0112's secrecy covers a private goal's OWN title,
|
|
107
|
+
// not the titles of tasks inside it. Widening or narrowing that is a policy
|
|
108
|
+
// change for the profile route, not something to fork here.) Kernel file, so it
|
|
109
|
+
// reads `pool` directly rather than importing the lifecycle module (ADR 0091 §1).
|
|
110
|
+
// Covered by core_228's idx_tasks_shipped_by_shipped_at — (shipped_by,
|
|
111
|
+
// shipped_at DESC) partial on status='shipped', which is this predicate exactly.
|
|
112
|
+
async function recentShipsForRollup(builderId) {
|
|
113
|
+
const { rows } = await pool.query(
|
|
114
|
+
`SELECT id, title, shipped_at
|
|
115
|
+
FROM tasks
|
|
116
|
+
WHERE shipped_by = $1
|
|
117
|
+
AND status = 'shipped'
|
|
118
|
+
AND title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
|
|
119
|
+
ORDER BY shipped_at DESC NULLS LAST
|
|
120
|
+
LIMIT $2`,
|
|
121
|
+
[builderId, ROLLUP_RECENT_SHIPS],
|
|
122
|
+
);
|
|
123
|
+
return rows.map((r) => ({
|
|
124
|
+
id: Number(r.id),
|
|
125
|
+
title: r.title,
|
|
126
|
+
shipped_at: r.shipped_at ? new Date(r.shipped_at).toISOString() : null,
|
|
127
|
+
url: hallTaskUrl(r.id),
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
80
131
|
// Best-effort cross-project activity rollup to the hub (ADR 0141 §4): report this
|
|
81
|
-
// builder's descriptive talent snapshot (credits, ships, karma, rank, disciplines
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
132
|
+
// builder's descriptive talent snapshot (credits, ships, karma, rank, disciplines,
|
|
133
|
+
// and since task 1002803 their last few shipped works) for the cross-project
|
|
134
|
+
// profile + scouting directory. Fires on every federated sign-in AND on every
|
|
135
|
+
// ship (reportActivityRollupForShippedTask). Fully swallowed + no-op when not
|
|
136
|
+
// federated — never fails a sign-in or a ship.
|
|
137
|
+
//
|
|
138
|
+
// `githubId` is OPTIONAL: a sign-in already holds the hub-supplied id, while the
|
|
139
|
+
// ship path holds only a builder id, and the snapshot SELECT below can answer
|
|
140
|
+
// both — so the ship path costs one query, not two. A non-numeric principal (a
|
|
141
|
+
// `system:*` machine id) is dropped here rather than sent for the hub to reject:
|
|
142
|
+
// it has no public profile and never will.
|
|
143
|
+
async function reportActivityRollup({ builderId, githubId = null }) {
|
|
86
144
|
const idp = loadIdpConfig();
|
|
87
145
|
if (!idp) return;
|
|
88
146
|
try {
|
|
89
147
|
// Snapshot the builder's talent signals directly from the DB (auth.js is a
|
|
90
148
|
// kernel file — it uses pool, never a non-kernel domain module like metrics.js).
|
|
91
149
|
const b = await pool.query(
|
|
92
|
-
'SELECT total_credits, karma, rank, preferred_disciplines FROM builders WHERE id = $1',
|
|
150
|
+
'SELECT github_id, total_credits, karma, rank, preferred_disciplines FROM builders WHERE id = $1',
|
|
93
151
|
[builderId],
|
|
94
152
|
);
|
|
95
153
|
if (!b.rows[0]) return;
|
|
96
|
-
const shipped = await pool.query('SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1', [builderId]);
|
|
97
154
|
const row = b.rows[0];
|
|
155
|
+
const ghId = githubId ?? row.github_id;
|
|
156
|
+
if (!/^\d+$/.test(String(ghId ?? ''))) return;
|
|
157
|
+
const shipped = await pool.query('SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1', [builderId]);
|
|
158
|
+
const recentShips = await recentShipsForRollup(builderId);
|
|
98
159
|
await fetch(`${idp.origin}/api/gds/sso/activity/rollup`, {
|
|
99
160
|
method: 'POST',
|
|
100
161
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
|
|
101
162
|
body: JSON.stringify({
|
|
102
163
|
client_id: idp.clientId,
|
|
103
164
|
client_secret: idp.clientSecret,
|
|
104
|
-
github_id:
|
|
165
|
+
github_id: ghId,
|
|
105
166
|
credits: Number(row.total_credits) || 0,
|
|
106
167
|
tasks_shipped: shipped.rows[0] ? shipped.rows[0].n : 0,
|
|
107
168
|
karma: Number(row.karma) || 0,
|
|
108
169
|
rank: row.rank || null,
|
|
109
170
|
disciplines: Array.isArray(row.preferred_disciplines) ? row.preferred_disciplines : [],
|
|
171
|
+
recent_ships: recentShips,
|
|
110
172
|
}),
|
|
111
173
|
});
|
|
112
174
|
} catch (err) {
|
|
@@ -114,6 +176,24 @@ async function reportActivityRollup({ builderId, githubId }) {
|
|
|
114
176
|
}
|
|
115
177
|
}
|
|
116
178
|
|
|
179
|
+
// The SHIP-TIME rollup push (task 1002803, ADR 0171 amendment R01). Until now the
|
|
180
|
+
// rollup fired only on a federated sign-in, so a builder who signs in once a month
|
|
181
|
+
// had a cross-project profile up to a month stale — showing old credit and ship
|
|
182
|
+
// counts with nothing on the page saying so. A ship is the event that CHANGES
|
|
183
|
+
// those numbers, so it re-reports them; nothing else about the push differs.
|
|
184
|
+
//
|
|
185
|
+
// Wired as a `task.shipped` listener in src/bongos/routes/auth.js, so it runs
|
|
186
|
+
// once per ship whichever path won the confirmed→shipped flip. Best-effort in the
|
|
187
|
+
// same sense as the sign-in push: it resolves nothing the caller waits on, and a
|
|
188
|
+
// hub outage can never fail a ship. The builder's github_id is resolved by the
|
|
189
|
+
// rollup's own snapshot SELECT (which now returns it), so this adds no lookup of
|
|
190
|
+
// its own — and a machine principal is dropped there, not sent to the hub.
|
|
191
|
+
async function reportActivityRollupForShippedTask(task) {
|
|
192
|
+
const builderId = task && task.shipped_by;
|
|
193
|
+
if (!builderId) return;
|
|
194
|
+
await reportActivityRollup({ builderId });
|
|
195
|
+
}
|
|
196
|
+
|
|
117
197
|
// Admission posture (SR-19 / task #1000). BOTH enrollment paths — the web hall
|
|
118
198
|
// sign-in (completeWebFlow) and the CLI device flow (pollDeviceFlow) — are
|
|
119
199
|
// "invite-gated by default": a FIRST-TIME GitHub account is admitted only if an
|
|
@@ -329,6 +409,7 @@ module.exports = {
|
|
|
329
409
|
membershipKind,
|
|
330
410
|
openEnrollmentEnabled,
|
|
331
411
|
reportActivityRollup,
|
|
412
|
+
reportActivityRollupForShippedTask,
|
|
332
413
|
reportMembershipCheckIn,
|
|
333
414
|
safeSyncRankRole,
|
|
334
415
|
webAdmissionStatus,
|
|
@@ -30,6 +30,7 @@ const express = require('express');
|
|
|
30
30
|
const crypto = require('node:crypto');
|
|
31
31
|
|
|
32
32
|
const auth = require('../auth');
|
|
33
|
+
const { reportActivityRollupForShippedTask } = require('../auth-admission.js');
|
|
33
34
|
const db = require('../db');
|
|
34
35
|
const { pairStore } = require('../app-pair');
|
|
35
36
|
const { publicOrigin, parseCookie, validateOrRespond } = require('./_helpers');
|
|
@@ -376,7 +377,27 @@ const DISCORD_REDIRECT_URI = `${DISCORD_REDIRECT_ORIGIN}/api/gds/auth/discord/ca
|
|
|
376
377
|
// sign-in started on a builders.* subdomain must still use the apex.
|
|
377
378
|
const WEB_REDIRECT_ORIGIN = process.env.GDS_PUBLIC_ORIGIN || configuredOAuthOrigin();
|
|
378
379
|
|
|
380
|
+
// The ship-time hub push (task 1002803, ADR 0171 amendment R01) lives here
|
|
381
|
+
// because this file already owns the OTHER trigger: the federated sign-in flows
|
|
382
|
+
// below are what fire the check-in + rollup today, and the reporter they call is
|
|
383
|
+
// this router's own auth-admission sibling. `task.shipped` is an EVENT, not a
|
|
384
|
+
// port — many listeners may react to a ship and none of them returns anything
|
|
385
|
+
// (the discord/sessions listener precedent). off→on guarded so a test that
|
|
386
|
+
// re-instantiates the router never stacks duplicate listeners.
|
|
387
|
+
let _shipRollupListenerOff = null;
|
|
388
|
+
function registerSeams() {
|
|
389
|
+
if (_shipRollupListenerOff) _shipRollupListenerOff();
|
|
390
|
+
// Fire-and-forget: reportActivityRollupForShippedTask swallows every error and
|
|
391
|
+
// no-ops on an unfederated instance, so the .catch is belt-and-braces against a
|
|
392
|
+
// future edit letting one escape into an unhandledRejection (the sign-in
|
|
393
|
+
// chain's reasoning, auth-idp.js).
|
|
394
|
+
_shipRollupListenerOff = seams.on('task.shipped', (task) => {
|
|
395
|
+
reportActivityRollupForShippedTask(task).catch(() => {});
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
379
399
|
module.exports = function buildAuthRouter() {
|
|
400
|
+
registerSeams();
|
|
380
401
|
const router = express.Router();
|
|
381
402
|
|
|
382
403
|
// /auth/device — CLI flow
|
package/src/module-api.js
CHANGED
|
@@ -1247,8 +1247,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1247
1247
|
// landed since 1.19.406 with no explicit bump. run 33214451873. (task 1002620)
|
|
1248
1248
|
// 1.19.408 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1249
1249
|
// landed since 1.19.407 with no explicit bump. run 33216756796. (task 1002620)
|
|
1250
|
+
// 1.19.409 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1251
|
+
// landed since 1.19.408 with no explicit bump. run 33217065997. (task 1002620)
|
|
1250
1252
|
// ---------------------------------------------------------------------------
|
|
1251
|
-
const CORE_VERSION = '1.19.
|
|
1253
|
+
const CORE_VERSION = '1.19.409'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1252
1254
|
|
|
1253
1255
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1254
1256
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// tests/hub_recent_ships.mjs — task 1002803 (P07, goal 1000045 "public builder
|
|
2
|
+
// profiles"; profile-page spec "The page spec" row 4 + ADR 0171 amendment R01).
|
|
3
|
+
//
|
|
4
|
+
// "Recent works" reaches the hub by PUSH, not pull: the hub has no `tasks` table
|
|
5
|
+
// for other projects, so each project reports at most 3 of a builder's latest
|
|
6
|
+
// shipped works on POST /sso/activity/rollup. Three deliverables, and each one
|
|
7
|
+
// has a failure mode that a code read does not catch:
|
|
8
|
+
//
|
|
9
|
+
// 1. THE CAP IS THE HUB'S, NOT THE SENDER'S. A reporting instance is
|
|
10
|
+
// third-party self-report (ADR 0171 D7). If the cap lived only in the
|
|
11
|
+
// sender, an old, buggy or hostile client would set the size of a public
|
|
12
|
+
// profile section. So the cap, the field shape and the link bound are all
|
|
13
|
+
// asserted against what upsertActivity BINDS, not against what was passed.
|
|
14
|
+
// 2. A SHIP LINK IS THE ONE FREE-TEXT URL ON A PUBLIC PORTFOLIO. It may only
|
|
15
|
+
// point back into the reporting client's own registered origin, or it is a
|
|
16
|
+
// phishing hop published under someone else's name.
|
|
17
|
+
// 3. THE ORDERING IS THE WHOLE POINT OF THE SEQUENCING FIX. The hub refuses a
|
|
18
|
+
// rollup without a membership row, and the check-in is what creates it —
|
|
19
|
+
// so asserting "both fired" would still pass while a first sign-in raced
|
|
20
|
+
// itself. This asserts the rollup has NOT fired while the check-in is still
|
|
21
|
+
// in flight, which is the only shape that fails when the chain is broken.
|
|
22
|
+
//
|
|
23
|
+
// Plus the privacy edges the goal turns on: a work title is at least as
|
|
24
|
+
// identifying as the project name it came from, so it may only surface where
|
|
25
|
+
// that name may (a non-public project stays counted-but-unnamed, ships and all),
|
|
26
|
+
// and hide_stats suppresses the whole cross-project read including recent_ships.
|
|
27
|
+
//
|
|
28
|
+
// DB-free: every DB function takes an injectable pool and is driven by a
|
|
29
|
+
// recording fake (the modules/platform-identity/tests/platform-identity.mjs
|
|
30
|
+
// idiom); the core halves pin require.cache (the tests/profile_route.mjs idiom)
|
|
31
|
+
// so the REAL handlers and the REAL chain run against spies.
|
|
32
|
+
//
|
|
33
|
+
// Run: node tests/hub_recent_ships.mjs
|
|
34
|
+
|
|
35
|
+
import { strict as assert } from 'node:assert';
|
|
36
|
+
import { createRequire } from 'node:module';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
|
|
43
|
+
let passed = 0, failed = 0;
|
|
44
|
+
async function t(name, fn) {
|
|
45
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
46
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A recording fake pool: answers by SQL shape and records every call, so a test
|
|
50
|
+
// asserts the params the real query actually bound.
|
|
51
|
+
function mockPool(answer = () => ({ rows: [] })) {
|
|
52
|
+
const calls = [];
|
|
53
|
+
return {
|
|
54
|
+
calls,
|
|
55
|
+
query: async (text, params = []) => {
|
|
56
|
+
calls.push({ text: String(text), params });
|
|
57
|
+
return answer(String(text), params) || { rows: [] };
|
|
58
|
+
},
|
|
59
|
+
find: (re) => calls.find((c) => re.test(c.text)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
64
|
+
|
|
65
|
+
const ORIGIN = 'https://p07.example';
|
|
66
|
+
const ship = (over = {}) => ({
|
|
67
|
+
id: 1002803, title: 'A shipped work', shipped_at: '2026-08-28T10:00:00.000Z',
|
|
68
|
+
url: `${ORIGIN}/builders#/task/1002803`, ...over,
|
|
69
|
+
});
|
|
70
|
+
// What upsertActivity BOUND as recent_ships — the jsonb param, parsed back.
|
|
71
|
+
async function storedShips(recentShips, clientOrigin = ORIGIN) {
|
|
72
|
+
const pool = mockPool(() => ({ rows: [{ id: 1 }] }));
|
|
73
|
+
await pi.upsertActivity({ clientId: 'c1', githubId: 42, recentShips, clientOrigin }, { pool });
|
|
74
|
+
const call = pool.find(/INSERT INTO platform_identity_builder_project_activity/);
|
|
75
|
+
assert.ok(call, 'the activity upsert ran');
|
|
76
|
+
const raw = call.params[7];
|
|
77
|
+
assert.equal(typeof raw, 'string', 'jsonb is bound as a JSON string, not a JS array (pg would emit a Postgres array literal)');
|
|
78
|
+
return JSON.parse(raw);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log('\n1. the cap and the shape are the HUB\'s, not the sender\'s:');
|
|
82
|
+
|
|
83
|
+
await t('a client reporting 5 works stores exactly 3', async () => {
|
|
84
|
+
const five = [1, 2, 3, 4, 5].map((n) => ship({ id: n, title: `work ${n}` }));
|
|
85
|
+
const out = await storedShips(five);
|
|
86
|
+
assert.equal(out.length, 3);
|
|
87
|
+
assert.deepEqual(out.map((s) => s.id), [1, 2, 3], 'the first three, in reported order');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await t('no works stores an empty list, never null and never a stray shape', async () => {
|
|
91
|
+
for (const nothing of [[], undefined, null, 'nope', { id: 1 }]) {
|
|
92
|
+
const out = await storedShips(nothing);
|
|
93
|
+
assert.deepEqual(out, [], `${JSON.stringify(nothing) ?? 'undefined'} → []`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await t('a stored work carries exactly id + title + shipped_at + url', async () => {
|
|
98
|
+
const [s] = await storedShips([ship({ extra: 'DO-NOT-STORE' })]);
|
|
99
|
+
assert.deepEqual(Object.keys(s).sort(), ['id', 'shipped_at', 'title', 'url']);
|
|
100
|
+
assert.equal(s.id, 1002803);
|
|
101
|
+
assert.equal(s.title, 'A shipped work');
|
|
102
|
+
assert.equal(s.shipped_at, '2026-08-28T10:00:00.000Z');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await t('a work with no usable id or title is dropped, not stored half-empty', async () => {
|
|
106
|
+
const out = await storedShips([
|
|
107
|
+
ship({ id: 'not-a-number' }),
|
|
108
|
+
ship({ id: 7, title: ' ' }),
|
|
109
|
+
ship({ id: -1 }),
|
|
110
|
+
ship({ id: 9, title: 'kept' }),
|
|
111
|
+
]);
|
|
112
|
+
assert.deepEqual(out.map((s) => s.id), [9]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await t('an unparseable date nulls only the date, never the work', async () => {
|
|
116
|
+
const [s] = await storedShips([ship({ shipped_at: 'yesterday-ish' })]);
|
|
117
|
+
assert.equal(s.shipped_at, null);
|
|
118
|
+
assert.equal(s.id, 1002803, 'the work still publishes without a date');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await t('a title longer than the cap is truncated, not rejected', async () => {
|
|
122
|
+
const [s] = await storedShips([ship({ title: 'x'.repeat(400) })]);
|
|
123
|
+
assert.equal(s.title.length, 200);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
console.log('\n2. a ship link may only point back into the reporting instance:');
|
|
127
|
+
|
|
128
|
+
for (const [label, url] of [
|
|
129
|
+
['another site', 'https://evil.example/phish'],
|
|
130
|
+
['a javascript: scheme', 'javascript:alert(1)'],
|
|
131
|
+
['embedded credentials', 'https://user:pw@p07.example/x'],
|
|
132
|
+
['a lookalike suffix', 'https://notp07.example/x'],
|
|
133
|
+
['a non-string', 12345],
|
|
134
|
+
// The mirror of the subdomain rule is NOT offered: on a shared multi-tenant
|
|
135
|
+
// apex it would let a client registered at foo.github.io publish links on bare
|
|
136
|
+
// github.io. The bound is downward only.
|
|
137
|
+
['an ancestor of the registered origin', 'https://example/x'],
|
|
138
|
+
]) {
|
|
139
|
+
await t(`${label} stores no link (the work still publishes)`, async () => {
|
|
140
|
+
const [s] = await storedShips([ship({ url })]);
|
|
141
|
+
assert.equal(s.url, null);
|
|
142
|
+
assert.equal(s.id, 1002803);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await t('the client\'s own origin and a subdomain of it are kept', async () => {
|
|
147
|
+
const same = await storedShips([ship({ url: `${ORIGIN}/builders#/task/1` })]);
|
|
148
|
+
assert.equal(same[0].url, `${ORIGIN}/builders#/task/1`);
|
|
149
|
+
const sub = await storedShips([ship({ url: 'https://builders.p07.example/builders#/task/1' })]);
|
|
150
|
+
assert.equal(sub[0].url, 'https://builders.p07.example/builders#/task/1', 'an instance hall on a subdomain of its registered origin');
|
|
151
|
+
// …but never UP. A client registered on a subdomain may not publish links on
|
|
152
|
+
// the apex above it — that apex may be shared (the github.io shape).
|
|
153
|
+
const up = await storedShips([ship({ url: 'https://p07.example/x' })], 'https://builders.p07.example');
|
|
154
|
+
assert.equal(up[0].url, null, 'the bound is downward only');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
console.log('\n3. POST /sso/activity/rollup accepts the field and hands the hub the trusted origin:');
|
|
158
|
+
|
|
159
|
+
await t('the route passes recent_ships through and takes the origin from the CLIENT row', async () => {
|
|
160
|
+
const SECRET = 'p07-secret';
|
|
161
|
+
const rollupPool = mockPool((sql) => {
|
|
162
|
+
if (/FROM platform_identity_sso_clients/.test(sql)) {
|
|
163
|
+
return { rows: [{ client_id: 'c1', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: ORIGIN }] };
|
|
164
|
+
}
|
|
165
|
+
if (/FROM platform_identity_project_memberships/.test(sql)) {
|
|
166
|
+
return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member' }] };
|
|
167
|
+
}
|
|
168
|
+
return { rows: [{ id: 1 }] };
|
|
169
|
+
});
|
|
170
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
171
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
172
|
+
const realApi = require('../src/module-api.js');
|
|
173
|
+
require.cache[moduleApiPath] = {
|
|
174
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
175
|
+
exports: {
|
|
176
|
+
pool: rollupPool,
|
|
177
|
+
validateOrRespond: helpers.validateOrRespond,
|
|
178
|
+
resolveSession: async () => null,
|
|
179
|
+
logger: () => ({ info() {}, warn() {}, error() {} }),
|
|
180
|
+
LIVE_RANK_LADDER: realApi.LIVE_RANK_LADDER,
|
|
181
|
+
corsPublicGet: () => (_q, _s, next) => next(),
|
|
182
|
+
on: () => () => {},
|
|
183
|
+
hasProvider: () => false,
|
|
184
|
+
registerProvider: () => {},
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
const ssoRoutes = require('../modules/platform-identity/routes/sso.js');
|
|
188
|
+
const router = ssoRoutes();
|
|
189
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/sso/activity/rollup');
|
|
190
|
+
assert.ok(layer, 'the rollup route is mounted');
|
|
191
|
+
|
|
192
|
+
const res = { body: null, failure: null };
|
|
193
|
+
res.json = (b) => { res.body = b; return res; };
|
|
194
|
+
res.fail = (code, ...rest) => { res.failure = { code, rest }; return res; };
|
|
195
|
+
const req = {
|
|
196
|
+
body: {
|
|
197
|
+
client_id: 'c1', client_secret: SECRET, github_id: 42,
|
|
198
|
+
credits: 10, tasks_shipped: 2, karma: 1, rank: 'metic', disciplines: ['engineer'],
|
|
199
|
+
// Five, so the HUB's cap is what decides — a sender that ignores its own
|
|
200
|
+
// cap must not widen the section.
|
|
201
|
+
recent_ships: [1, 2, 3, 4, 5].map((n) => ship({ id: n, title: `work ${n}`, url: `${ORIGIN}/builders#/task/${n}` })),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
await layer.route.stack[0].handle(req, res, () => {});
|
|
205
|
+
assert.equal(res.failure, null, `no failure (validateOrRespond runs in STRICT mode, so an unknown recent_ships would 400 here): ${JSON.stringify(res.failure)}`);
|
|
206
|
+
assert.deepEqual(res.body, { ok: true });
|
|
207
|
+
|
|
208
|
+
const insert = rollupPool.find(/INSERT INTO platform_identity_builder_project_activity/);
|
|
209
|
+
const stored = JSON.parse(insert.params[7]);
|
|
210
|
+
assert.equal(stored.length, 3, 'the hub capped what the sender did not');
|
|
211
|
+
assert.equal(stored[0].url, `${ORIGIN}/builders#/task/1`, 'links pass the origin bound taken from the authenticated client row');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
console.log('\n4. a work title surfaces only where the project name may:');
|
|
215
|
+
|
|
216
|
+
await t('getRollupForBuilder attaches recent_ships to publicly-named projects only', async () => {
|
|
217
|
+
const pool = mockPool((sql) => {
|
|
218
|
+
if (/WITH agg AS/.test(sql)) {
|
|
219
|
+
return { rows: [{ projects: 2, credits: 5, works_shipped: 2, karma: 0, last_active: null, disciplines: ['engineer'] }] };
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
rows: [
|
|
223
|
+
{ name: 'Public Project', origin: ORIGIN, rank_held: 'metic', is_public: true, recent_ships: [ship()] },
|
|
224
|
+
{ name: 'SECRET-PROJECT', origin: 'https://priv.example', rank_held: 'thetes', is_public: false, recent_ships: [ship({ id: 5, title: 'SECRET-WORK' })] },
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
const out = await pi.getRollupForBuilder(42, { pool });
|
|
229
|
+
assert.equal(out.projects.length, 1);
|
|
230
|
+
assert.equal(out.more_projects, 1);
|
|
231
|
+
assert.deepEqual(out.projects[0].recent_ships, [ship()]);
|
|
232
|
+
const serialized = JSON.stringify(out);
|
|
233
|
+
assert.ok(!serialized.includes('SECRET-WORK'), 'a non-public project\'s work titles never leave the hub');
|
|
234
|
+
assert.ok(!serialized.includes('SECRET-PROJECT'), 'nor does its name (the pre-existing rule this one follows)');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await t('a project that reported no works reads as an empty list, not null', async () => {
|
|
238
|
+
const pool = mockPool((sql) => {
|
|
239
|
+
if (/WITH agg AS/.test(sql)) return { rows: [{ projects: 1, credits: 0, works_shipped: 0, karma: 0, last_active: null, disciplines: null }] };
|
|
240
|
+
return { rows: [{ name: 'P', origin: ORIGIN, rank_held: null, is_public: true, recent_ships: null }] };
|
|
241
|
+
});
|
|
242
|
+
const out = await pi.getRollupForBuilder(42, { pool });
|
|
243
|
+
assert.deepEqual(out.projects[0].recent_ships, []);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await t('hide_stats suppresses the whole cross-project read, recent_ships included', async () => {
|
|
247
|
+
for (const acct of [
|
|
248
|
+
{ profile_state: 'active', hide_stats: true },
|
|
249
|
+
{ profile_state: 'provisional', hide_stats: false },
|
|
250
|
+
]) {
|
|
251
|
+
const pool = mockPool(() => ({ rows: [acct] }));
|
|
252
|
+
assert.equal(await pi.getPublicProfileExtras(42, { pool }), null, JSON.stringify(acct));
|
|
253
|
+
assert.ok(!pool.find(/WITH agg AS/), 'the rollup query never even runs — no partial numbers, no leak by subtraction');
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// The core halves. Everything below pins require.cache BEFORE the core files
|
|
259
|
+
// load, so the REAL handlers run against spies.
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
const admissionPath = require.resolve('../src/bongos/auth-admission.js');
|
|
263
|
+
const realAdmission = require('../src/bongos/auth-admission.js');
|
|
264
|
+
const spy = { shipPushes: [], order: [] };
|
|
265
|
+
// A mutable exports object: section 6 re-points the two reporters at spies
|
|
266
|
+
// after section 5 has used the real ones.
|
|
267
|
+
const admissionExports = {
|
|
268
|
+
...realAdmission,
|
|
269
|
+
reportActivityRollupForShippedTask: async (task) => { spy.shipPushes.push(task); },
|
|
270
|
+
};
|
|
271
|
+
require.cache[admissionPath] = {
|
|
272
|
+
id: admissionPath, filename: admissionPath, loaded: true, exports: admissionExports,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
console.log('\n5. the ship-time push — hub numbers stop being as stale as the last sign-in:');
|
|
276
|
+
|
|
277
|
+
await t('a task reaching shipped fires the rollup push exactly once', async () => {
|
|
278
|
+
const seams = require('../src/module-seams.js');
|
|
279
|
+
const buildAuthRouter = require('../src/bongos/routes/auth.js');
|
|
280
|
+
buildAuthRouter();
|
|
281
|
+
spy.shipPushes.length = 0;
|
|
282
|
+
const invoked = await seams.emitAsync('task.shipped', { id: 1002803, shipped_by: 7 });
|
|
283
|
+
assert.ok(invoked >= 1, 'the core auth router registered a task.shipped listener');
|
|
284
|
+
assert.equal(spy.shipPushes.length, 1, 'the ship push fired once');
|
|
285
|
+
assert.equal(spy.shipPushes[0].shipped_by, 7);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await t('re-instantiating the router does not stack a second listener', async () => {
|
|
289
|
+
const seams = require('../src/module-seams.js');
|
|
290
|
+
const buildAuthRouter = require('../src/bongos/routes/auth.js');
|
|
291
|
+
buildAuthRouter();
|
|
292
|
+
buildAuthRouter();
|
|
293
|
+
spy.shipPushes.length = 0;
|
|
294
|
+
await seams.emitAsync('task.shipped', { id: 1, shipped_by: 7 });
|
|
295
|
+
assert.equal(spy.shipPushes.length, 1, 'off→on guarded — a ship is pushed once, not once per router build');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
console.log('\n6. the check-in is AWAITED before the rollup (ADR 0171 D7 sequencing):');
|
|
299
|
+
|
|
300
|
+
await t('a federated sign-in does not report activity until its membership check-in has resolved', async () => {
|
|
301
|
+
// Re-point the reporters at ordering spies, then load a FRESH auth-idp so it
|
|
302
|
+
// destructures these rather than the real ones section 5 left in place.
|
|
303
|
+
let releaseCheckIn;
|
|
304
|
+
const checkInLanded = new Promise((r) => { releaseCheckIn = r; });
|
|
305
|
+
admissionExports.reportMembershipCheckIn = async () => {
|
|
306
|
+
spy.order.push('check-in:start');
|
|
307
|
+
await checkInLanded;
|
|
308
|
+
spy.order.push('check-in:done');
|
|
309
|
+
};
|
|
310
|
+
admissionExports.reportActivityRollup = async () => { spy.order.push('rollup'); };
|
|
311
|
+
admissionExports.openEnrollmentEnabled = () => true;
|
|
312
|
+
admissionExports.maybeSeatFirstAdmin = async (b) => b;
|
|
313
|
+
admissionExports.safeSyncRankRole = async () => {};
|
|
314
|
+
|
|
315
|
+
const pin = (rel, exports) => {
|
|
316
|
+
const p = require.resolve(rel);
|
|
317
|
+
require.cache[p] = { id: p, filename: p, loaded: true, exports };
|
|
318
|
+
return p;
|
|
319
|
+
};
|
|
320
|
+
pin('../src/bongos/db-kernel.js', {
|
|
321
|
+
RANKS: ['xenos', 'thetes', 'metic', 'archon'],
|
|
322
|
+
upsertBuilder: async () => ({ id: 7, github_id: 42, rank: 'metic' }),
|
|
323
|
+
getBuilderByGithubId: async () => ({ id: 7, github_id: 42, rank: 'metic' }),
|
|
324
|
+
getBuilderById: async () => ({ id: 7, github_id: 42, rank: 'metic' }),
|
|
325
|
+
createSession: async () => ({}),
|
|
326
|
+
lookupSession: async () => null,
|
|
327
|
+
seatFirstAdminIfUnclaimed: async () => null,
|
|
328
|
+
hasInvitedAccessRequest: async () => true,
|
|
329
|
+
accessStateByLogin: async () => ({}),
|
|
330
|
+
});
|
|
331
|
+
pin('../src/bongos/pool.js', { pool: { query: async () => ({ rows: [] }) } });
|
|
332
|
+
pin('../src/bongos/auth-config.js', {
|
|
333
|
+
loadIdpConfig: () => ({ origin: 'https://hub.example', clientId: 'c1', clientSecret: 's' }),
|
|
334
|
+
});
|
|
335
|
+
pin('../src/bongos/auth-github.js', { newSessionToken: () => 'tok' });
|
|
336
|
+
delete require.cache[require.resolve('../src/bongos/auth-idp.js')];
|
|
337
|
+
|
|
338
|
+
const realFetch = globalThis.fetch;
|
|
339
|
+
globalThis.fetch = async () => ({
|
|
340
|
+
ok: true,
|
|
341
|
+
json: async () => ({ github_id: 42, github_login: 'p07', display_name: 'P07', avatar_url: null }),
|
|
342
|
+
text: async () => '',
|
|
343
|
+
});
|
|
344
|
+
try {
|
|
345
|
+
const idp = require('../src/bongos/auth-idp.js');
|
|
346
|
+
await idp.completeIdpFlow({ code: 'x', redirectUri: 'https://p07.example/cb' });
|
|
347
|
+
// The sign-in has RETURNED and the check-in is still in flight. If the two
|
|
348
|
+
// pushes were fired side by side, the rollup would already be in `order`
|
|
349
|
+
// here — and on a first sign-in it would reach a hub with no membership row.
|
|
350
|
+
await new Promise((r) => setImmediate(r));
|
|
351
|
+
assert.deepEqual(spy.order, ['check-in:start'], `rollup must not have fired yet, saw ${JSON.stringify(spy.order)}`);
|
|
352
|
+
releaseCheckIn();
|
|
353
|
+
await new Promise((r) => setImmediate(r));
|
|
354
|
+
await new Promise((r) => setImmediate(r));
|
|
355
|
+
assert.deepEqual(spy.order, ['check-in:start', 'check-in:done', 'rollup'], 'the rollup runs only after the check-in resolved');
|
|
356
|
+
} finally {
|
|
357
|
+
globalThis.fetch = realFetch;
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await t('the CLI device exchange chains the same way (both federated sign-ins, one rule)', async () => {
|
|
362
|
+
// completeDeviceExchange needs a hub-signed Ed25519 assertion to reach its
|
|
363
|
+
// chain, which is a different test's subject — so this half is pinned at the
|
|
364
|
+
// source, normalised to LF first (this checkout reads back CRLF, and an
|
|
365
|
+
// anchored multi-line match silently finds nothing otherwise).
|
|
366
|
+
const fs = require('node:fs');
|
|
367
|
+
const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth-idp.js'), 'utf8').replace(/\r\n/g, '\n');
|
|
368
|
+
const chains = src.match(/reportMembershipCheckIn\([\s\S]{0,400}?\n\s*\.then\(\(\) => reportActivityRollup\(/g) || [];
|
|
369
|
+
assert.equal(chains.length, 2, 'both the web flow and the device exchange chain the rollup after the check-in');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
console.log(`\nhub_recent_ships.mjs: ${passed} passed, ${failed} failed`);
|
|
373
|
+
process.exit(failed ? 1 : 0);
|