@bongos/core 1.19.169 → 1.19.171
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +37 -27
- 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 +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +126 -3
- package/docs/api-reference.md +4 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/profile.html +13 -1
- package/modules/hall-ui/public/profile.js +98 -5
- package/modules/hall-ui/public/settings.html +14 -0
- package/modules/hall-ui/public/settings.js +84 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +21 -0
- package/modules/platform-identity/routes/profile.js +77 -0
- package/modules/platform-identity/tests/platform-identity.mjs +22 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +1 -0
- package/src/module-api.js +5 -1
- package/tests/profile_rollup_consent.mjs +25 -0
- package/tests/profile_ui_cross_project.mjs +182 -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.171",
|
|
6
|
+
"core_contract": "1.19.171",
|
|
7
|
+
"source_commit": "d102a0205fd62edcc81a804a8c439af4cde60606",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-16T19:20:36.950Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 305,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1269,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1595,
|
|
20
|
+
"tree_sha256": "1596294ffbb9fe146a18ced51823d4b074c23b5dd47601619bcd16752c5eaf04",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -347,12 +347,12 @@
|
|
|
347
347
|
{
|
|
348
348
|
"path": "clients/bongos-client/README.md",
|
|
349
349
|
"mode": "0000644",
|
|
350
|
-
"sha256": "
|
|
350
|
+
"sha256": "d6d6163164f98c96a8e25dfe84825669bf8324f1f1b98adb0674781ad235ad10"
|
|
351
351
|
},
|
|
352
352
|
{
|
|
353
353
|
"path": "clients/bongos-client/bongos-client.global.js",
|
|
354
354
|
"mode": "0000644",
|
|
355
|
-
"sha256": "
|
|
355
|
+
"sha256": "386706d0ef3d09c5e074bdc45bfc0de869d732eb1cb2a2280472f02f797f2b74"
|
|
356
356
|
},
|
|
357
357
|
{
|
|
358
358
|
"path": "clients/bongos-client/examples/hello-world.mjs",
|
|
@@ -362,17 +362,17 @@
|
|
|
362
362
|
{
|
|
363
363
|
"path": "clients/bongos-client/index.cjs",
|
|
364
364
|
"mode": "0000644",
|
|
365
|
-
"sha256": "
|
|
365
|
+
"sha256": "7d462b5b80c9db9159981406398fc62c5ba71043c6a3b9433852d06d612bf2b0"
|
|
366
366
|
},
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "4acb1dde6f894ba37cff7e27e0110f12ae30c7cb6e4d972d6e85d2877489b6d0"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
374
374
|
"mode": "0000644",
|
|
375
|
-
"sha256": "
|
|
375
|
+
"sha256": "4c7679ef258644ab12db1b8fe412b388f96f4296c6aae45d6ab637a8f3b9a037"
|
|
376
376
|
},
|
|
377
377
|
{
|
|
378
378
|
"path": "clients/bongos-client/package.json",
|
|
@@ -1347,12 +1347,12 @@
|
|
|
1347
1347
|
{
|
|
1348
1348
|
"path": "docs/api-reference.md",
|
|
1349
1349
|
"mode": "0000644",
|
|
1350
|
-
"sha256": "
|
|
1350
|
+
"sha256": "ccf59372e0456f36d83154d6dff4dbf5382e8d69023bee82b318b8e9cfb2c50b"
|
|
1351
1351
|
},
|
|
1352
1352
|
{
|
|
1353
1353
|
"path": "docs/api/openapi.json",
|
|
1354
1354
|
"mode": "0000644",
|
|
1355
|
-
"sha256": "
|
|
1355
|
+
"sha256": "4deac83a1d7c12e1a24109e856d9a6fef6b539afafdca55a525ab6786fe4e235"
|
|
1356
1356
|
},
|
|
1357
1357
|
{
|
|
1358
1358
|
"path": "docs/architecture.md",
|
|
@@ -2662,7 +2662,7 @@
|
|
|
2662
2662
|
{
|
|
2663
2663
|
"path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
|
|
2664
2664
|
"mode": "0000644",
|
|
2665
|
-
"sha256": "
|
|
2665
|
+
"sha256": "7d462b5b80c9db9159981406398fc62c5ba71043c6a3b9433852d06d612bf2b0"
|
|
2666
2666
|
},
|
|
2667
2667
|
{
|
|
2668
2668
|
"path": "modules/dev-box/app/test/unit.mjs",
|
|
@@ -3317,12 +3317,12 @@
|
|
|
3317
3317
|
{
|
|
3318
3318
|
"path": "modules/hall-ui/public/profile.html",
|
|
3319
3319
|
"mode": "0000644",
|
|
3320
|
-
"sha256": "
|
|
3320
|
+
"sha256": "b8d5fb31d92f6d65e8f257d10c77f4ee0aa0ebec50d977d4c7d918ff2253da1e"
|
|
3321
3321
|
},
|
|
3322
3322
|
{
|
|
3323
3323
|
"path": "modules/hall-ui/public/profile.js",
|
|
3324
3324
|
"mode": "0000644",
|
|
3325
|
-
"sha256": "
|
|
3325
|
+
"sha256": "a4cf0c4c2d088fd1fb84c41c93cc28e2acd2b656b0bc995cd46043881b3611fa"
|
|
3326
3326
|
},
|
|
3327
3327
|
{
|
|
3328
3328
|
"path": "modules/hall-ui/public/ranks.html",
|
|
@@ -3382,12 +3382,12 @@
|
|
|
3382
3382
|
{
|
|
3383
3383
|
"path": "modules/hall-ui/public/settings.html",
|
|
3384
3384
|
"mode": "0000644",
|
|
3385
|
-
"sha256": "
|
|
3385
|
+
"sha256": "ae68a26d5d37ac75968ac11335b7062a448798cb3af0e0a4f81963f3382f204d"
|
|
3386
3386
|
},
|
|
3387
3387
|
{
|
|
3388
3388
|
"path": "modules/hall-ui/public/settings.js",
|
|
3389
3389
|
"mode": "0000644",
|
|
3390
|
-
"sha256": "
|
|
3390
|
+
"sha256": "2b69d258218e92b9fe2922496632e43d4bdb81f641f09469f5f6df85494a9fca"
|
|
3391
3391
|
},
|
|
3392
3392
|
{
|
|
3393
3393
|
"path": "modules/hall-ui/public/shell.js",
|
|
@@ -3792,12 +3792,12 @@
|
|
|
3792
3792
|
{
|
|
3793
3793
|
"path": "modules/platform-identity/module.json",
|
|
3794
3794
|
"mode": "0000644",
|
|
3795
|
-
"sha256": "
|
|
3795
|
+
"sha256": "e993fb26e61249bf89c0b0a5977c60e9d974810ffe1b67e94d909e8b52e00d5e"
|
|
3796
3796
|
},
|
|
3797
3797
|
{
|
|
3798
3798
|
"path": "modules/platform-identity/platform-identity.js",
|
|
3799
3799
|
"mode": "0000644",
|
|
3800
|
-
"sha256": "
|
|
3800
|
+
"sha256": "96907af1af78c472118ecf5ed9f269d6d2ce940b09535aae3b3df4de4d2337c1"
|
|
3801
3801
|
},
|
|
3802
3802
|
{
|
|
3803
3803
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -3814,6 +3814,11 @@
|
|
|
3814
3814
|
"mode": "0000644",
|
|
3815
3815
|
"sha256": "7fdb5a107e1ad84214c0d07507a7fd14b9234c9907224fddc0f6a70c850f460a"
|
|
3816
3816
|
},
|
|
3817
|
+
{
|
|
3818
|
+
"path": "modules/platform-identity/routes/profile.js",
|
|
3819
|
+
"mode": "0000644",
|
|
3820
|
+
"sha256": "6ea29b4f07417fa925b7987dd7fbe8a0262b7fec83ceffade9fccbc220289bba"
|
|
3821
|
+
},
|
|
3817
3822
|
{
|
|
3818
3823
|
"path": "modules/platform-identity/routes/projects.js",
|
|
3819
3824
|
"mode": "0000644",
|
|
@@ -3832,7 +3837,7 @@
|
|
|
3832
3837
|
{
|
|
3833
3838
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
3834
3839
|
"mode": "0000644",
|
|
3835
|
-
"sha256": "
|
|
3840
|
+
"sha256": "18bf8d0cb42d76bc5413c760b13837fe439a6e719b88a84db96f143b829e67d5"
|
|
3836
3841
|
},
|
|
3837
3842
|
{
|
|
3838
3843
|
"path": "modules/platform-identity/ui/.gitkeep",
|
|
@@ -4207,12 +4212,12 @@
|
|
|
4207
4212
|
{
|
|
4208
4213
|
"path": "package-lock.json",
|
|
4209
4214
|
"mode": "0000644",
|
|
4210
|
-
"sha256": "
|
|
4215
|
+
"sha256": "90e1fc07f655eb2874620b5a85f965e4f32cc20aeddb552dbc8885c4681150d9"
|
|
4211
4216
|
},
|
|
4212
4217
|
{
|
|
4213
4218
|
"path": "package.json",
|
|
4214
4219
|
"mode": "0000644",
|
|
4215
|
-
"sha256": "
|
|
4220
|
+
"sha256": "2b1408849261b93418e448820da8081a7ec83fa6b5c2a51b9e1aa5dca7456efb"
|
|
4216
4221
|
},
|
|
4217
4222
|
{
|
|
4218
4223
|
"path": "public-docs/index.html",
|
|
@@ -5472,7 +5477,7 @@
|
|
|
5472
5477
|
{
|
|
5473
5478
|
"path": "scripts/hall-preview/server.js",
|
|
5474
5479
|
"mode": "0000644",
|
|
5475
|
-
"sha256": "
|
|
5480
|
+
"sha256": "1cd66475d610b107b0f3b7f19688133e7c482b41b034e2cef6009d552df31cb2"
|
|
5476
5481
|
},
|
|
5477
5482
|
{
|
|
5478
5483
|
"path": "scripts/migrate.sh",
|
|
@@ -5817,7 +5822,7 @@
|
|
|
5817
5822
|
{
|
|
5818
5823
|
"path": "src/module-api.js",
|
|
5819
5824
|
"mode": "0000644",
|
|
5820
|
-
"sha256": "
|
|
5825
|
+
"sha256": "17202c8572c75ca1af5cd2eee1c8dd1ba4b6ea4c5fd7cd757eaf97907d26a268"
|
|
5821
5826
|
},
|
|
5822
5827
|
{
|
|
5823
5828
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7377,13 +7382,18 @@
|
|
|
7377
7382
|
{
|
|
7378
7383
|
"path": "tests/profile_rollup_consent.mjs",
|
|
7379
7384
|
"mode": "0000644",
|
|
7380
|
-
"sha256": "
|
|
7385
|
+
"sha256": "b926ffa5d4e96ef223e4057ecc3e64d678ca1e16ec4afe79cb90cbf3e35e65e0"
|
|
7381
7386
|
},
|
|
7382
7387
|
{
|
|
7383
7388
|
"path": "tests/profile_route.mjs",
|
|
7384
7389
|
"mode": "0000644",
|
|
7385
7390
|
"sha256": "3b3045a6143d67c963516ed5703b5dabb9b8945a392118721e6a0a56258f1732"
|
|
7386
7391
|
},
|
|
7392
|
+
{
|
|
7393
|
+
"path": "tests/profile_ui_cross_project.mjs",
|
|
7394
|
+
"mode": "0000644",
|
|
7395
|
+
"sha256": "6a7a223a678b56333befaed017429e5b9d8a81e4405efae72a23355902517d9d"
|
|
7396
|
+
},
|
|
7387
7397
|
{
|
|
7388
7398
|
"path": "tests/project_stats.mjs",
|
|
7389
7399
|
"mode": "0000644",
|
|
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
|
|
|
5
5
|
by hand; it regenerates when the spec changes, so it can never drift from the routes.
|
|
6
6
|
|
|
7
7
|
- API version: **v1** (served at `/api/bongos/v1`)
|
|
8
|
-
-
|
|
8
|
+
- 273 operations across 45 resource groups
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -409,6 +409,10 @@ function createClient(opts = {}) {
|
|
|
409
409
|
getMeAchievements: (args) => request("GET", "/me/achievements", { hasBody: false }, args),
|
|
410
410
|
// POST /me/anthropic-link — rank: any-builder — POST /me/anthropic-link
|
|
411
411
|
postMeAnthropicLink: (args) => request("POST", "/me/anthropic-link", { hasBody: true }, args),
|
|
412
|
+
// GET /me/cross-project — rank: any-builder — GET /me/cross-project
|
|
413
|
+
getMeCrossProject: (args) => request("GET", "/me/cross-project", { hasBody: false }, args),
|
|
414
|
+
// PATCH /me/cross-project — rank: any-builder — PATCH /me/cross-project
|
|
415
|
+
patchMeCrossProject: (args) => request("PATCH", "/me/cross-project", { hasBody: true }, args),
|
|
412
416
|
// PATCH /me/disciplines — rank: any-builder — PATCH /me/disciplines
|
|
413
417
|
patchMeDisciplines: (args) => request("PATCH", "/me/disciplines", { hasBody: true }, args),
|
|
414
418
|
// GET /me/display-name — rank: any-builder — GET /me/display-name
|
|
@@ -408,6 +408,10 @@ function createClient(opts = {}) {
|
|
|
408
408
|
getMeAchievements: (args) => request("GET", "/me/achievements", { hasBody: false }, args),
|
|
409
409
|
// POST /me/anthropic-link — rank: any-builder — POST /me/anthropic-link
|
|
410
410
|
postMeAnthropicLink: (args) => request("POST", "/me/anthropic-link", { hasBody: true }, args),
|
|
411
|
+
// GET /me/cross-project — rank: any-builder — GET /me/cross-project
|
|
412
|
+
getMeCrossProject: (args) => request("GET", "/me/cross-project", { hasBody: false }, args),
|
|
413
|
+
// PATCH /me/cross-project — rank: any-builder — PATCH /me/cross-project
|
|
414
|
+
patchMeCrossProject: (args) => request("PATCH", "/me/cross-project", { hasBody: true }, args),
|
|
411
415
|
// PATCH /me/disciplines — rank: any-builder — PATCH /me/disciplines
|
|
412
416
|
patchMeDisciplines: (args) => request("PATCH", "/me/disciplines", { hasBody: true }, args),
|
|
413
417
|
// GET /me/display-name — rank: any-builder — GET /me/display-name
|
|
@@ -51,6 +51,7 @@ export interface GetInboxResponse { ideas: unknown; open_count: unknown }
|
|
|
51
51
|
export interface GetLeaderboardResponse { leaderboard: unknown; page: unknown }
|
|
52
52
|
export interface GetLearningsResponse { learnings: unknown }
|
|
53
53
|
export interface GetMeAchievementsResponse { unlocked: unknown }
|
|
54
|
+
export interface GetMeCrossProjectResponse { hide_stats: boolean; profile_state: unknown; cross_project: unknown }
|
|
54
55
|
export interface GetMeDisplayNameResponse { display_name: unknown; max_length: unknown }
|
|
55
56
|
export interface GetMeEventSoundPrefsResponse { prefs: unknown; effective: unknown; effective_wav: unknown; events: unknown }
|
|
56
57
|
export interface GetMeRecentShipsResponse { ships: unknown }
|
|
@@ -124,6 +125,8 @@ export interface PatchGoalsIdVisibilityRequest { visibility: string }
|
|
|
124
125
|
export interface PatchGoalsIdVisibilityResponse { ok: boolean; goal: unknown }
|
|
125
126
|
export interface PatchInboxIdRequest { status?: "open" | "promoted" | "discarded" | "merged"; kind?: string; promoted_to_task_id?: number; reviewed_at?: unknown; merged_into_idea_id?: number; triage_note?: string }
|
|
126
127
|
export interface PatchInboxIdResponse { idea: unknown }
|
|
128
|
+
export interface PatchMeCrossProjectRequest { hide_stats: boolean }
|
|
129
|
+
export interface PatchMeCrossProjectResponse { hide_stats: boolean }
|
|
127
130
|
export interface PatchMeDisciplinesRequest { preferred_disciplines: string[] }
|
|
128
131
|
export interface PatchMeDisciplinesResponse { builder: unknown; onboarding: unknown }
|
|
129
132
|
export interface PatchMeDisplayNameResponse { display_name: unknown; max_length: unknown }
|
|
@@ -645,6 +648,10 @@ export interface BongosClient {
|
|
|
645
648
|
getMeAchievements(args?: RequestArgs): Promise<GetMeAchievementsResponse>;
|
|
646
649
|
/** POST /me/anthropic-link — rank: any-builder */
|
|
647
650
|
postMeAnthropicLink(args: RequestArgs & { body: PostMeAnthropicLinkRequest }): Promise<PostMeAnthropicLinkResponse>;
|
|
651
|
+
/** GET /me/cross-project — rank: any-builder */
|
|
652
|
+
getMeCrossProject(args?: RequestArgs): Promise<GetMeCrossProjectResponse>;
|
|
653
|
+
/** PATCH /me/cross-project — rank: any-builder */
|
|
654
|
+
patchMeCrossProject(args: RequestArgs & { body: PatchMeCrossProjectRequest }): Promise<PatchMeCrossProjectResponse>;
|
|
648
655
|
/** PATCH /me/disciplines — rank: any-builder */
|
|
649
656
|
patchMeDisciplines(args: RequestArgs & { body: PatchMeDisciplinesRequest }): Promise<PatchMeDisciplinesResponse>;
|
|
650
657
|
/** GET /me/display-name — rank: any-builder */
|
|
@@ -405,6 +405,10 @@ export function createClient(opts = {}) {
|
|
|
405
405
|
getMeAchievements: (args) => request("GET", "/me/achievements", { hasBody: false }, args),
|
|
406
406
|
// POST /me/anthropic-link — rank: any-builder — POST /me/anthropic-link
|
|
407
407
|
postMeAnthropicLink: (args) => request("POST", "/me/anthropic-link", { hasBody: true }, args),
|
|
408
|
+
// GET /me/cross-project — rank: any-builder — GET /me/cross-project
|
|
409
|
+
getMeCrossProject: (args) => request("GET", "/me/cross-project", { hasBody: false }, args),
|
|
410
|
+
// PATCH /me/cross-project — rank: any-builder — PATCH /me/cross-project
|
|
411
|
+
patchMeCrossProject: (args) => request("PATCH", "/me/cross-project", { hasBody: true }, args),
|
|
408
412
|
// PATCH /me/disciplines — rank: any-builder — PATCH /me/disciplines
|
|
409
413
|
patchMeDisciplines: (args) => request("PATCH", "/me/disciplines", { hasBody: true }, args),
|
|
410
414
|
// GET /me/display-name — rank: any-builder — GET /me/display-name
|
package/docs/api/openapi.json
CHANGED
|
@@ -6267,6 +6267,91 @@
|
|
|
6267
6267
|
]
|
|
6268
6268
|
}
|
|
6269
6269
|
},
|
|
6270
|
+
"/me/cross-project": {
|
|
6271
|
+
"get": {
|
|
6272
|
+
"operationId": "get_me_cross_project",
|
|
6273
|
+
"tags": [
|
|
6274
|
+
"me"
|
|
6275
|
+
],
|
|
6276
|
+
"summary": "GET /me/cross-project",
|
|
6277
|
+
"description": "the caller's OWN cross-project record + hide flag (own data).\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
6278
|
+
"x-rank": "any-builder",
|
|
6279
|
+
"x-source": "modules/platform-identity/routes/profile.js",
|
|
6280
|
+
"responses": {
|
|
6281
|
+
"200": {
|
|
6282
|
+
"description": "Success.",
|
|
6283
|
+
"content": {
|
|
6284
|
+
"application/json": {
|
|
6285
|
+
"schema": {
|
|
6286
|
+
"$ref": "#/components/schemas/GetMeCrossProjectResponse"
|
|
6287
|
+
}
|
|
6288
|
+
}
|
|
6289
|
+
}
|
|
6290
|
+
},
|
|
6291
|
+
"400": {
|
|
6292
|
+
"$ref": "#/components/responses/BadRequest"
|
|
6293
|
+
},
|
|
6294
|
+
"401": {
|
|
6295
|
+
"$ref": "#/components/responses/Unauthorized"
|
|
6296
|
+
},
|
|
6297
|
+
"403": {
|
|
6298
|
+
"$ref": "#/components/responses/Forbidden"
|
|
6299
|
+
}
|
|
6300
|
+
},
|
|
6301
|
+
"security": [
|
|
6302
|
+
{
|
|
6303
|
+
"builderSession": []
|
|
6304
|
+
}
|
|
6305
|
+
]
|
|
6306
|
+
},
|
|
6307
|
+
"patch": {
|
|
6308
|
+
"operationId": "patch_me_cross_project",
|
|
6309
|
+
"tags": [
|
|
6310
|
+
"me"
|
|
6311
|
+
],
|
|
6312
|
+
"summary": "PATCH /me/cross-project",
|
|
6313
|
+
"description": "flip the caller's OWN hide flag (C2's self-serve opt-out; own data, keyed on the authenticated github_id).\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
6314
|
+
"x-rank": "any-builder",
|
|
6315
|
+
"x-source": "modules/platform-identity/routes/profile.js",
|
|
6316
|
+
"requestBody": {
|
|
6317
|
+
"required": true,
|
|
6318
|
+
"content": {
|
|
6319
|
+
"application/json": {
|
|
6320
|
+
"schema": {
|
|
6321
|
+
"$ref": "#/components/schemas/PatchMeCrossProjectRequest"
|
|
6322
|
+
}
|
|
6323
|
+
}
|
|
6324
|
+
},
|
|
6325
|
+
"x-validated": true
|
|
6326
|
+
},
|
|
6327
|
+
"responses": {
|
|
6328
|
+
"200": {
|
|
6329
|
+
"description": "Success.",
|
|
6330
|
+
"content": {
|
|
6331
|
+
"application/json": {
|
|
6332
|
+
"schema": {
|
|
6333
|
+
"$ref": "#/components/schemas/PatchMeCrossProjectResponse"
|
|
6334
|
+
}
|
|
6335
|
+
}
|
|
6336
|
+
}
|
|
6337
|
+
},
|
|
6338
|
+
"400": {
|
|
6339
|
+
"$ref": "#/components/responses/ValidationFailed"
|
|
6340
|
+
},
|
|
6341
|
+
"401": {
|
|
6342
|
+
"$ref": "#/components/responses/Unauthorized"
|
|
6343
|
+
},
|
|
6344
|
+
"403": {
|
|
6345
|
+
"$ref": "#/components/responses/Forbidden"
|
|
6346
|
+
}
|
|
6347
|
+
},
|
|
6348
|
+
"security": [
|
|
6349
|
+
{
|
|
6350
|
+
"builderSession": []
|
|
6351
|
+
}
|
|
6352
|
+
]
|
|
6353
|
+
}
|
|
6354
|
+
},
|
|
6270
6355
|
"/me/disciplines": {
|
|
6271
6356
|
"patch": {
|
|
6272
6357
|
"operationId": "patch_me_disciplines",
|
|
@@ -12850,6 +12935,21 @@
|
|
|
12850
12935
|
"unlocked"
|
|
12851
12936
|
]
|
|
12852
12937
|
},
|
|
12938
|
+
"GetMeCrossProjectResponse": {
|
|
12939
|
+
"type": "object",
|
|
12940
|
+
"properties": {
|
|
12941
|
+
"hide_stats": {
|
|
12942
|
+
"type": "boolean"
|
|
12943
|
+
},
|
|
12944
|
+
"profile_state": {},
|
|
12945
|
+
"cross_project": {}
|
|
12946
|
+
},
|
|
12947
|
+
"required": [
|
|
12948
|
+
"hide_stats",
|
|
12949
|
+
"profile_state",
|
|
12950
|
+
"cross_project"
|
|
12951
|
+
]
|
|
12952
|
+
},
|
|
12853
12953
|
"GetMeDisplayNameResponse": {
|
|
12854
12954
|
"type": "object",
|
|
12855
12955
|
"properties": {
|
|
@@ -13808,6 +13908,29 @@
|
|
|
13808
13908
|
"idea"
|
|
13809
13909
|
]
|
|
13810
13910
|
},
|
|
13911
|
+
"PatchMeCrossProjectRequest": {
|
|
13912
|
+
"type": "object",
|
|
13913
|
+
"properties": {
|
|
13914
|
+
"hide_stats": {
|
|
13915
|
+
"type": "boolean"
|
|
13916
|
+
}
|
|
13917
|
+
},
|
|
13918
|
+
"required": [
|
|
13919
|
+
"hide_stats"
|
|
13920
|
+
],
|
|
13921
|
+
"additionalProperties": false
|
|
13922
|
+
},
|
|
13923
|
+
"PatchMeCrossProjectResponse": {
|
|
13924
|
+
"type": "object",
|
|
13925
|
+
"properties": {
|
|
13926
|
+
"hide_stats": {
|
|
13927
|
+
"type": "boolean"
|
|
13928
|
+
}
|
|
13929
|
+
},
|
|
13930
|
+
"required": [
|
|
13931
|
+
"hide_stats"
|
|
13932
|
+
]
|
|
13933
|
+
},
|
|
13811
13934
|
"PatchMeDisciplinesRequest": {
|
|
13812
13935
|
"type": "object",
|
|
13813
13936
|
"properties": {
|
|
@@ -17099,9 +17222,9 @@
|
|
|
17099
17222
|
"description": "A required dependency/feature is not configured or is temporarily down."
|
|
17100
17223
|
}
|
|
17101
17224
|
},
|
|
17102
|
-
"x-endpoint-count":
|
|
17103
|
-
"x-schema-count":
|
|
17225
|
+
"x-endpoint-count": 273,
|
|
17226
|
+
"x-schema-count": 306,
|
|
17104
17227
|
"x-undocumented-bodies": 8,
|
|
17105
|
-
"x-response-schemas":
|
|
17228
|
+
"x-response-schemas": 207,
|
|
17106
17229
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
17107
17230
|
}
|
package/docs/api-reference.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Bongos API reference
|
|
4
4
|
|
|
5
|
-
> **Generated from the live route files** — the route file is authoritative.
|
|
5
|
+
> **Generated from the live route files** — the route file is authoritative. 273 endpoints across 45 route files.
|
|
6
6
|
> Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
|
|
7
7
|
|
|
8
8
|
Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
|
|
@@ -282,13 +282,15 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
282
282
|
| POST | `/api/bongos/llm-cache/lookup` | `metic+archon` | `cache_key`, `tokens_saved_est` | POST /llm-cache/lookup — hit/miss + atomic hit_count bump. |
|
|
283
283
|
| POST | `/api/bongos/llm-cache/store` | `metic+archon` | `spec`, `result_stdout`, `result_cost_usd` | POST /llm-cache/store — upsert a freshly-produced result. |
|
|
284
284
|
|
|
285
|
-
## `me` (
|
|
285
|
+
## `me` (23)
|
|
286
286
|
|
|
287
287
|
| Method | Path | Rank | Body | Description |
|
|
288
288
|
|---|---|---|---|---|
|
|
289
289
|
| GET | `/api/bongos/me` | `any-builder` | — | PMS-V2 task 188: response now also carries `unlocked` (server-joined achievement metadata) + `locked_with_progress` (server-computed huma… |
|
|
290
290
|
| GET | `/api/bongos/me/achievements` | `any-builder` | — | The builder's unlocked achievements (joined to achievement metadata). |
|
|
291
291
|
| POST | `/api/bongos/me/anthropic-link` | `any-builder` | `anthropic_email`, `anthropic_user_id` | V3.R10 (task 235), criterion C3 — link the authed builder's Anthropic account. |
|
|
292
|
+
| GET | `/api/bongos/me/cross-project` | `any-builder` | — | the caller's OWN cross-project record + hide flag (own data). |
|
|
293
|
+
| PATCH | `/api/bongos/me/cross-project` | `any-builder` | `hide_stats` | flip the caller's OWN hide flag (C2's self-serve opt-out; own data, keyed on the authenticated github_id). |
|
|
292
294
|
| PATCH | `/api/bongos/me/disciplines` | `any-builder` | `preferred_disciplines` | Self-service edit of the authenticated builder's preferred disciplines (migrations 027 + 086 / V3.R80). |
|
|
293
295
|
| GET | `/api/bongos/me/display-name` | `any-builder` | — | Per-builder DISPLAY NAME (task 1669). |
|
|
294
296
|
| PATCH | `/api/bongos/me/display-name` | `any-builder` | _undocumented_ | PATCH the display name. |
|
|
@@ -408,6 +408,10 @@ function createClient(opts = {}) {
|
|
|
408
408
|
getMeAchievements: (args) => request("GET", "/me/achievements", { hasBody: false }, args),
|
|
409
409
|
// POST /me/anthropic-link — rank: any-builder — POST /me/anthropic-link
|
|
410
410
|
postMeAnthropicLink: (args) => request("POST", "/me/anthropic-link", { hasBody: true }, args),
|
|
411
|
+
// GET /me/cross-project — rank: any-builder — GET /me/cross-project
|
|
412
|
+
getMeCrossProject: (args) => request("GET", "/me/cross-project", { hasBody: false }, args),
|
|
413
|
+
// PATCH /me/cross-project — rank: any-builder — PATCH /me/cross-project
|
|
414
|
+
patchMeCrossProject: (args) => request("PATCH", "/me/cross-project", { hasBody: true }, args),
|
|
411
415
|
// PATCH /me/disciplines — rank: any-builder — PATCH /me/disciplines
|
|
412
416
|
patchMeDisciplines: (args) => request("PATCH", "/me/disciplines", { hasBody: true }, args),
|
|
413
417
|
// GET /me/display-name — rank: any-builder — GET /me/display-name
|
|
@@ -40,6 +40,14 @@
|
|
|
40
40
|
</div>
|
|
41
41
|
</section>
|
|
42
42
|
|
|
43
|
+
<!-- Cross-project record — hub-only (ADR 0171). profile.js reveals this
|
|
44
|
+
only when the profile response carries a cross_project key; on a
|
|
45
|
+
vanilla instance the key is absent and the section never renders. -->
|
|
46
|
+
<section class="scroll" id="xp-scroll" aria-labelledby="xp-h" hidden>
|
|
47
|
+
<h2 id="xp-h" class="scroll__h">Cross-project record</h2>
|
|
48
|
+
<div id="xp-body"></div>
|
|
49
|
+
</section>
|
|
50
|
+
|
|
43
51
|
<!-- Achievements — the builder's earned shelf. -->
|
|
44
52
|
<section class="scroll scroll--bare" id="ach-scroll" aria-labelledby="ach-h">
|
|
45
53
|
<h2 id="ach-h" class="scroll__h">Achievements</h2>
|
|
@@ -66,6 +74,10 @@
|
|
|
66
74
|
</tbody>
|
|
67
75
|
</table>
|
|
68
76
|
</section>
|
|
77
|
+
|
|
78
|
+
<!-- Reserved slots — connections, guilds, and actions (profile-page
|
|
79
|
+
spec sections 6–8) mount here in later phases; keeping the order
|
|
80
|
+
fixed now means the page is not re-laid-out twice (Decision 5). -->
|
|
69
81
|
</main>
|
|
70
82
|
|
|
71
83
|
<footer class="app-foot" id="app-foot"></footer>
|
|
@@ -83,6 +95,6 @@
|
|
|
83
95
|
<!-- shared frontend toolkit (window.OTB) — loaded before profile.js, which pulls from it. -->
|
|
84
96
|
<script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
|
|
85
97
|
<script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
|
|
86
|
-
<script src="/builders/profile.js?v=2026-08-
|
|
98
|
+
<script src="/builders/profile.js?v=2026-08-16-r04"></script>
|
|
87
99
|
</body>
|
|
88
100
|
</html>
|
|
@@ -160,6 +160,82 @@
|
|
|
160
160
|
}).join('');
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// ---- render: cross-project record (hub-only, ADR 0171 / task 1002280) ----
|
|
164
|
+
//
|
|
165
|
+
// The section reveals only when the response carries a cross_project KEY —
|
|
166
|
+
// present iff the hub port resolved; on a vanilla instance the key is absent
|
|
167
|
+
// and the section stays hidden (degrade, don't 500). A null value means "no
|
|
168
|
+
// public cross-project record" — hidden, provisional, or none, deliberately
|
|
169
|
+
// indistinguishable — so all three render the same private line and NOTHING
|
|
170
|
+
// derived from the rollup (no partial numbers; a count plus a hidden total is
|
|
171
|
+
// a leak by subtraction). The response is viewer-independent (ADR 0171 D4):
|
|
172
|
+
// self is detected client-side by comparing the /me id, and the self "toggle"
|
|
173
|
+
// is a link to the real control in Settings (R03). No caching assumptions —
|
|
174
|
+
// hub profile responses are no-store (ADR 0171 D5).
|
|
175
|
+
|
|
176
|
+
function xpProjectRow(p) {
|
|
177
|
+
const name = escapeHtml(p.name || 'Project');
|
|
178
|
+
// origin is third-party-reported; link only a well-formed https URL.
|
|
179
|
+
const origin = typeof p.origin === 'string' && /^https:\/\//.test(p.origin) ? p.origin : null;
|
|
180
|
+
const nameCell = origin ? `<a href="${escapeHtml(origin)}" rel="noopener">${name}</a>` : name;
|
|
181
|
+
// rank_held is THAT project's standing as it reported it — plain text, never
|
|
182
|
+
// the local rank badge or the instance's own rank labels (spec Decision 1).
|
|
183
|
+
const held = typeof p.rank_held === 'string' && p.rank_held
|
|
184
|
+
? escapeHtml(p.rank_held.charAt(0).toUpperCase() + p.rank_held.slice(1))
|
|
185
|
+
: '—';
|
|
186
|
+
return `<tr><td>${nameCell}</td><td>${held}</td></tr>`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderCrossProject(data, selfId) {
|
|
190
|
+
const section = $('#xp-scroll');
|
|
191
|
+
const body = $('#xp-body');
|
|
192
|
+
if (!section || !body) return;
|
|
193
|
+
if (!data || !('cross_project' in data)) {
|
|
194
|
+
section.hidden = true;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// ids may serialize as strings (pg bigint) — compare canonicalized.
|
|
198
|
+
const isSelf = selfId != null && data.builder && String(data.builder.id) === String(selfId);
|
|
199
|
+
const manage = isSelf
|
|
200
|
+
? `<p class="scroll__lede">This is you — <a href="/settings#cross-project">manage its visibility in Settings → Cross-project record</a>.</p>`
|
|
201
|
+
: '';
|
|
202
|
+
const cp = data.cross_project;
|
|
203
|
+
if (!cp) {
|
|
204
|
+
body.innerHTML = `<p class="scroll__lede">This builder keeps their track record private.</p>${manage}`;
|
|
205
|
+
section.hidden = false;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const t = cp.totals || {};
|
|
209
|
+
const tiles =
|
|
210
|
+
`<div class="stat-row">` +
|
|
211
|
+
tile('Projects', fmtNum(t.projects)) +
|
|
212
|
+
tile('Works shipped', fmtNum(t.works_shipped)) +
|
|
213
|
+
tile('Credits', fmtNum(t.credits)) +
|
|
214
|
+
tile('Karma', fmtNum(t.karma)) +
|
|
215
|
+
`</div>`;
|
|
216
|
+
const discs = Array.isArray(cp.disciplines) ? cp.disciplines.filter((d) => typeof d === 'string') : [];
|
|
217
|
+
const metaBits = [];
|
|
218
|
+
if (discs.length) metaBits.push(`Disciplines: ${discs.join(', ')}`);
|
|
219
|
+
if (cp.last_active) metaBits.push(`Last active ${fmtDate(cp.last_active)}`);
|
|
220
|
+
const meta = metaBits.length ? `<p class="scroll__lede">${escapeHtml(metaBits.join(' · '))}</p>` : '';
|
|
221
|
+
const named = Array.isArray(cp.projects) ? cp.projects : [];
|
|
222
|
+
const moreCount = Number(cp.more_projects) || 0;
|
|
223
|
+
const projectsTable = named.length
|
|
224
|
+
? `<table class="ledger">
|
|
225
|
+
<thead><tr><th>Project</th><th title="That project's own standing, as reported by that project.">Standing there</th></tr></thead>
|
|
226
|
+
<tbody>${named.map(xpProjectRow).join('')}</tbody>
|
|
227
|
+
</table>`
|
|
228
|
+
: '';
|
|
229
|
+
const more = moreCount > 0
|
|
230
|
+
? `<p class="scroll__lede">+ ${fmtNum(moreCount)} more project${moreCount === 1 ? '' : 's'} not publicly listed.</p>`
|
|
231
|
+
: '';
|
|
232
|
+
const none = !named.length && !moreCount
|
|
233
|
+
? `<p class="scroll__lede">No cross-project activity reported yet.</p>`
|
|
234
|
+
: '';
|
|
235
|
+
body.innerHTML = tiles + meta + projectsTable + more + none + manage;
|
|
236
|
+
section.hidden = false;
|
|
237
|
+
}
|
|
238
|
+
|
|
163
239
|
// ---- states -------------------------------------------------------------
|
|
164
240
|
|
|
165
241
|
function showMessage(text) {
|
|
@@ -167,13 +243,14 @@
|
|
|
167
243
|
if (sub) sub.textContent = text;
|
|
168
244
|
}
|
|
169
245
|
|
|
170
|
-
function renderProfile(data) {
|
|
246
|
+
function renderProfile(data, selfId) {
|
|
171
247
|
const builder = data.builder || {};
|
|
172
248
|
const name = builder.display_name || builder.github_login || 'Builder';
|
|
173
249
|
const shipped = Number(data.shipped_count) || 0;
|
|
174
250
|
showMessage(`${name} — ${rankLabel(normalizeRank(builder.rank))} · ${fmtNum(shipped)} work${shipped === 1 ? '' : 's'} shipped`);
|
|
175
251
|
renderHead(builder);
|
|
176
252
|
renderNumbers(data.shipped_count, data.achievements);
|
|
253
|
+
renderCrossProject(data, selfId);
|
|
177
254
|
renderAchievements(data.achievements);
|
|
178
255
|
renderWorks(data.recent_ships);
|
|
179
256
|
}
|
|
@@ -197,23 +274,39 @@
|
|
|
197
274
|
const ach = $('#ach-grid'); if (ach) ach.innerHTML = '';
|
|
198
275
|
const works = $('#works-table tbody'); if (works) works.innerHTML = '';
|
|
199
276
|
const worksLede = $('#works-lede'); if (worksLede) worksLede.hidden = true;
|
|
277
|
+
const xp = $('#xp-scroll'); if (xp) xp.hidden = true;
|
|
200
278
|
}
|
|
201
279
|
|
|
202
|
-
async function fetchProfile(id) {
|
|
280
|
+
async function fetchProfile(id, selfId) {
|
|
203
281
|
const res = await api.request('GET', `${API}/builders/${encodeURIComponent(id)}/profile`);
|
|
204
282
|
if (res.status === 404) {
|
|
205
283
|
showMessage('No builder found with that id.');
|
|
206
284
|
return;
|
|
207
285
|
}
|
|
208
286
|
if (!res.ok) throw new Error(`profile → ${res.status}`);
|
|
209
|
-
renderProfile(res.data || {});
|
|
287
|
+
renderProfile(res.data || {}, selfId);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// The viewer's own builder id, or null when signed out / unresolvable. Used
|
|
291
|
+
// ONLY for client-side self-detection (ADR 0171 D4 — the profile response is
|
|
292
|
+
// viewer-independent); a failure here just means no self link renders.
|
|
293
|
+
async function resolveSelfId() {
|
|
294
|
+
try {
|
|
295
|
+
const me = await api.request('GET', `${API}/me`);
|
|
296
|
+
if (!me.ok) return null;
|
|
297
|
+
const builder = (me.data && me.data.builder) ? me.data.builder : me.data;
|
|
298
|
+
return (builder && builder.id) || null;
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.error('[profile] /me self-detect failed', err);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
210
303
|
}
|
|
211
304
|
|
|
212
305
|
async function load() {
|
|
213
306
|
const idParam = new URLSearchParams(location.search).get('id');
|
|
214
307
|
try {
|
|
215
308
|
if (idParam) {
|
|
216
|
-
await fetchProfile(idParam);
|
|
309
|
+
await fetchProfile(idParam, await resolveSelfId());
|
|
217
310
|
return;
|
|
218
311
|
}
|
|
219
312
|
// No id — soft-resolve the viewer's OWN id from /me (no hard redirect; a
|
|
@@ -230,7 +323,7 @@
|
|
|
230
323
|
renderEmpty();
|
|
231
324
|
return;
|
|
232
325
|
}
|
|
233
|
-
await fetchProfile(selfId);
|
|
326
|
+
await fetchProfile(selfId, selfId);
|
|
234
327
|
} catch (err) {
|
|
235
328
|
console.error('[profile] failed to load', err);
|
|
236
329
|
showMessage('Could not load this profile. Return to the hall and try again.');
|
|
@@ -79,6 +79,20 @@
|
|
|
79
79
|
<p class="section-note">Saves when you press Save; shows everywhere at once.</p>
|
|
80
80
|
</section>
|
|
81
81
|
|
|
82
|
+
<!-- Cross-project record (platform-identity module, ADR 0171 / task 1002279).
|
|
83
|
+
Revealed only when the module's own endpoint answers; hidden on a
|
|
84
|
+
vanilla instance. P04 later moves this into the Profile rail group. -->
|
|
85
|
+
<section class="scroll" id="cross-project-scroll" aria-labelledby="cross-project-h" data-group="account" hidden>
|
|
86
|
+
<h2 id="cross-project-h" class="scroll__h">Cross-project record</h2>
|
|
87
|
+
<p class="scroll__lede">
|
|
88
|
+
Your track record across every project you build on, shown on your
|
|
89
|
+
public profile. Hiding it takes effect immediately everywhere —
|
|
90
|
+
your profile, the People directory, and recruiting views.
|
|
91
|
+
</p>
|
|
92
|
+
<div id="cross-project-rows"></div>
|
|
93
|
+
<p class="settings-status" id="cross-project-status" role="status" aria-live="polite"></p>
|
|
94
|
+
</section>
|
|
95
|
+
|
|
82
96
|
<!-- CRAFT — discipline preferences (GET /me, PATCH /me/disciplines). -->
|
|
83
97
|
<section class="scroll" id="craft-scroll" aria-labelledby="craft-h" data-group="account" hidden>
|
|
84
98
|
<h2 id="craft-h" class="scroll__h">Preferred disciplines</h2>
|
|
@@ -550,6 +550,86 @@
|
|
|
550
550
|
}
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
+
// ---- section: CROSS-PROJECT RECORD (hide flag — ADR 0171 D4, task 1002279)
|
|
554
|
+
//
|
|
555
|
+
// GET/PATCH /api/bongos/me/cross-project (platform-identity module — hub only).
|
|
556
|
+
// One on/off toggle: "show my cross-project record on my public profile".
|
|
557
|
+
// Enforcement is server-side in the module's read functions, so flipping it
|
|
558
|
+
// takes effect immediately on the profile, /people, and /scouting. The section
|
|
559
|
+
// reveals ONLY when the module's endpoint answers (404 on a vanilla instance →
|
|
560
|
+
// stays hidden, no error state). P04 later re-homes this in the Profile rail
|
|
561
|
+
// group; storage is account-global on the hub (not behavior_prefs).
|
|
562
|
+
function renderCrossProject(data) {
|
|
563
|
+
const wrap = $('#cross-project-rows');
|
|
564
|
+
if (!wrap) return;
|
|
565
|
+
const hidden = !!(data && data.hide_stats);
|
|
566
|
+
const on = !hidden; // the toggle reads as "shown"
|
|
567
|
+
const cp = data && data.cross_project;
|
|
568
|
+
const totals = cp && cp.totals ? cp.totals : null;
|
|
569
|
+
const totalsLine = totals
|
|
570
|
+
? `Your record: ${totals.projects} project${totals.projects === 1 ? '' : 's'} · ` +
|
|
571
|
+
`${totals.credits} credits · ${totals.works_shipped} works shipped · ${totals.karma} karma.`
|
|
572
|
+
: 'No cross-project activity reported yet.';
|
|
573
|
+
const consentNote = data && data.profile_state !== 'active'
|
|
574
|
+
? ' Your profile publishes after your next sign-in at the hub.'
|
|
575
|
+
: '';
|
|
576
|
+
wrap.innerHTML =
|
|
577
|
+
'<div class="sound-row" data-cross-project="hide">' +
|
|
578
|
+
'<div class="sound-row__text">' +
|
|
579
|
+
'<div class="sound-row__label">Show my cross-project record</div>' +
|
|
580
|
+
`<div class="sound-row__desc">${escapeHtml(totalsLine + consentNote)} You always see your own numbers here.</div>` +
|
|
581
|
+
'</div>' +
|
|
582
|
+
`<button type="button" class="sound-toggle" data-on="${on ? '1' : '0'}" aria-pressed="${on ? 'true' : 'false'}">${on ? 'Shown' : 'Hidden'}</button>` +
|
|
583
|
+
'</div>';
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// PATCH the hide flag. Body: { hide_stats: <bool> } (routes/profile.js).
|
|
587
|
+
async function saveCrossProject(hide, btn) {
|
|
588
|
+
const status = $('#cross-project-status');
|
|
589
|
+
if (status) status.textContent = 'Saving…';
|
|
590
|
+
if (btn) btn.disabled = true;
|
|
591
|
+
try {
|
|
592
|
+
const res = await api.request('PATCH', `${API}/me/cross-project`, { body: { hide_stats: hide } });
|
|
593
|
+
if (res.status === 401) { toAuth(); return; }
|
|
594
|
+
if (!res.ok) throw new Error(`cross-project PATCH → ${res.status}`);
|
|
595
|
+
await loadCrossProject(); // re-render from the server's truth (flag + record)
|
|
596
|
+
const msg = hide
|
|
597
|
+
? 'Cross-project record hidden — removed from your profile and directories immediately.'
|
|
598
|
+
: 'Cross-project record shown on your public profile.';
|
|
599
|
+
if (status) status.textContent = msg;
|
|
600
|
+
toast(msg);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
console.error('[settings] save cross-project failed', err);
|
|
603
|
+
if (status) status.textContent = 'Could not save. Try again.';
|
|
604
|
+
toast('Could not save your cross-project preference. Try again.');
|
|
605
|
+
if (btn) btn.disabled = false;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
let crossProjectWired = false;
|
|
610
|
+
async function loadCrossProject() {
|
|
611
|
+
const section = $('#cross-project-scroll');
|
|
612
|
+
if (!section) return;
|
|
613
|
+
try {
|
|
614
|
+
const res = await api.request('GET', `${API}/me/cross-project`);
|
|
615
|
+
if (res.status === 401) { toAuth(); return; }
|
|
616
|
+
if (!res.ok) return; // module off / route absent → the section stays hidden
|
|
617
|
+
renderCrossProject(res.data);
|
|
618
|
+
section.hidden = false;
|
|
619
|
+
if (!crossProjectWired) {
|
|
620
|
+
crossProjectWired = true;
|
|
621
|
+
$('#cross-project-rows').addEventListener('click', (e) => {
|
|
622
|
+
const b = e.target.closest('.sound-toggle');
|
|
623
|
+
if (!b || b.disabled) return;
|
|
624
|
+
saveCrossProject(b.dataset.on === '1', b); // shown → hide; hidden → show
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
} catch (err) {
|
|
628
|
+
// Network/parse failure on an optional module section — stay hidden, log only.
|
|
629
|
+
console.error('[settings] failed to load cross-project', err);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
553
633
|
// ---- section 2: CRAFT (discipline preferences) ---------------------------
|
|
554
634
|
|
|
555
635
|
// Valid disciplines are limited (see me.js + db.updateBuilderDisciplines):
|
|
@@ -1098,6 +1178,7 @@
|
|
|
1098
1178
|
const PANEL_ANCHORS = {
|
|
1099
1179
|
'connections': 'connections-scroll',
|
|
1100
1180
|
'display-name': 'display-name-scroll',
|
|
1181
|
+
'cross-project': 'cross-project-scroll',
|
|
1101
1182
|
'disciplines': 'craft-scroll',
|
|
1102
1183
|
'cli': 'cli-scroll',
|
|
1103
1184
|
'art-key': 'art-key',
|
|
@@ -1250,6 +1331,9 @@
|
|
|
1250
1331
|
await loadWandering();
|
|
1251
1332
|
// Rendering loads independently too (rich in-feed cards on/off, task 1279).
|
|
1252
1333
|
await loadRender();
|
|
1334
|
+
// Cross-project record (hide flag) — reveals only where the platform-identity
|
|
1335
|
+
// module answers (the hub); silent no-op elsewhere (task 1002279).
|
|
1336
|
+
await loadCrossProject();
|
|
1253
1337
|
// Image key (per-builder Gemini key, task 1013) — own try/catch; `me` carries
|
|
1254
1338
|
// the `needs` art_key item that drives the covered/action-needed banner.
|
|
1255
1339
|
await loadArtKey(me);
|
|
@@ -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
|
},
|
|
@@ -601,6 +601,26 @@ async function getOwnProfileExtras(githubId, { pool = defaultPool } = {}) {
|
|
|
601
601
|
return getRollupForBuilder(githubId, { pool });
|
|
602
602
|
}
|
|
603
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
|
+
|
|
604
624
|
// -------------------------------------------------------------------------
|
|
605
625
|
// Authorization codes (single-use, short-lived, bound)
|
|
606
626
|
// -------------------------------------------------------------------------
|
|
@@ -700,6 +720,7 @@ module.exports = {
|
|
|
700
720
|
getRollupForBuilder,
|
|
701
721
|
getPublicProfileExtras,
|
|
702
722
|
getOwnProfileExtras,
|
|
723
|
+
setAccountHideStats,
|
|
703
724
|
// codes
|
|
704
725
|
mintAuthCode,
|
|
705
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
|
+
};
|
|
@@ -486,6 +486,28 @@ await ta('getPublicProfileExtras returns the rollup for an active, visible accou
|
|
|
486
486
|
assert.equal(out.more_projects, 0);
|
|
487
487
|
});
|
|
488
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
|
+
|
|
489
511
|
await ta('getOwnProfileExtras serves the self view regardless of flags (no flags lookup)', async () => {
|
|
490
512
|
const pool = mockPool([
|
|
491
513
|
{ rows: [{ projects: 1, credits: 10, works_shipped: 1, karma: 0, last_active: 'T', disciplines: null }] },
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.171",
|
|
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.171",
|
|
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.171",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -81,6 +81,7 @@ const FIXTURE_FALLBACKS = [
|
|
|
81
81
|
[/^blockers\/\d+$/, 'blockers__detail'],
|
|
82
82
|
[/^analytics\/builder\/\d+$/, 'analytics__builder'],
|
|
83
83
|
[/^builders\/\d+\/active-claims$/, 'builders__active-claims'],
|
|
84
|
+
[/^builders\/\d+\/profile$/, 'builders__profile'],
|
|
84
85
|
];
|
|
85
86
|
|
|
86
87
|
function loadFixture(name) {
|
package/src/module-api.js
CHANGED
|
@@ -765,8 +765,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
765
765
|
// landed since 1.19.167 with no explicit bump. run 31962256201. (task 1002620)
|
|
766
766
|
// 1.19.169 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
767
767
|
// landed since 1.19.168 with no explicit bump. run 31964702865. (task 1002620)
|
|
768
|
+
// 1.19.170 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
769
|
+
// landed since 1.19.169 with no explicit bump. run 31965483417. (task 1002620)
|
|
770
|
+
// 1.19.171 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
771
|
+
// landed since 1.19.170 with no explicit bump. run 31967255167. (task 1002620)
|
|
768
772
|
// ---------------------------------------------------------------------------
|
|
769
|
-
const CORE_VERSION = '1.19.
|
|
773
|
+
const CORE_VERSION = '1.19.171'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
770
774
|
|
|
771
775
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
772
776
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -142,6 +142,31 @@ try {
|
|
|
142
142
|
acct = await pi.getAccount(LAZY, { pool });
|
|
143
143
|
ok(acct.profile_state === 'active', 'a later plain upsert never demotes an active account');
|
|
144
144
|
|
|
145
|
+
// 4b — the live hide toggle (R03, task 1002279): flipping hide_stats takes
|
|
146
|
+
// effect IMMEDIATELY on every public read — the profile port and the
|
|
147
|
+
// /people + /scouting rollup — while the self view keeps serving.
|
|
148
|
+
await pi.setAccountHideStats({ githubId: ACTIVE, githubLogin: 'active-b', hide: true }, { pool });
|
|
149
|
+
ok(await pi.getPublicProfileExtras(ACTIVE, { pool }) === null, 'hide ON → public read drops to null immediately');
|
|
150
|
+
const hiddenListed = (await pi.listScoutingRollup({ limit: 500, pool })).map((r) => Number(r.github_id));
|
|
151
|
+
ok(!hiddenListed.includes(ACTIVE), 'hide ON → the builder drops out of /people + /scouting immediately');
|
|
152
|
+
const stillOwn = await pi.getOwnProfileExtras(ACTIVE, { pool });
|
|
153
|
+
ok(stillOwn && stillOwn.totals.credits === 175, 'hide ON → the self view still serves their own numbers');
|
|
154
|
+
let hiddenAcct = await pi.getAccount(ACTIVE, { pool });
|
|
155
|
+
ok(hiddenAcct.profile_state === 'active', 'the toggle never touches profile_state');
|
|
156
|
+
ok(hiddenAcct.github_login === 'active-b', 'the toggle never clobbers identity fields');
|
|
157
|
+
await pi.setAccountHideStats({ githubId: ACTIVE, githubLogin: 'active-b', hide: false }, { pool });
|
|
158
|
+
const backPub = await pi.getPublicProfileExtras(ACTIVE, { pool });
|
|
159
|
+
ok(backPub && backPub.totals.credits === 175, 'hide OFF → the public read returns immediately');
|
|
160
|
+
|
|
161
|
+
// 4c — hide-before-federating: a builder with NO account row can still hide;
|
|
162
|
+
// the insert creates a fail-closed provisional account carrying the flag.
|
|
163
|
+
const EARLY = 90002282;
|
|
164
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = $1`, [EARLY]).catch(() => {});
|
|
165
|
+
const early = await pi.setAccountHideStats({ githubId: EARLY, githubLogin: 'early-b', hide: true }, { pool });
|
|
166
|
+
ok(early.hide_stats === true && early.profile_state === 'provisional',
|
|
167
|
+
'hide with no account row creates a provisional, hidden account');
|
|
168
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = $1`, [EARLY]).catch(() => {});
|
|
169
|
+
|
|
145
170
|
// 5 — rollup payload sanitization against the real columns (D7).
|
|
146
171
|
await pi.upsertActivity({
|
|
147
172
|
clientId: PUB_CLIENT, githubId: LAZY, credits: 1,
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// tests/profile_ui_cross_project.mjs — the profile page's cross-project section
|
|
2
|
+
// (task 1002280, R04 · goal 1000045, ADR 0171).
|
|
3
|
+
//
|
|
4
|
+
// EXECUTES the real modules/hall-ui/public/profile.js in a minimal DOM stub
|
|
5
|
+
// (the hall_signout.mjs pattern) against canned API responses, and asserts the
|
|
6
|
+
// consent-sensitive render contract:
|
|
7
|
+
//
|
|
8
|
+
// - no `cross_project` key in the response (vanilla instance) → the section
|
|
9
|
+
// stays hidden entirely;
|
|
10
|
+
// - `cross_project: null` (hidden / provisional / none — indistinguishable by
|
|
11
|
+
// design) → the "keeps their track record private" line renders and NOTHING
|
|
12
|
+
// derived from the rollup does (no tiles, no digits — a count plus a hidden
|
|
13
|
+
// total is a leak by subtraction);
|
|
14
|
+
// - the self hide-toggle is client-side only (ADR 0171 D4): the Settings link
|
|
15
|
+
// renders iff the /me id matches the profile id, on both the ?id= and the
|
|
16
|
+
// own-profile load paths;
|
|
17
|
+
// - the full object renders totals tiles, named project rows (https origins
|
|
18
|
+
// linked, anything else plain text), `rank_held` as plain text — never the
|
|
19
|
+
// local rank-badge component (spec Decision 1) — and the counted-never-named
|
|
20
|
+
// "+N more" line.
|
|
21
|
+
//
|
|
22
|
+
// Pure vm + file-read (no DB/network) — runs in the DB-free unit gate.
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import vm from 'node:vm';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
|
|
32
|
+
const HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.html'), 'utf8');
|
|
33
|
+
|
|
34
|
+
// ---- fixtures -------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const BUILDER = {
|
|
37
|
+
id: 7, github_login: 'pheidias', display_name: 'Pheidias', avatar_url: null,
|
|
38
|
+
rank: 'metic', total_credits: 120, karma: 3, created_at: '2026-01-01T00:00:00.000Z',
|
|
39
|
+
preferred_disciplines: ['engineer'],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const CP_FULL = {
|
|
43
|
+
totals: { projects: 3, credits: 150, works_shipped: 7, karma: 4 },
|
|
44
|
+
disciplines: ['engineer', 'ui'],
|
|
45
|
+
last_active: '2026-08-01T00:00:00.000Z',
|
|
46
|
+
projects: [
|
|
47
|
+
{ name: 'Public Proj', origin: 'https://pub.example', rank_held: 'metic' },
|
|
48
|
+
{ name: 'Odd Origin', origin: 'http://insecure.example', rank_held: null },
|
|
49
|
+
],
|
|
50
|
+
more_projects: 1,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const baseProfile = () => ({ builder: { ...BUILDER }, shipped_count: 1, recent_ships: [], achievements: [] });
|
|
54
|
+
|
|
55
|
+
// ---- harness --------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
// Boot profile.js with canned /me + profile responses; returns the memoised
|
|
58
|
+
// element map after the async load() settles.
|
|
59
|
+
async function boot({ search = '', me, profile } = {}) {
|
|
60
|
+
const els = new Map();
|
|
61
|
+
const makeEl = () => ({
|
|
62
|
+
hidden: false, textContent: '', _html: '',
|
|
63
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
64
|
+
});
|
|
65
|
+
const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
|
|
66
|
+
// Mirror the markup: the section ships with the `hidden` attribute.
|
|
67
|
+
$('#xp-scroll').hidden = true;
|
|
68
|
+
|
|
69
|
+
const escapeHtml = (s) => String(s)
|
|
70
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
71
|
+
const api = {
|
|
72
|
+
async request(_method, url) {
|
|
73
|
+
if (url === '/api/bongos/me') return me ?? { status: 401, ok: false, data: null };
|
|
74
|
+
if (/\/builders\/[^/]+\/profile$/.test(url)) return profile ?? { status: 404, ok: false, data: null };
|
|
75
|
+
return { status: 404, ok: false, data: null };
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const windowObj = {
|
|
79
|
+
__BRANDING__: {},
|
|
80
|
+
BongosClient: { createClient: () => api },
|
|
81
|
+
OTB: {
|
|
82
|
+
$, escapeHtml,
|
|
83
|
+
fmtNum: (n) => String(Number(n) || 0),
|
|
84
|
+
fmtDate: (d) => String(d).slice(0, 10),
|
|
85
|
+
rankLabel: (r) => r,
|
|
86
|
+
toast() {},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const sandbox = {
|
|
90
|
+
window: windowObj, location: { search }, URLSearchParams, console,
|
|
91
|
+
};
|
|
92
|
+
sandbox.globalThis = sandbox;
|
|
93
|
+
vm.createContext(sandbox);
|
|
94
|
+
vm.runInContext(SRC, sandbox, { filename: 'profile.js' });
|
|
95
|
+
// load() is fire-and-forget; let its await chain settle.
|
|
96
|
+
for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r));
|
|
97
|
+
return { els, $ };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const meOk = (id) => ({ status: 200, ok: true, data: { builder: { id } } });
|
|
101
|
+
const profileOk = (data) => ({ status: 200, ok: true, data });
|
|
102
|
+
|
|
103
|
+
// ---- the markup ships the section hidden ----------------------------------
|
|
104
|
+
|
|
105
|
+
test('profile.html carries the hidden cross-project section + reserved slots', () => {
|
|
106
|
+
assert.match(HTML, /<section[^>]*id="xp-scroll"[^>]*hidden>/, 'the section must ship hidden — it reveals only when the response carries a cross_project key');
|
|
107
|
+
assert.match(HTML, /Reserved slots[\s\S]*connections, guilds, and actions/, 'the spec Decision 5 slot reservation must stay in the layout');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---- vanilla instance: key absent → section absent -------------------------
|
|
111
|
+
|
|
112
|
+
test('no cross_project key (vanilla instance) → the section stays hidden', async () => {
|
|
113
|
+
const { $ } = await boot({ search: '?id=7', me: meOk(99), profile: profileOk(baseProfile()) });
|
|
114
|
+
assert.equal($('#xp-scroll').hidden, true);
|
|
115
|
+
assert.equal($('#xp-body').innerHTML, '');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ---- cross_project: null → private line, nothing derived -------------------
|
|
119
|
+
|
|
120
|
+
test('cross_project null, stranger view → private line only, no numbers, no self link', async () => {
|
|
121
|
+
const data = { ...baseProfile(), cross_project: null };
|
|
122
|
+
const { $ } = await boot({ search: '?id=7', me: meOk(99), profile: profileOk(data) });
|
|
123
|
+
const xp = $('#xp-scroll');
|
|
124
|
+
const html = $('#xp-body').innerHTML;
|
|
125
|
+
assert.equal(xp.hidden, false, 'on the hub the section reveals even when the record is private');
|
|
126
|
+
assert.match(html, /keeps their track record private/);
|
|
127
|
+
assert.ok(!html.includes('stat-tile'), 'no tiles in the private state');
|
|
128
|
+
assert.ok(!/\d/.test(html), 'no digits at all — no partial numbers (leak by subtraction)');
|
|
129
|
+
assert.ok(!html.includes('/settings#cross-project'), 'no self link for a stranger');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('cross_project null, self view via ?id= → private line + the Settings link', async () => {
|
|
133
|
+
const data = { ...baseProfile(), cross_project: null };
|
|
134
|
+
// /me id as a STRING (pg bigint serialization) against the numeric profile id
|
|
135
|
+
// — self-detection must be serialization-proof.
|
|
136
|
+
const { $ } = await boot({ search: '?id=7', me: meOk('7'), profile: profileOk(data) });
|
|
137
|
+
const html = $('#xp-body').innerHTML;
|
|
138
|
+
assert.match(html, /keeps their track record private/);
|
|
139
|
+
assert.ok(html.includes('/settings#cross-project'), 'the self hide-toggle links to Settings → Cross-project record');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('self view with no ?id= (own-profile path) also gets the Settings link', async () => {
|
|
143
|
+
const data = { ...baseProfile(), cross_project: null };
|
|
144
|
+
const { $ } = await boot({ search: '', me: meOk(7), profile: profileOk(data) });
|
|
145
|
+
assert.ok($('#xp-body').innerHTML.includes('/settings#cross-project'));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- the full record -------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
test('full cross_project → tiles, named rows, plain-text standing, +N more', async () => {
|
|
151
|
+
const data = { ...baseProfile(), cross_project: CP_FULL };
|
|
152
|
+
const { $ } = await boot({ search: '?id=7', me: meOk(99), profile: profileOk(data) });
|
|
153
|
+
const xp = $('#xp-scroll');
|
|
154
|
+
const html = $('#xp-body').innerHTML;
|
|
155
|
+
assert.equal(xp.hidden, false);
|
|
156
|
+
assert.equal((html.match(/stat-tile__num/g) || []).length, 4, 'projects / works shipped / credits / karma tiles');
|
|
157
|
+
assert.match(html, /Public Proj/);
|
|
158
|
+
assert.ok(html.includes('href="https://pub.example"'), 'a well-formed https origin links out');
|
|
159
|
+
assert.match(html, /Odd Origin/);
|
|
160
|
+
assert.ok(!html.includes('href="http://insecure.example"'), 'a non-https origin renders as plain text');
|
|
161
|
+
assert.match(html, /Metic/, 'rank_held renders capitalized as that project\'s own standing');
|
|
162
|
+
assert.ok(!html.includes('rank-badge'), 'never the local rank-badge component for another project\'s rank (spec Decision 1)');
|
|
163
|
+
assert.match(html, /\+ 1 more project/, 'non-public projects are counted, never named');
|
|
164
|
+
assert.match(html, /Disciplines: engineer, ui/);
|
|
165
|
+
assert.match(html, /Last active 2026-08-01/);
|
|
166
|
+
assert.ok(!html.includes('/settings#cross-project'), 'no self link for a stranger');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('zero-activity record → the empty line, no project table', async () => {
|
|
170
|
+
const cp = { ...CP_FULL, projects: [], more_projects: 0 };
|
|
171
|
+
const { $ } = await boot({ search: '?id=7', me: meOk(99), profile: profileOk({ ...baseProfile(), cross_project: cp }) });
|
|
172
|
+
const html = $('#xp-body').innerHTML;
|
|
173
|
+
assert.match(html, /No cross-project activity reported yet\./);
|
|
174
|
+
assert.ok(!html.includes('<table'), 'no empty ledger');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---- signed-out empty state ------------------------------------------------
|
|
178
|
+
|
|
179
|
+
test('signed-out visitor with no ?id= → empty state keeps the section hidden', async () => {
|
|
180
|
+
const { $ } = await boot({ search: '', me: { status: 401, ok: false, data: null } });
|
|
181
|
+
assert.equal($('#xp-scroll').hidden, true);
|
|
182
|
+
});
|