@bongos/core 1.19.181 → 1.19.182
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 +34 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +81 -4
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_009_profile_fields.sql +38 -0
- package/modules/platform-identity/platform-identity.js +201 -3
- package/modules/platform-identity/routes/profile.js +63 -1
- package/modules/platform-identity/routes/public-profile.js +41 -2
- package/modules/platform-identity/tests/platform-identity.mjs +90 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/hub_profile_leak.mjs +11 -6
- package/tests/profile_fields.mjs +133 -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.182",
|
|
6
|
+
"core_contract": "1.19.182",
|
|
7
|
+
"source_commit": "e5417ea88504c51b0cc9af06d143ec9b3d37c0ed",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-16T21:
|
|
9
|
+
"built_at": "2026-08-16T21:13:59.751Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 308,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1279,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1608,
|
|
20
|
+
"tree_sha256": "101a4745dd8d3b2a32eb0658b962706986782e31b92eb968862d929b7e5e05b7",
|
|
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": "4801db32c6f4ba6220bce8e664be063f8d8483bc22a57df08205b0fc36788b8b"
|
|
351
351
|
},
|
|
352
352
|
{
|
|
353
353
|
"path": "clients/bongos-client/bongos-client.global.js",
|
|
354
354
|
"mode": "0000644",
|
|
355
|
-
"sha256": "
|
|
355
|
+
"sha256": "0d3fc054ed48e1e9398366c75e526e52c3b9ecf96b70888d98607e8aa0e68949"
|
|
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": "53dc2ac9322bdc1827fae124789e0cd8b09751c5359e1ae0266b65951a90126a"
|
|
366
366
|
},
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "a775432136dc7ba0093a989991adf3f639793a287919dccc002eeaf026dacf6b"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
374
374
|
"mode": "0000644",
|
|
375
|
-
"sha256": "
|
|
375
|
+
"sha256": "3588010536522ef306ab7e822bdb68dcc40ef42754d5b298384dac443f9ae0c6"
|
|
376
376
|
},
|
|
377
377
|
{
|
|
378
378
|
"path": "clients/bongos-client/package.json",
|
|
@@ -1362,12 +1362,12 @@
|
|
|
1362
1362
|
{
|
|
1363
1363
|
"path": "docs/api-reference.md",
|
|
1364
1364
|
"mode": "0000644",
|
|
1365
|
-
"sha256": "
|
|
1365
|
+
"sha256": "b311208b348d4e94ca20536ae095098007f855ef1861169a15e534dbb2cf6ab4"
|
|
1366
1366
|
},
|
|
1367
1367
|
{
|
|
1368
1368
|
"path": "docs/api/openapi.json",
|
|
1369
1369
|
"mode": "0000644",
|
|
1370
|
-
"sha256": "
|
|
1370
|
+
"sha256": "c414eadf025db00d4e87b2cadd5a7c6b18a55780ac2af6bfe0cafc9897ae4512"
|
|
1371
1371
|
},
|
|
1372
1372
|
{
|
|
1373
1373
|
"path": "docs/architecture.md",
|
|
@@ -2677,7 +2677,7 @@
|
|
|
2677
2677
|
{
|
|
2678
2678
|
"path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
|
|
2679
2679
|
"mode": "0000644",
|
|
2680
|
-
"sha256": "
|
|
2680
|
+
"sha256": "53dc2ac9322bdc1827fae124789e0cd8b09751c5359e1ae0266b65951a90126a"
|
|
2681
2681
|
},
|
|
2682
2682
|
{
|
|
2683
2683
|
"path": "modules/dev-box/app/test/unit.mjs",
|
|
@@ -3337,7 +3337,7 @@
|
|
|
3337
3337
|
{
|
|
3338
3338
|
"path": "modules/hall-ui/public/profile.js",
|
|
3339
3339
|
"mode": "0000644",
|
|
3340
|
-
"sha256": "
|
|
3340
|
+
"sha256": "ddd95350fa5c3696452996d4c79bb7e9c5d4d2b1034ed31a082dd568195b6543"
|
|
3341
3341
|
},
|
|
3342
3342
|
{
|
|
3343
3343
|
"path": "modules/hall-ui/public/ranks.html",
|
|
@@ -3814,6 +3814,11 @@
|
|
|
3814
3814
|
"mode": "0000644",
|
|
3815
3815
|
"sha256": "f78eb8f7b6ee1ea3ddd789d6019b9715ae193ff0cb7890aeb24d8473033a911a"
|
|
3816
3816
|
},
|
|
3817
|
+
{
|
|
3818
|
+
"path": "modules/platform-identity/migrations/platform_identity_009_profile_fields.sql",
|
|
3819
|
+
"mode": "0000644",
|
|
3820
|
+
"sha256": "00e2e9feccc56bdb30a5e4f0c6abbe610382fe425222419963a04ae022d3c58a"
|
|
3821
|
+
},
|
|
3817
3822
|
{
|
|
3818
3823
|
"path": "modules/platform-identity/module.json",
|
|
3819
3824
|
"mode": "0000644",
|
|
@@ -3822,7 +3827,7 @@
|
|
|
3822
3827
|
{
|
|
3823
3828
|
"path": "modules/platform-identity/platform-identity.js",
|
|
3824
3829
|
"mode": "0000644",
|
|
3825
|
-
"sha256": "
|
|
3830
|
+
"sha256": "423afc175f0e1024d39fc20b561e24b423e2d9b6e5c16ddcd8e4a3329bcfa97b"
|
|
3826
3831
|
},
|
|
3827
3832
|
{
|
|
3828
3833
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -3842,7 +3847,7 @@
|
|
|
3842
3847
|
{
|
|
3843
3848
|
"path": "modules/platform-identity/routes/profile.js",
|
|
3844
3849
|
"mode": "0000644",
|
|
3845
|
-
"sha256": "
|
|
3850
|
+
"sha256": "2e158331e7cae0535ae81d63b6b7e8d471330c0f3d732f7e3092e45ad8255916"
|
|
3846
3851
|
},
|
|
3847
3852
|
{
|
|
3848
3853
|
"path": "modules/platform-identity/routes/projects.js",
|
|
@@ -3852,7 +3857,7 @@
|
|
|
3852
3857
|
{
|
|
3853
3858
|
"path": "modules/platform-identity/routes/public-profile.js",
|
|
3854
3859
|
"mode": "0000644",
|
|
3855
|
-
"sha256": "
|
|
3860
|
+
"sha256": "e59f99d6f323e70611a08fa2c8299af815e350dbb81bd7cc79e9d96bd93cdbba"
|
|
3856
3861
|
},
|
|
3857
3862
|
{
|
|
3858
3863
|
"path": "modules/platform-identity/routes/scouting.js",
|
|
@@ -3867,7 +3872,7 @@
|
|
|
3867
3872
|
{
|
|
3868
3873
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
3869
3874
|
"mode": "0000644",
|
|
3870
|
-
"sha256": "
|
|
3875
|
+
"sha256": "514adcaae952190d418888ffb778d541a632e3040664ce53f7a55ba9a297292b"
|
|
3871
3876
|
},
|
|
3872
3877
|
{
|
|
3873
3878
|
"path": "modules/platform-identity/ui/.gitkeep",
|
|
@@ -4242,12 +4247,12 @@
|
|
|
4242
4247
|
{
|
|
4243
4248
|
"path": "package-lock.json",
|
|
4244
4249
|
"mode": "0000644",
|
|
4245
|
-
"sha256": "
|
|
4250
|
+
"sha256": "1817517e0566226b12320fb61a3a216cbb08c4536cd0e5b959567438b81f9993"
|
|
4246
4251
|
},
|
|
4247
4252
|
{
|
|
4248
4253
|
"path": "package.json",
|
|
4249
4254
|
"mode": "0000644",
|
|
4250
|
-
"sha256": "
|
|
4255
|
+
"sha256": "090d46542c940a8327bb953057f47a8e51e56c9ecc86755af99a7e941eea4247"
|
|
4251
4256
|
},
|
|
4252
4257
|
{
|
|
4253
4258
|
"path": "public-docs/index.html",
|
|
@@ -5852,7 +5857,7 @@
|
|
|
5852
5857
|
{
|
|
5853
5858
|
"path": "src/module-api.js",
|
|
5854
5859
|
"mode": "0000644",
|
|
5855
|
-
"sha256": "
|
|
5860
|
+
"sha256": "bcbce9e29c6f05fc39ad8e52b4aa13b170962a06dbd3b6b5befba810a48e29d8"
|
|
5856
5861
|
},
|
|
5857
5862
|
{
|
|
5858
5863
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6967,7 +6972,7 @@
|
|
|
6967
6972
|
{
|
|
6968
6973
|
"path": "tests/hub_profile_leak.mjs",
|
|
6969
6974
|
"mode": "0000644",
|
|
6970
|
-
"sha256": "
|
|
6975
|
+
"sha256": "ea992d1ab2ddedc27aaa5348a397356bef91a4c1ad9b295eb1a0ed1c4fc707e9"
|
|
6971
6976
|
},
|
|
6972
6977
|
{
|
|
6973
6978
|
"path": "tests/idea_completeness.mjs",
|
|
@@ -7434,6 +7439,11 @@
|
|
|
7434
7439
|
"mode": "0000644",
|
|
7435
7440
|
"sha256": "3c85d28ecc55e375988f3e55aee993c8c84220889678262d8207146c2772f3f5"
|
|
7436
7441
|
},
|
|
7442
|
+
{
|
|
7443
|
+
"path": "tests/profile_fields.mjs",
|
|
7444
|
+
"mode": "0000644",
|
|
7445
|
+
"sha256": "ff73579dd2416a81648d85dcd5d1a77cb776c32047bd0cc7897dff132cbae5b7"
|
|
7446
|
+
},
|
|
7437
7447
|
{
|
|
7438
7448
|
"path": "tests/profile_rollup_consent.mjs",
|
|
7439
7449
|
"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
|
+
- 276 operations across 46 resource groups
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -433,6 +433,8 @@ function createClient(opts = {}) {
|
|
|
433
433
|
getMeModelAllocation: (args) => request("GET", "/me/model-allocation", { hasBody: false }, args),
|
|
434
434
|
// PATCH /me/model-allocation — rank: any-builder — PATCH /me/model-allocation
|
|
435
435
|
patchMeModelAllocation: (args) => request("PATCH", "/me/model-allocation", { hasBody: true }, args),
|
|
436
|
+
// PATCH /me/profile — rank: any-builder — PATCH /me/profile
|
|
437
|
+
patchMeProfile: (args) => request("PATCH", "/me/profile", { hasBody: true }, args),
|
|
436
438
|
// GET /me/recent-ships — rank: any-builder — GET /me/recent-ships
|
|
437
439
|
getMeRecentShips: (args) => request("GET", "/me/recent-ships", { hasBody: false }, args),
|
|
438
440
|
// GET /me/render — rank: any-builder — GET /me/render
|
|
@@ -432,6 +432,8 @@ function createClient(opts = {}) {
|
|
|
432
432
|
getMeModelAllocation: (args) => request("GET", "/me/model-allocation", { hasBody: false }, args),
|
|
433
433
|
// PATCH /me/model-allocation — rank: any-builder — PATCH /me/model-allocation
|
|
434
434
|
patchMeModelAllocation: (args) => request("PATCH", "/me/model-allocation", { hasBody: true }, args),
|
|
435
|
+
// PATCH /me/profile — rank: any-builder — PATCH /me/profile
|
|
436
|
+
patchMeProfile: (args) => request("PATCH", "/me/profile", { hasBody: true }, args),
|
|
435
437
|
// GET /me/recent-ships — rank: any-builder — GET /me/recent-ships
|
|
436
438
|
getMeRecentShips: (args) => request("GET", "/me/recent-ships", { hasBody: false }, args),
|
|
437
439
|
// GET /me/render — rank: any-builder — GET /me/render
|
|
@@ -51,7 +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; handle: unknown; cross_project: unknown }
|
|
54
|
+
export interface GetMeCrossProjectResponse { hide_stats: boolean; profile_state: unknown; handle: unknown; display_name: unknown; bio: unknown; links: unknown; cross_project: unknown }
|
|
55
55
|
export interface GetMeDisplayNameResponse { display_name: unknown; max_length: unknown }
|
|
56
56
|
export interface GetMeEventSoundPrefsResponse { prefs: unknown; effective: unknown; effective_wav: unknown; events: unknown }
|
|
57
57
|
export interface GetMeRecentShipsResponse { ships: unknown }
|
|
@@ -134,6 +134,8 @@ export interface PatchMeDisplayNameResponse { display_name: unknown; max_length:
|
|
|
134
134
|
export interface PatchMeEventSoundPrefsResponse { prefs: unknown; effective: unknown; effective_wav: unknown; events: unknown }
|
|
135
135
|
export interface PatchMeHandleRequest { handle: string }
|
|
136
136
|
export interface PatchMeHandleResponse { handle: unknown; previous_handle: unknown }
|
|
137
|
+
export interface PatchMeProfileRequest { display_name?: unknown; bio?: unknown; links?: unknown }
|
|
138
|
+
export interface PatchMeProfileResponse { display_name: unknown; bio: unknown; links: unknown }
|
|
137
139
|
export interface PatchMeSkillPrefsResponse { prefs: unknown; effective: unknown; defaults: unknown; known_skills: unknown; allowed_models: unknown }
|
|
138
140
|
export interface PatchMeSoundPrefsResponse { prefs: unknown; effective: unknown; effective_map: unknown; volume: unknown; defaults: unknown; known_sounds: unknown }
|
|
139
141
|
export interface PatchProjectsIdFeaturedRequest { featured: boolean }
|
|
@@ -675,6 +677,8 @@ export interface BongosClient {
|
|
|
675
677
|
getMeModelAllocation(args?: RequestArgs): Promise<ApiResponse>;
|
|
676
678
|
/** PATCH /me/model-allocation — rank: any-builder */
|
|
677
679
|
patchMeModelAllocation(args?: RequestArgs): Promise<ApiResponse>;
|
|
680
|
+
/** PATCH /me/profile — rank: any-builder */
|
|
681
|
+
patchMeProfile(args?: RequestArgs & { body?: PatchMeProfileRequest }): Promise<PatchMeProfileResponse>;
|
|
678
682
|
/** GET /me/recent-ships — rank: any-builder */
|
|
679
683
|
getMeRecentShips(args?: RequestArgs): Promise<GetMeRecentShipsResponse>;
|
|
680
684
|
/** GET /me/render — rank: any-builder */
|
|
@@ -429,6 +429,8 @@ export function createClient(opts = {}) {
|
|
|
429
429
|
getMeModelAllocation: (args) => request("GET", "/me/model-allocation", { hasBody: false }, args),
|
|
430
430
|
// PATCH /me/model-allocation — rank: any-builder — PATCH /me/model-allocation
|
|
431
431
|
patchMeModelAllocation: (args) => request("PATCH", "/me/model-allocation", { hasBody: true }, args),
|
|
432
|
+
// PATCH /me/profile — rank: any-builder — PATCH /me/profile
|
|
433
|
+
patchMeProfile: (args) => request("PATCH", "/me/profile", { hasBody: true }, args),
|
|
432
434
|
// GET /me/recent-ships — rank: any-builder — GET /me/recent-ships
|
|
433
435
|
getMeRecentShips: (args) => request("GET", "/me/recent-ships", { hasBody: false }, args),
|
|
434
436
|
// GET /me/render — rank: any-builder — GET /me/render
|
package/docs/api/openapi.json
CHANGED
|
@@ -6774,6 +6774,55 @@
|
|
|
6774
6774
|
]
|
|
6775
6775
|
}
|
|
6776
6776
|
},
|
|
6777
|
+
"/me/profile": {
|
|
6778
|
+
"patch": {
|
|
6779
|
+
"operationId": "patch_me_profile",
|
|
6780
|
+
"tags": [
|
|
6781
|
+
"me"
|
|
6782
|
+
],
|
|
6783
|
+
"summary": "PATCH /me/profile",
|
|
6784
|
+
"description": "the caller's OWN profile fields (P02, task 1002798; spec Decision 4): bio, typed links, account display name. Own data, keyed on the authenticated github_id. The schema declares the keys for strict mode; the REAL validation (bio length/charset, https-only + host-pinned links, name length) lives in the domain, next to the data, and each field is nullable (null clears), which validateOrRespond's typed rules can't express.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
6785
|
+
"x-rank": "any-builder",
|
|
6786
|
+
"x-source": "modules/platform-identity/routes/profile.js",
|
|
6787
|
+
"requestBody": {
|
|
6788
|
+
"required": false,
|
|
6789
|
+
"content": {
|
|
6790
|
+
"application/json": {
|
|
6791
|
+
"schema": {
|
|
6792
|
+
"$ref": "#/components/schemas/PatchMeProfileRequest"
|
|
6793
|
+
}
|
|
6794
|
+
}
|
|
6795
|
+
},
|
|
6796
|
+
"x-validated": true
|
|
6797
|
+
},
|
|
6798
|
+
"responses": {
|
|
6799
|
+
"200": {
|
|
6800
|
+
"description": "Success.",
|
|
6801
|
+
"content": {
|
|
6802
|
+
"application/json": {
|
|
6803
|
+
"schema": {
|
|
6804
|
+
"$ref": "#/components/schemas/PatchMeProfileResponse"
|
|
6805
|
+
}
|
|
6806
|
+
}
|
|
6807
|
+
}
|
|
6808
|
+
},
|
|
6809
|
+
"400": {
|
|
6810
|
+
"$ref": "#/components/responses/ValidationFailed"
|
|
6811
|
+
},
|
|
6812
|
+
"401": {
|
|
6813
|
+
"$ref": "#/components/responses/Unauthorized"
|
|
6814
|
+
},
|
|
6815
|
+
"403": {
|
|
6816
|
+
"$ref": "#/components/responses/Forbidden"
|
|
6817
|
+
}
|
|
6818
|
+
},
|
|
6819
|
+
"security": [
|
|
6820
|
+
{
|
|
6821
|
+
"builderSession": []
|
|
6822
|
+
}
|
|
6823
|
+
]
|
|
6824
|
+
}
|
|
6825
|
+
},
|
|
6777
6826
|
"/me/recent-ships": {
|
|
6778
6827
|
"get": {
|
|
6779
6828
|
"operationId": "get_me_recent_ships",
|
|
@@ -8174,7 +8223,7 @@
|
|
|
8174
8223
|
"profiles"
|
|
8175
8224
|
],
|
|
8176
8225
|
"summary": "GET /profiles/:handle",
|
|
8177
|
-
"description": "a handle-addressed public profile. Every field is already world-readable on the numeric profile route + the leaderboard; the handle is just the rename-proof address (spec Decision 3). Whitelisting happens inside the module (getPublicProfileByHandle); no-store because the payload embeds cross_project and hide must beat the cache (ADR 0171 D5).\n\n**Rank:** `public` — No authentication — any caller.",
|
|
8226
|
+
"description": "a handle-addressed public profile. Every field is already world-readable on the numeric profile route + the leaderboard; the handle is just the rename-proof address (spec Decision 3). Whitelisting happens inside the module (getPublicProfileByHandle); no-store because the payload embeds cross_project and hide must beat the cache (ADR 0171 D5). Rate-limited like /public/* (per-IP read limiter) so the handle→identity mapping can't be scripted-enumerated at line rate (R05 grade advisory, task 1002798).\n\n**Rank:** `public` — No authentication — any caller.",
|
|
8178
8227
|
"x-rank": "public",
|
|
8179
8228
|
"x-source": "modules/platform-identity/routes/public-profile.js",
|
|
8180
8229
|
"parameters": [
|
|
@@ -13038,12 +13087,18 @@
|
|
|
13038
13087
|
},
|
|
13039
13088
|
"profile_state": {},
|
|
13040
13089
|
"handle": {},
|
|
13090
|
+
"display_name": {},
|
|
13091
|
+
"bio": {},
|
|
13092
|
+
"links": {},
|
|
13041
13093
|
"cross_project": {}
|
|
13042
13094
|
},
|
|
13043
13095
|
"required": [
|
|
13044
13096
|
"hide_stats",
|
|
13045
13097
|
"profile_state",
|
|
13046
13098
|
"handle",
|
|
13099
|
+
"display_name",
|
|
13100
|
+
"bio",
|
|
13101
|
+
"links",
|
|
13047
13102
|
"cross_project"
|
|
13048
13103
|
]
|
|
13049
13104
|
},
|
|
@@ -14117,6 +14172,28 @@
|
|
|
14117
14172
|
"previous_handle"
|
|
14118
14173
|
]
|
|
14119
14174
|
},
|
|
14175
|
+
"PatchMeProfileRequest": {
|
|
14176
|
+
"type": "object",
|
|
14177
|
+
"properties": {
|
|
14178
|
+
"display_name": {},
|
|
14179
|
+
"bio": {},
|
|
14180
|
+
"links": {}
|
|
14181
|
+
},
|
|
14182
|
+
"additionalProperties": false
|
|
14183
|
+
},
|
|
14184
|
+
"PatchMeProfileResponse": {
|
|
14185
|
+
"type": "object",
|
|
14186
|
+
"properties": {
|
|
14187
|
+
"display_name": {},
|
|
14188
|
+
"bio": {},
|
|
14189
|
+
"links": {}
|
|
14190
|
+
},
|
|
14191
|
+
"required": [
|
|
14192
|
+
"display_name",
|
|
14193
|
+
"bio",
|
|
14194
|
+
"links"
|
|
14195
|
+
]
|
|
14196
|
+
},
|
|
14120
14197
|
"PatchMeSkillPrefsResponse": {
|
|
14121
14198
|
"type": "object",
|
|
14122
14199
|
"properties": {
|
|
@@ -17355,9 +17432,9 @@
|
|
|
17355
17432
|
"description": "A required dependency/feature is not configured or is temporarily down."
|
|
17356
17433
|
}
|
|
17357
17434
|
},
|
|
17358
|
-
"x-endpoint-count":
|
|
17359
|
-
"x-schema-count":
|
|
17435
|
+
"x-endpoint-count": 276,
|
|
17436
|
+
"x-schema-count": 311,
|
|
17360
17437
|
"x-undocumented-bodies": 8,
|
|
17361
|
-
"x-response-schemas":
|
|
17438
|
+
"x-response-schemas": 210,
|
|
17362
17439
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
17363
17440
|
}
|
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. 276 endpoints across 46 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,7 +282,7 @@ 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` (25)
|
|
286
286
|
|
|
287
287
|
| Method | Path | Rank | Body | Description |
|
|
288
288
|
|---|---|---|---|---|
|
|
@@ -301,6 +301,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
301
301
|
| PATCH | `/api/bongos/me/interaction` | `any-builder` | _undocumented_ | PATCH the interaction profile. |
|
|
302
302
|
| GET | `/api/bongos/me/model-allocation` | `any-builder` | — | |
|
|
303
303
|
| PATCH | `/api/bongos/me/model-allocation` | `any-builder` | _undocumented_ | PATCH the model allocation. |
|
|
304
|
+
| PATCH | `/api/bongos/me/profile` | `any-builder` | `display_name`, `bio`, `links` | the caller's OWN profile fields (P02, task 1002798; spec Decision 4): bio, typed links, account display name. |
|
|
304
305
|
| GET | `/api/bongos/me/recent-ships` | `any-builder` | — | Tasks this builder has shipped, most recent first. |
|
|
305
306
|
| GET | `/api/bongos/me/render` | `any-builder` | — | Per-builder "render widgets" knob (task 1279, shares the behavior_prefs jsonb / migration 119). |
|
|
306
307
|
| PATCH | `/api/bongos/me/render` | `any-builder` | _undocumented_ | PATCH the render knob. |
|
|
@@ -432,6 +432,8 @@ function createClient(opts = {}) {
|
|
|
432
432
|
getMeModelAllocation: (args) => request("GET", "/me/model-allocation", { hasBody: false }, args),
|
|
433
433
|
// PATCH /me/model-allocation — rank: any-builder — PATCH /me/model-allocation
|
|
434
434
|
patchMeModelAllocation: (args) => request("PATCH", "/me/model-allocation", { hasBody: true }, args),
|
|
435
|
+
// PATCH /me/profile — rank: any-builder — PATCH /me/profile
|
|
436
|
+
patchMeProfile: (args) => request("PATCH", "/me/profile", { hasBody: true }, args),
|
|
435
437
|
// GET /me/recent-ships — rank: any-builder — GET /me/recent-ships
|
|
436
438
|
getMeRecentShips: (args) => request("GET", "/me/recent-ships", { hasBody: false }, args),
|
|
437
439
|
// GET /me/render — rank: any-builder — GET /me/render
|
|
@@ -177,7 +177,7 @@
|
|
|
177
177
|
const name = escapeHtml(p.name || 'Project');
|
|
178
178
|
// origin is third-party-reported; link only a well-formed https URL.
|
|
179
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;
|
|
180
|
+
const nameCell = origin ? `<a href="${escapeHtml(origin)}" rel="noopener noreferrer">${name}</a>` : name;
|
|
181
181
|
// rank_held is THAT project's standing as it reported it — plain text, never
|
|
182
182
|
// the local rank badge or the instance's own rank labels (spec Decision 1).
|
|
183
183
|
const held = typeof p.rank_held === 'string' && p.rank_held
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- platform_identity_009_profile_fields.sql — bio + typed links + the
|
|
2
|
+
-- display-name source bit (P02, task 1002798 / goal 1000045; profile-page spec
|
|
3
|
+
-- Decision 4, ADR 0171 amendments R01).
|
|
4
|
+
--
|
|
5
|
+
-- bio — plain text, ~280 chars, NO markdown and no auto-linking. The length
|
|
6
|
+
-- cap + charset live in the domain validator (validateProfileBio in
|
|
7
|
+
-- platform-identity.js — the only writer); nullable = never set.
|
|
8
|
+
-- links — a TYPED, capped set as a jsonb object whose only keys are
|
|
9
|
+
-- website | x | linkedin | discord, each an https URL (max 4 by
|
|
10
|
+
-- construction — one per type). Typed-and-capped rather than a
|
|
11
|
+
-- free-form list because a public, indexable page with
|
|
12
|
+
-- user-controlled links is an SEO-spam magnet (spec Decision 4).
|
|
13
|
+
-- Validation (https-only, no userinfo, per-type host pinning) lives
|
|
14
|
+
-- in validateProfileLinks — the single write path; nullable = never set.
|
|
15
|
+
-- display_name_source — the ADR 0156 mirror for the HUB account: 'github'
|
|
16
|
+
-- (default; upsertAccount keeps re-syncing the name on every
|
|
17
|
+
-- check-in/authorize) or 'user' (set by setAccountProfileFields; the
|
|
18
|
+
-- upsertAccount conflict branch then never overwrites the name).
|
|
19
|
+
-- Module-owned here — unlike core_197 the guard reads it inside this
|
|
20
|
+
-- module's own upsertAccount, so it belongs on the module's table
|
|
21
|
+
-- (ADR 0083 §5).
|
|
22
|
+
--
|
|
23
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent: the
|
|
24
|
+
-- inline CHECK rides the ADD COLUMN IF NOT EXISTS, so a re-run is a no-op.
|
|
25
|
+
|
|
26
|
+
BEGIN;
|
|
27
|
+
|
|
28
|
+
ALTER TABLE platform_identity_accounts
|
|
29
|
+
ADD COLUMN IF NOT EXISTS bio text,
|
|
30
|
+
ADD COLUMN IF NOT EXISTS links jsonb,
|
|
31
|
+
ADD COLUMN IF NOT EXISTS display_name_source text NOT NULL DEFAULT 'github'
|
|
32
|
+
CHECK (display_name_source IN ('github', 'user'));
|
|
33
|
+
|
|
34
|
+
-- 'github' is correct for every pre-existing row: before this change there was
|
|
35
|
+
-- no user-set account display name, so whatever is stored IS GitHub-derived
|
|
36
|
+
-- (the ADR 0156 backfill argument, unchanged).
|
|
37
|
+
|
|
38
|
+
COMMIT;
|
|
@@ -107,13 +107,21 @@ function redirectUriAllowed(client, redirectUri) {
|
|
|
107
107
|
// where a human is demonstrably at the hub. Every other caller (device-poll,
|
|
108
108
|
// membership check-in) leaves the fail-closed 'provisional' default; activation
|
|
109
109
|
// is one-way (a later non-activating upsert never demotes an active account).
|
|
110
|
+
//
|
|
111
|
+
// display_name is source-guarded (the ADR 0156 mirror, P02/task 1002798): once
|
|
112
|
+
// a builder sets the account name themselves (setAccountProfileFields stamps
|
|
113
|
+
// display_name_source='user'), no check-in/authorize re-sync overwrites it; a
|
|
114
|
+
// never-renamed account keeps following GitHub. github_login and avatar_url
|
|
115
|
+
// re-sync unconditionally — neither is user-editable (spec Decision 4).
|
|
110
116
|
async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, activate = false }, { pool = defaultPool } = {}) {
|
|
111
117
|
const { rows } = await pool.query(
|
|
112
118
|
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
|
|
113
119
|
VALUES ($1, $2, $3, $4, CASE WHEN $5::boolean THEN 'active' ELSE 'provisional' END)
|
|
114
120
|
ON CONFLICT (github_id) DO UPDATE SET
|
|
115
121
|
github_login = EXCLUDED.github_login,
|
|
116
|
-
display_name =
|
|
122
|
+
display_name = CASE WHEN platform_identity_accounts.display_name_source = 'user'
|
|
123
|
+
THEN platform_identity_accounts.display_name
|
|
124
|
+
ELSE EXCLUDED.display_name END,
|
|
117
125
|
avatar_url = EXCLUDED.avatar_url,
|
|
118
126
|
profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END,
|
|
119
127
|
updated_at = now()
|
|
@@ -142,7 +150,8 @@ async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
|
|
|
142
150
|
async function getAccount(githubId, { pool = defaultPool } = {}) {
|
|
143
151
|
const { rows } = await pool.query(
|
|
144
152
|
`SELECT id, github_id, github_login, display_name, avatar_url, profile_state, hide_stats,
|
|
145
|
-
handle, previous_handle, handle_changed_at,
|
|
153
|
+
handle, previous_handle, handle_changed_at, bio, links, display_name_source,
|
|
154
|
+
created_at, updated_at
|
|
146
155
|
FROM platform_identity_accounts WHERE github_id = $1`,
|
|
147
156
|
[githubId],
|
|
148
157
|
);
|
|
@@ -762,7 +771,7 @@ async function resolveHandle(handle, { pool = defaultPool } = {}) {
|
|
|
762
771
|
const norm = normalizeHandle(handle);
|
|
763
772
|
if (!norm) return null;
|
|
764
773
|
const { rows } = await pool.query(
|
|
765
|
-
`SELECT github_id, github_login, display_name, avatar_url, handle, created_at,
|
|
774
|
+
`SELECT github_id, github_login, display_name, avatar_url, handle, bio, links, created_at,
|
|
766
775
|
(lower(handle) = $1) AS is_current
|
|
767
776
|
FROM platform_identity_accounts
|
|
768
777
|
WHERE profile_state = 'active'
|
|
@@ -797,11 +806,189 @@ async function getPublicProfileByHandle(handle, { pool = defaultPool } = {}) {
|
|
|
797
806
|
display_name: a.display_name,
|
|
798
807
|
avatar_url: a.avatar_url,
|
|
799
808
|
member_since: a.created_at,
|
|
809
|
+
// P02 (spec Decision 4): plain-text bio + the typed link set. RENDER
|
|
810
|
+
// CONTRACT for any consumer: escape the bio, never linkify it, and emit
|
|
811
|
+
// links with rel="nofollow ugc noopener" (validated at write, but the
|
|
812
|
+
// page must still treat them as user content).
|
|
813
|
+
bio: a.bio ?? null,
|
|
814
|
+
links: a.links ?? null,
|
|
800
815
|
},
|
|
801
816
|
cross_project: await getPublicProfileExtras(a.github_id, { pool }),
|
|
802
817
|
};
|
|
803
818
|
}
|
|
804
819
|
|
|
820
|
+
// -------------------------------------------------------------------------
|
|
821
|
+
// Profile fields: bio + typed links + account display name (P02, task 1002798
|
|
822
|
+
// — profile-page spec Decision 4, ADR 0171 amendments R01)
|
|
823
|
+
// -------------------------------------------------------------------------
|
|
824
|
+
//
|
|
825
|
+
// The typed-and-capped shape is the anti-spam design: a public, indexable page
|
|
826
|
+
// with user-controlled links is an SEO-spam magnet, and a fixed set of four
|
|
827
|
+
// typed slots rendered rel="nofollow ugc noopener" removes the incentive
|
|
828
|
+
// without a moderation queue. Validation lives HERE, next to the data, where
|
|
829
|
+
// no route can forget it (the upsertActivity precedent) — the client is never
|
|
830
|
+
// relied on.
|
|
831
|
+
|
|
832
|
+
// ~280 chars, Twitter-bio-sized (spec Decision 4). Counted in code points so a
|
|
833
|
+
// 280-emoji bio is exactly at the cap, not over it (the display-name precedent).
|
|
834
|
+
const BIO_MAX_CHARS = 280;
|
|
835
|
+
|
|
836
|
+
// Mirrors modules/builder-settings/display-name.js MAX_LENGTH — boundary-forced
|
|
837
|
+
// duplication (the ADR 0091 §2 pattern): the module may not import another
|
|
838
|
+
// module's validator, and the two caps should not drift apart silently.
|
|
839
|
+
const DISPLAY_NAME_MAX_CHARS = 60;
|
|
840
|
+
|
|
841
|
+
// The closed link vocabulary. One slot per type ⇒ max 4 by construction.
|
|
842
|
+
const PROFILE_LINK_TYPES = ['website', 'x', 'linkedin', 'discord'];
|
|
843
|
+
|
|
844
|
+
// Typed slots are host-pinned: a link labelled "LinkedIn" that resolves
|
|
845
|
+
// anywhere else is a phishing shape, not a profile link. `website` is the one
|
|
846
|
+
// free slot (any https host). Subdomains of a pinned host are allowed.
|
|
847
|
+
const PROFILE_LINK_HOSTS = {
|
|
848
|
+
x: ['x.com', 'twitter.com'],
|
|
849
|
+
linkedin: ['linkedin.com'],
|
|
850
|
+
discord: ['discord.gg', 'discord.com'],
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
const PROFILE_LINK_MAX_CHARS = 300;
|
|
854
|
+
|
|
855
|
+
const codePointLength = (s) => Array.from(s).length;
|
|
856
|
+
|
|
857
|
+
// Bio: plain text only — NO markdown, no auto-linking (that is a RENDER
|
|
858
|
+
// contract: consumers escape + never linkify). Trimmed; empty/null clears.
|
|
859
|
+
// Newlines and tabs are fine; other control characters are not text.
|
|
860
|
+
// Returns { value } (value null = clear) or { error, ... }.
|
|
861
|
+
function validateProfileBio(raw) {
|
|
862
|
+
if (raw == null) return { value: null };
|
|
863
|
+
if (typeof raw !== 'string') return { error: 'invalid_bio', reason: 'not_a_string' };
|
|
864
|
+
const trimmed = raw.trim();
|
|
865
|
+
if (!trimmed) return { value: null };
|
|
866
|
+
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(trimmed)) {
|
|
867
|
+
return { error: 'invalid_bio', reason: 'control_characters' };
|
|
868
|
+
}
|
|
869
|
+
const length = codePointLength(trimmed);
|
|
870
|
+
if (length > BIO_MAX_CHARS) return { error: 'bio_too_long', max: BIO_MAX_CHARS, length };
|
|
871
|
+
return { value: trimmed };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function hostAllowed(hostname, allowed) {
|
|
875
|
+
return allowed.some((h) => hostname === h || hostname.endsWith('.' + h));
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// One link slot: https only — rejecting every other scheme (javascript:,
|
|
879
|
+
// data:, http:, …) — with the userinfo/host tricks closed: credentials in the
|
|
880
|
+
// URL (https://linkedin.com@evil.com parses linkedin.com as USERNAME) are
|
|
881
|
+
// refused outright, and typed slots must land on their pinned host. Returns
|
|
882
|
+
// the WHATWG-normalized href (lowercased host, canonical escaping) or an error.
|
|
883
|
+
function validateProfileLink(type, raw) {
|
|
884
|
+
if (typeof raw !== 'string' || !raw.trim()) return { error: 'invalid_link', link_type: type, reason: 'not_a_url' };
|
|
885
|
+
const trimmed = raw.trim();
|
|
886
|
+
if (trimmed.length > PROFILE_LINK_MAX_CHARS) return { error: 'invalid_link', link_type: type, reason: 'too_long' };
|
|
887
|
+
let u;
|
|
888
|
+
try { u = new URL(trimmed); } catch { return { error: 'invalid_link', link_type: type, reason: 'not_a_url' }; }
|
|
889
|
+
if (u.protocol !== 'https:') return { error: 'invalid_link', link_type: type, reason: 'https_only' };
|
|
890
|
+
if (u.username || u.password) return { error: 'invalid_link', link_type: type, reason: 'userinfo_not_allowed' };
|
|
891
|
+
if (!u.hostname || !u.hostname.includes('.')) return { error: 'invalid_link', link_type: type, reason: 'bad_host' };
|
|
892
|
+
const pinned = PROFILE_LINK_HOSTS[type];
|
|
893
|
+
if (pinned && !hostAllowed(u.hostname, pinned)) {
|
|
894
|
+
return { error: 'invalid_link', link_type: type, reason: 'wrong_host', expected_hosts: pinned };
|
|
895
|
+
}
|
|
896
|
+
return { value: u.href };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// The full links object — REPLACES the stored set wholesale (a PATCH sends the
|
|
900
|
+
// set it wants, omitting a key drops that link; null/{} clears everything).
|
|
901
|
+
// Only the closed vocabulary is accepted; a null/empty slot value is treated
|
|
902
|
+
// as "drop this slot". Returns { value } (null = clear) or the first error.
|
|
903
|
+
function validateProfileLinks(raw) {
|
|
904
|
+
if (raw == null) return { value: null };
|
|
905
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return { error: 'invalid_links', reason: 'not_an_object' };
|
|
906
|
+
const out = {};
|
|
907
|
+
for (const [type, val] of Object.entries(raw)) {
|
|
908
|
+
if (!PROFILE_LINK_TYPES.includes(type)) return { error: 'invalid_links', reason: 'unknown_link_type', link_type: type };
|
|
909
|
+
if (val == null || val === '') continue;
|
|
910
|
+
const one = validateProfileLink(type, val);
|
|
911
|
+
if (one.error) return one;
|
|
912
|
+
out[type] = one.value;
|
|
913
|
+
}
|
|
914
|
+
return { value: Object.keys(out).length ? out : null };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Account display name (spec Decision 4: the hub gets its OWN copy — hub value
|
|
918
|
+
// wins on hub surfaces, instance value on instance surfaces, no sync engine).
|
|
919
|
+
function validateAccountDisplayName(raw) {
|
|
920
|
+
if (typeof raw !== 'string') return { error: 'invalid_display_name', reason: 'not_a_string' };
|
|
921
|
+
const trimmed = raw.trim();
|
|
922
|
+
if (!trimmed) return { error: 'invalid_display_name', reason: 'empty' };
|
|
923
|
+
if (/[\u0000-\u001F\u007F]/.test(trimmed)) return { error: 'invalid_display_name', reason: 'control_characters' };
|
|
924
|
+
const length = codePointLength(trimmed);
|
|
925
|
+
if (length > DISPLAY_NAME_MAX_CHARS) return { error: 'display_name_too_long', max: DISPLAY_NAME_MAX_CHARS, length };
|
|
926
|
+
return { value: trimmed };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// The own-scoped profile-fields WRITE (P02) — callers key this by the
|
|
930
|
+
// AUTHENTICATED session's github_id, never a caller-named one. Validates here,
|
|
931
|
+
// then upserts on the setAccountHideStats pattern: the INSERT branch creates a
|
|
932
|
+
// fail-closed provisional account (a hub builder who never federated can still
|
|
933
|
+
// fill their profile ahead of time); the UPDATE branch touches ONLY the fields
|
|
934
|
+
// the caller sent — never identity re-sync, never profile_state (activation
|
|
935
|
+
// stays /sso/authorize's alone, D3), never hide_stats. Setting display_name
|
|
936
|
+
// stamps display_name_source='user' — from then on upsertAccount's re-sync
|
|
937
|
+
// keeps its hands off the name (the ADR 0156 mirror).
|
|
938
|
+
async function setAccountProfileFields({ githubId, githubLogin, displayName, avatarUrl, fields = {} }, { pool = defaultPool } = {}) {
|
|
939
|
+
if (!isNumericGithubId(githubId)) return { error: 'no_account' };
|
|
940
|
+
const setName = 'display_name' in fields;
|
|
941
|
+
const setBio = 'bio' in fields;
|
|
942
|
+
const setLinks = 'links' in fields;
|
|
943
|
+
if (!setName && !setBio && !setLinks) return { error: 'no_fields' };
|
|
944
|
+
let nameValue = null;
|
|
945
|
+
if (setName) {
|
|
946
|
+
const v = validateAccountDisplayName(fields.display_name);
|
|
947
|
+
if (v.error) return v;
|
|
948
|
+
nameValue = v.value;
|
|
949
|
+
}
|
|
950
|
+
let bioValue = null;
|
|
951
|
+
if (setBio) {
|
|
952
|
+
const v = validateProfileBio(fields.bio);
|
|
953
|
+
if (v.error) return v;
|
|
954
|
+
bioValue = v.value;
|
|
955
|
+
}
|
|
956
|
+
let linksValue = null;
|
|
957
|
+
if (setLinks) {
|
|
958
|
+
const v = validateProfileLinks(fields.links);
|
|
959
|
+
if (v.error) return v;
|
|
960
|
+
linksValue = v.value;
|
|
961
|
+
}
|
|
962
|
+
const { rows } = await pool.query(
|
|
963
|
+
`INSERT INTO platform_identity_accounts
|
|
964
|
+
(github_id, github_login, display_name, avatar_url, bio, links, display_name_source)
|
|
965
|
+
VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN $7::boolean THEN 'user' ELSE 'github' END)
|
|
966
|
+
ON CONFLICT (github_id) DO UPDATE SET
|
|
967
|
+
display_name = CASE WHEN $7::boolean THEN EXCLUDED.display_name ELSE platform_identity_accounts.display_name END,
|
|
968
|
+
display_name_source = CASE WHEN $7::boolean THEN 'user' ELSE platform_identity_accounts.display_name_source END,
|
|
969
|
+
bio = CASE WHEN $8::boolean THEN EXCLUDED.bio ELSE platform_identity_accounts.bio END,
|
|
970
|
+
links = CASE WHEN $9::boolean THEN EXCLUDED.links ELSE platform_identity_accounts.links END,
|
|
971
|
+
updated_at = now()
|
|
972
|
+
RETURNING github_id, display_name, bio, links, handle`,
|
|
973
|
+
[
|
|
974
|
+
githubId,
|
|
975
|
+
githubLogin,
|
|
976
|
+
setName ? nameValue : (displayName ?? null),
|
|
977
|
+
avatarUrl ?? null,
|
|
978
|
+
bioValue,
|
|
979
|
+
linksValue == null ? null : JSON.stringify(linksValue),
|
|
980
|
+
setName,
|
|
981
|
+
setBio,
|
|
982
|
+
setLinks,
|
|
983
|
+
],
|
|
984
|
+
);
|
|
985
|
+
const row = rows[0];
|
|
986
|
+
// This upsert can CREATE the account row — seed the handle then, same as
|
|
987
|
+
// upsertAccount / setAccountHideStats.
|
|
988
|
+
if (row && !row.handle) row.handle = await ensureHandle({ githubId, githubLogin }, { pool });
|
|
989
|
+
return row;
|
|
990
|
+
}
|
|
991
|
+
|
|
805
992
|
// -------------------------------------------------------------------------
|
|
806
993
|
// Authorization codes (single-use, short-lived, bound)
|
|
807
994
|
// -------------------------------------------------------------------------
|
|
@@ -912,6 +1099,17 @@ module.exports = {
|
|
|
912
1099
|
setAccountHandle,
|
|
913
1100
|
resolveHandle,
|
|
914
1101
|
getPublicProfileByHandle,
|
|
1102
|
+
// profile fields: bio + typed links + account display name (P02)
|
|
1103
|
+
BIO_MAX_CHARS,
|
|
1104
|
+
DISPLAY_NAME_MAX_CHARS,
|
|
1105
|
+
PROFILE_LINK_TYPES,
|
|
1106
|
+
PROFILE_LINK_HOSTS,
|
|
1107
|
+
PROFILE_LINK_MAX_CHARS,
|
|
1108
|
+
validateProfileBio,
|
|
1109
|
+
validateProfileLink,
|
|
1110
|
+
validateProfileLinks,
|
|
1111
|
+
validateAccountDisplayName,
|
|
1112
|
+
setAccountProfileFields,
|
|
915
1113
|
// codes
|
|
916
1114
|
mintAuthCode,
|
|
917
1115
|
redeemAuthCode,
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// PATCH /me/handle — rename the caller's own handle (P01, task 1002797;
|
|
17
17
|
// spec Decision 3). Low rate limit, previous handle
|
|
18
18
|
// kept for the 301 window.
|
|
19
|
+
// PATCH /me/profile — the profile-fields write (P02, task 1002798; spec
|
|
20
|
+
// Decision 4): bio + typed links + the account
|
|
21
|
+
// display name. Validation lives in the domain
|
|
22
|
+
// (https-only typed links, host pinning, 280-char
|
|
23
|
+
// plain-text bio); setting display_name stamps
|
|
24
|
+
// display_name_source='user' so federated re-syncs
|
|
25
|
+
// stop overwriting it (the ADR 0156 mirror).
|
|
19
26
|
//
|
|
20
27
|
// Reaches core only through the doorway (ADR 0083).
|
|
21
28
|
'use strict';
|
|
@@ -35,7 +42,7 @@ module.exports = function profileRoutes() {
|
|
|
35
42
|
// Core builders include system:* principals whose github_id is not numeric;
|
|
36
43
|
// they have no platform identity — answer the empty shape, not a 500.
|
|
37
44
|
if (!pi.isNumericGithubId(req.builder.github_id)) {
|
|
38
|
-
return res.json({ hide_stats: false, profile_state: null, handle: null, cross_project: null });
|
|
45
|
+
return res.json({ hide_stats: false, profile_state: null, handle: null, display_name: null, bio: null, links: null, cross_project: null });
|
|
39
46
|
}
|
|
40
47
|
const [acct, extras] = await Promise.all([
|
|
41
48
|
pi.getAccount(req.builder.github_id, { pool }),
|
|
@@ -45,6 +52,10 @@ module.exports = function profileRoutes() {
|
|
|
45
52
|
hide_stats: !!(acct && acct.hide_stats),
|
|
46
53
|
profile_state: acct ? acct.profile_state : null,
|
|
47
54
|
handle: (acct && acct.handle) || null,
|
|
55
|
+
// P02: the account-global profile fields, for the Settings form (P04).
|
|
56
|
+
display_name: (acct && acct.display_name) || null,
|
|
57
|
+
bio: (acct && acct.bio) || null,
|
|
58
|
+
links: (acct && acct.links) || null,
|
|
48
59
|
cross_project: extras,
|
|
49
60
|
});
|
|
50
61
|
} catch (err) {
|
|
@@ -119,5 +130,56 @@ module.exports = function profileRoutes() {
|
|
|
119
130
|
}
|
|
120
131
|
});
|
|
121
132
|
|
|
133
|
+
// rank: any-builder — the caller's OWN profile fields (P02, task 1002798; spec
|
|
134
|
+
// Decision 4): bio, typed links, account display name. Own data, keyed on the
|
|
135
|
+
// authenticated github_id. The schema declares the keys for strict mode; the
|
|
136
|
+
// REAL validation (bio length/charset, https-only + host-pinned links, name
|
|
137
|
+
// length) lives in the domain, next to the data, and each field is nullable
|
|
138
|
+
// (null clears), which validateOrRespond's typed rules can't express.
|
|
139
|
+
router.patch('/me/profile', requireBuilder, async (req, res) => {
|
|
140
|
+
if (validateOrRespond(req, res, { display_name: {}, bio: {}, links: {} })) return;
|
|
141
|
+
try {
|
|
142
|
+
if (!pi.isNumericGithubId(req.builder.github_id)) {
|
|
143
|
+
return res.fail('no_platform_identity', 400);
|
|
144
|
+
}
|
|
145
|
+
const fields = {};
|
|
146
|
+
for (const k of ['display_name', 'bio', 'links']) {
|
|
147
|
+
if (k in req.body) fields[k] = req.body[k];
|
|
148
|
+
}
|
|
149
|
+
const out = await pi.setAccountProfileFields({
|
|
150
|
+
githubId: req.builder.github_id,
|
|
151
|
+
githubLogin: req.builder.github_login,
|
|
152
|
+
displayName: req.builder.display_name,
|
|
153
|
+
avatarUrl: req.builder.avatar_url,
|
|
154
|
+
fields,
|
|
155
|
+
}, { pool });
|
|
156
|
+
if (out.error === 'no_fields') {
|
|
157
|
+
return res.fail('no_fields', { status: 400, message: 'send at least one of display_name, bio, links' });
|
|
158
|
+
}
|
|
159
|
+
if (out.error === 'bio_too_long') {
|
|
160
|
+
return res.fail('bio_too_long', { status: 400, message: `a bio is at most ${pi.BIO_MAX_CHARS} characters of plain text` });
|
|
161
|
+
}
|
|
162
|
+
if (out.error === 'invalid_bio') {
|
|
163
|
+
return res.fail('invalid_bio', { status: 400, message: 'a bio is plain text (no control characters)' });
|
|
164
|
+
}
|
|
165
|
+
if (out.error === 'invalid_link' || out.error === 'invalid_links') {
|
|
166
|
+
return res.fail(out.error, { status: 400, message: `links are a typed set (${pi.PROFILE_LINK_TYPES.join(', ')}), https URLs only${out.link_type ? ` — problem with '${out.link_type}'` : ''}` });
|
|
167
|
+
}
|
|
168
|
+
if (out.error === 'display_name_too_long') {
|
|
169
|
+
return res.fail('display_name_too_long', { status: 400, message: `a display name is at most ${pi.DISPLAY_NAME_MAX_CHARS} characters` });
|
|
170
|
+
}
|
|
171
|
+
if (out.error === 'invalid_display_name') {
|
|
172
|
+
return res.fail('invalid_display_name', { status: 400, message: 'a display name must be non-empty plain text' });
|
|
173
|
+
}
|
|
174
|
+
if (out.error) {
|
|
175
|
+
return res.fail('profile_write_failed', { status: 500, message: 'internal error' });
|
|
176
|
+
}
|
|
177
|
+
res.json({ display_name: out.display_name, bio: out.bio, links: out.links });
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error('[platform-identity] PATCH /me/profile', err);
|
|
180
|
+
res.fail('profile_write_failed', { status: 500, message: 'internal error' });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
122
184
|
return router;
|
|
123
185
|
};
|
|
@@ -19,6 +19,43 @@ const pi = require('../platform-identity');
|
|
|
19
19
|
|
|
20
20
|
const { pool, corsPublicGet } = api;
|
|
21
21
|
|
|
22
|
+
// Per-IP read cap for this UNAUTHENTICATED route (R05 grade advisory, task
|
|
23
|
+
// 1002798): the global limiter only meters writes, so an uncapped scraper could
|
|
24
|
+
// enumerate the handle→identity mapping at line rate. Vendored sliding window —
|
|
25
|
+
// the modules/memory readRateLimit pattern (the core /public/* limiter is not
|
|
26
|
+
// on the doorway; a kernel file may not import it). 120/60s mirrors the
|
|
27
|
+
// /public/* posture; req.ip honors 'trust proxy' so it is the end user.
|
|
28
|
+
const READ_RATE_WINDOW_MS = 60 * 1000;
|
|
29
|
+
const READ_RATE_LIMIT = 120;
|
|
30
|
+
const buckets = new Map(); // ip -> timestamp[]
|
|
31
|
+
|
|
32
|
+
const sweepTimer = setInterval(() => {
|
|
33
|
+
const cutoff = Date.now() - READ_RATE_WINDOW_MS;
|
|
34
|
+
for (const [k, arr] of buckets) {
|
|
35
|
+
while (arr.length && arr[0] < cutoff) arr.shift();
|
|
36
|
+
if (arr.length === 0) buckets.delete(k);
|
|
37
|
+
}
|
|
38
|
+
}, 5 * 60 * 1000);
|
|
39
|
+
if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
|
|
40
|
+
|
|
41
|
+
function profileReadRateLimit(req, res, next) {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const key = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
44
|
+
let bucket = buckets.get(key);
|
|
45
|
+
if (!bucket) { bucket = []; buckets.set(key, bucket); }
|
|
46
|
+
const cutoff = now - READ_RATE_WINDOW_MS;
|
|
47
|
+
while (bucket.length && bucket[0] < cutoff) bucket.shift();
|
|
48
|
+
if (bucket.length >= READ_RATE_LIMIT) {
|
|
49
|
+
const retryAfter = Math.max(1, Math.ceil((bucket[0] + READ_RATE_WINDOW_MS - now) / 1000));
|
|
50
|
+
res.set('Retry-After', String(retryAfter));
|
|
51
|
+
res.set('X-RateLimit-Scope', 'public-profile-read');
|
|
52
|
+
res.set('X-RateLimit-Limit', String(READ_RATE_LIMIT));
|
|
53
|
+
return res.fail('rate_limited', 429, { scope: 'public-profile-read', retry_after_seconds: retryAfter });
|
|
54
|
+
}
|
|
55
|
+
bucket.push(now);
|
|
56
|
+
return next();
|
|
57
|
+
}
|
|
58
|
+
|
|
22
59
|
module.exports = function publicProfileRoutes() {
|
|
23
60
|
const router = express.Router();
|
|
24
61
|
|
|
@@ -28,8 +65,10 @@ module.exports = function publicProfileRoutes() {
|
|
|
28
65
|
// world-readable on the numeric profile route + the leaderboard; the handle is
|
|
29
66
|
// just the rename-proof address (spec Decision 3). Whitelisting happens inside
|
|
30
67
|
// the module (getPublicProfileByHandle); no-store because the payload embeds
|
|
31
|
-
// cross_project and hide must beat the cache (ADR 0171 D5).
|
|
32
|
-
|
|
68
|
+
// cross_project and hide must beat the cache (ADR 0171 D5). Rate-limited like
|
|
69
|
+
// /public/* (per-IP read limiter) so the handle→identity mapping can't be
|
|
70
|
+
// scripted-enumerated at line rate (R05 grade advisory, task 1002798).
|
|
71
|
+
router.get('/profiles/:handle', profileReadRateLimit, async (req, res) => {
|
|
33
72
|
try {
|
|
34
73
|
const out = await pi.getPublicProfileByHandle(req.params.handle, { pool });
|
|
35
74
|
res.set('Cache-Control', 'no-store');
|
|
@@ -756,7 +756,7 @@ await ta('getPublicProfileByHandle: identity-band whitelist — github_id/profil
|
|
|
756
756
|
{ rows: [{ profile_state: 'active', hide_stats: true }], rowCount: 1 },
|
|
757
757
|
]);
|
|
758
758
|
const out = await P.getPublicProfileByHandle('x', { pool });
|
|
759
|
-
assert.deepEqual(Object.keys(out.account).sort(), ['avatar_url', 'display_name', 'github_login', 'handle', 'member_since'].sort());
|
|
759
|
+
assert.deepEqual(Object.keys(out.account).sort(), ['avatar_url', 'bio', 'display_name', 'github_login', 'handle', 'links', 'member_since'].sort());
|
|
760
760
|
assert.equal(out.cross_project, null, 'hidden → null, indistinguishable from none');
|
|
761
761
|
const text = JSON.stringify(out);
|
|
762
762
|
assert.doesNotMatch(text, /github_id/, 'the join key stays internal');
|
|
@@ -772,5 +772,94 @@ await ta('getPublicProfileByHandle: unknown → null; moved passes through', asy
|
|
|
772
772
|
assert.deepEqual(await P.getPublicProfileByHandle('then', { pool: pool2 }), { moved_to: 'now' });
|
|
773
773
|
});
|
|
774
774
|
|
|
775
|
+
console.log('\nprofile fields (P02, task 1002798):');
|
|
776
|
+
|
|
777
|
+
t('validateProfileBio: trims, clears on empty/null, caps at 280 code points, plain text only', () => {
|
|
778
|
+
assert.deepEqual(P.validateProfileBio(null), { value: null });
|
|
779
|
+
assert.deepEqual(P.validateProfileBio(' '), { value: null });
|
|
780
|
+
assert.deepEqual(P.validateProfileBio(' hi '), { value: 'hi' });
|
|
781
|
+
assert.equal(P.validateProfileBio('🔥'.repeat(P.BIO_MAX_CHARS)).error, undefined, 'code points, not UTF-16 units');
|
|
782
|
+
assert.equal(P.validateProfileBio('x'.repeat(P.BIO_MAX_CHARS + 1)).error, 'bio_too_long');
|
|
783
|
+
assert.equal(P.validateProfileBio('a\nb\tc').value, 'a\nb\tc', 'newlines and tabs are text');
|
|
784
|
+
assert.equal(P.validateProfileBio('a\u0007b').error, 'invalid_bio', 'other control chars are not');
|
|
785
|
+
assert.equal(P.validateProfileBio(42).error, 'invalid_bio');
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
t('validateProfileLink: https only — every other scheme refused', () => {
|
|
789
|
+
assert.equal(P.validateProfileLink('website', 'https://me.example/x').value, 'https://me.example/x');
|
|
790
|
+
assert.equal(P.validateProfileLink('website', 'HTTPS://Me.EXAMPLE/x').value, 'https://me.example/x', 'scheme+host normalize');
|
|
791
|
+
for (const bad of ['http://me.example', 'javascript:alert(1)', 'data:text/html,x', 'ftp://me.example', '//me.example', 'me.example']) {
|
|
792
|
+
assert.ok(P.validateProfileLink('website', bad).error, `refused: ${bad}`);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
t('validateProfileLink: userinfo + host tricks refused', () => {
|
|
797
|
+
assert.equal(P.validateProfileLink('website', 'https://linkedin.com@evil.example/x').error, 'invalid_link');
|
|
798
|
+
assert.equal(P.validateProfileLink('website', 'https://a:b@evil.example/x').error, 'invalid_link');
|
|
799
|
+
assert.equal(P.validateProfileLink('website', 'https://localhost/x').error, 'invalid_link', 'dotless hosts refused');
|
|
800
|
+
assert.equal(P.validateProfileLink('x', 'https://x.com.evil.example/u').reason, 'wrong_host', 'suffix lookalike refused');
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
t('validateProfileLink: typed slots are host-pinned; website is free', () => {
|
|
804
|
+
assert.ok(P.validateProfileLink('x', 'https://x.com/jaxri').value);
|
|
805
|
+
assert.ok(P.validateProfileLink('x', 'https://www.twitter.com/jaxri').value, 'subdomains of a pinned host allowed');
|
|
806
|
+
assert.equal(P.validateProfileLink('x', 'https://evil.example/u').reason, 'wrong_host');
|
|
807
|
+
assert.ok(P.validateProfileLink('linkedin', 'https://www.linkedin.com/in/jaxri').value);
|
|
808
|
+
assert.ok(P.validateProfileLink('discord', 'https://discord.gg/bongos').value);
|
|
809
|
+
assert.equal(P.validateProfileLink('discord', 'https://discord.example/x').reason, 'wrong_host');
|
|
810
|
+
assert.ok(P.validateProfileLink('website', 'https://anything.example/at-all').value);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
t('validateProfileLinks: closed vocabulary, object only, empty → clear', () => {
|
|
814
|
+
assert.deepEqual(P.validateProfileLinks(null), { value: null });
|
|
815
|
+
assert.deepEqual(P.validateProfileLinks({}), { value: null });
|
|
816
|
+
assert.deepEqual(P.validateProfileLinks({ website: '' }), { value: null }, 'empty slot values drop');
|
|
817
|
+
assert.equal(P.validateProfileLinks([]).error, 'invalid_links');
|
|
818
|
+
assert.equal(P.validateProfileLinks('https://x.com').error, 'invalid_links');
|
|
819
|
+
assert.equal(P.validateProfileLinks({ github: 'https://github.com/x' }).reason, 'unknown_link_type');
|
|
820
|
+
const full = P.validateProfileLinks({ website: 'https://me.example', x: 'https://x.com/j', linkedin: 'https://linkedin.com/in/j', discord: 'https://discord.gg/j' });
|
|
821
|
+
assert.deepEqual(Object.keys(full.value).sort(), [...P.PROFILE_LINK_TYPES].sort(), 'max 4 by construction — one slot per type');
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
t('validateAccountDisplayName: trimmed, non-empty, ≤60 code points, single line', () => {
|
|
825
|
+
assert.equal(P.validateAccountDisplayName(' Percy J ').value, 'Percy J');
|
|
826
|
+
assert.equal(P.validateAccountDisplayName('').error, 'invalid_display_name');
|
|
827
|
+
assert.equal(P.validateAccountDisplayName('x'.repeat(P.DISPLAY_NAME_MAX_CHARS + 1)).error, 'display_name_too_long');
|
|
828
|
+
assert.equal(P.validateAccountDisplayName('a\nb').error, 'invalid_display_name');
|
|
829
|
+
assert.equal(P.validateAccountDisplayName(42).error, 'invalid_display_name');
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
await ta('upsertAccount SQL carries the ADR 0156 guard: a user-set name is never overwritten', async () => {
|
|
833
|
+
const pool = mockPool([{ rows: [{ handle: 'x' }], rowCount: 1 }]);
|
|
834
|
+
await P.upsertAccount({ githubId: '7', githubLogin: 'x' }, { pool });
|
|
835
|
+
const sql = pool.calls[0].text;
|
|
836
|
+
assert.match(sql, /display_name\s*=\s*CASE WHEN platform_identity_accounts\.display_name_source = 'user'/,
|
|
837
|
+
'the conflict branch guards display_name on display_name_source');
|
|
838
|
+
assert.match(sql, /github_login\s*=\s*EXCLUDED\.github_login/, 'github_login still re-syncs unconditionally');
|
|
839
|
+
assert.match(sql, /avatar_url\s*=\s*EXCLUDED\.avatar_url/, 'avatar_url still re-syncs unconditionally');
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
await ta('setAccountProfileFields: validates before any query; refuses empty + non-numeric ids', async () => {
|
|
843
|
+
const pool = mockPool();
|
|
844
|
+
assert.deepEqual(await P.setAccountProfileFields({ githubId: 'system:bfg', githubLogin: 'x', fields: { bio: 'b' } }, { pool }), { error: 'no_account' });
|
|
845
|
+
assert.deepEqual(await P.setAccountProfileFields({ githubId: '7', githubLogin: 'x', fields: {} }, { pool }), { error: 'no_fields' });
|
|
846
|
+
const bad = await P.setAccountProfileFields({ githubId: '7', githubLogin: 'x', fields: { links: { x: 'https://evil.example/u' } } }, { pool });
|
|
847
|
+
assert.equal(bad.error, 'invalid_link');
|
|
848
|
+
assert.equal(pool.calls.length, 0, 'nothing reached the DB');
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
await ta('setAccountProfileFields SQL: flag-guarded UPDATE that never touches consent or identity re-sync', async () => {
|
|
852
|
+
const pool = mockPool([{ rows: [{ github_id: '7', display_name: 'N', bio: 'b', links: null, handle: 'h' }], rowCount: 1 }]);
|
|
853
|
+
await P.setAccountProfileFields({ githubId: '7', githubLogin: 'x', fields: { display_name: 'N', bio: 'b' } }, { pool });
|
|
854
|
+
const { text, params } = pool.calls[0];
|
|
855
|
+
const updateBranch = text.slice(text.indexOf('DO UPDATE SET'));
|
|
856
|
+
assert.match(updateBranch, /display_name_source = CASE WHEN \$7::boolean THEN 'user'/, 'setting the name stamps source=user');
|
|
857
|
+
assert.doesNotMatch(updateBranch, /profile_state/, 'consent never moves here (activation is /sso/authorize alone)');
|
|
858
|
+
assert.doesNotMatch(updateBranch, /hide_stats/, 'the C2 flag never moves here');
|
|
859
|
+
assert.doesNotMatch(updateBranch, /github_login\s*=/, 'no identity re-sync on the profile write');
|
|
860
|
+
assert.doesNotMatch(updateBranch, /avatar_url\s*=/, 'no identity re-sync on the profile write');
|
|
861
|
+
assert.deepEqual(params.slice(6, 9), [true, true, false], 'flags: name+bio sent, links not');
|
|
862
|
+
});
|
|
863
|
+
|
|
775
864
|
console.log(`\nplatform-identity: ${passed} passed, ${failed} failed`);
|
|
776
865
|
if (failed > 0) process.exit(1);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.182",
|
|
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.182",
|
|
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.182",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -789,8 +789,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
789
789
|
// landed since 1.19.179 with no explicit bump. run 31972568511. (task 1002620)
|
|
790
790
|
// 1.19.181 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
791
791
|
// landed since 1.19.180 with no explicit bump. run 31972686846. (task 1002620)
|
|
792
|
+
// 1.19.182 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
793
|
+
// landed since 1.19.181 with no explicit bump. run 31972846686. (task 1002620)
|
|
792
794
|
// ---------------------------------------------------------------------------
|
|
793
|
-
const CORE_VERSION = '1.19.
|
|
795
|
+
const CORE_VERSION = '1.19.182'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
794
796
|
|
|
795
797
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
796
798
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -69,7 +69,7 @@ const SECRET_PROJECT = 'P05-PRIVATE-DO-NOT-LEAK';
|
|
|
69
69
|
const SECRET_PREV = 'p05-prev-sentinel';
|
|
70
70
|
const PUB_ORIGIN = 'https://p05-pub.example', PRIV_ORIGIN = 'https://p05-priv.example';
|
|
71
71
|
const CP_KEYS = ['totals', 'disciplines', 'last_active', 'projects', 'more_projects'];
|
|
72
|
-
const ACCOUNT_KEYS = ['handle', 'github_login', 'display_name', 'avatar_url', 'member_since'];
|
|
72
|
+
const ACCOUNT_KEYS = ['handle', 'github_login', 'display_name', 'avatar_url', 'member_since', 'bio', 'links'];
|
|
73
73
|
|
|
74
74
|
const q = (sql, params) => pool.query(sql, params);
|
|
75
75
|
async function cleanup() {
|
|
@@ -103,7 +103,7 @@ function get(port, path) {
|
|
|
103
103
|
|
|
104
104
|
const NEVER_IN_A_BODY = [
|
|
105
105
|
SECRET_CLIENT_PUB, SECRET_CLIENT_PRIV, SECRET_HASH, SECRET_PROJECT, SECRET_PREV,
|
|
106
|
-
'profile_state', 'hide_stats', 'github_id',
|
|
106
|
+
'profile_state', 'hide_stats', 'github_id', 'display_name_source',
|
|
107
107
|
];
|
|
108
108
|
function assertNoSentinels(text, where) {
|
|
109
109
|
for (const s of NEVER_IN_A_BODY) {
|
|
@@ -118,14 +118,17 @@ try {
|
|
|
118
118
|
await q(migrationSql('platform_identity_005_builder_activity.sql'));
|
|
119
119
|
await q(migrationSql('platform_identity_007_profile_consent.sql'));
|
|
120
120
|
await q(migrationSql('platform_identity_008_handle.sql'));
|
|
121
|
+
await q(migrationSql('platform_identity_009_profile_fields.sql'));
|
|
121
122
|
await cleanup();
|
|
122
123
|
|
|
124
|
+
// The active account carries the P02 fields + a user-set name source, so the
|
|
125
|
+
// 200 proves bio/links serialize while display_name_source never does.
|
|
123
126
|
await q(
|
|
124
127
|
`INSERT INTO platform_identity_accounts
|
|
125
|
-
(github_id, github_login, display_name, avatar_url, profile_state, hide_stats, handle, previous_handle)
|
|
126
|
-
VALUES ($1, 'p05-active-login', 'P05 Active', 'https://a.example/a.png', 'active', false, 'p05-active', $4),
|
|
127
|
-
($2, 'p05-hidden-login', 'P05 Hidden', NULL, 'active', true, 'p05-hidden', NULL),
|
|
128
|
-
($3, 'p05-prov-login', 'P05 Prov', NULL, 'provisional', false, 'p05-prov', NULL)`,
|
|
128
|
+
(github_id, github_login, display_name, avatar_url, profile_state, hide_stats, handle, previous_handle, bio, links, display_name_source)
|
|
129
|
+
VALUES ($1, 'p05-active-login', 'P05 Active', 'https://a.example/a.png', 'active', false, 'p05-active', $4, 'Builds things.', '{"website":"https://p05.example/"}', 'user'),
|
|
130
|
+
($2, 'p05-hidden-login', 'P05 Hidden', NULL, 'active', true, 'p05-hidden', NULL, NULL, NULL, 'github'),
|
|
131
|
+
($3, 'p05-prov-login', 'P05 Prov', NULL, 'provisional', false, 'p05-prov', NULL, NULL, NULL, 'github')`,
|
|
129
132
|
[ACTIVE, HIDDEN, PROV, SECRET_PREV],
|
|
130
133
|
);
|
|
131
134
|
await q(
|
|
@@ -165,6 +168,8 @@ try {
|
|
|
165
168
|
assert.equal(r.json.cross_project.projects[0].name, 'P05 Pub Proj');
|
|
166
169
|
assert.deepEqual(Object.keys(r.json.cross_project.projects[0]).sort(), ['name', 'origin', 'rank_held'].sort());
|
|
167
170
|
assert.equal(r.json.cross_project.more_projects, 1, 'the non-public project is counted, never named');
|
|
171
|
+
assert.equal(r.json.account.bio, 'Builds things.', 'bio serializes (P02)');
|
|
172
|
+
assert.deepEqual(r.json.account.links, { website: 'https://p05.example/' }, 'links serialize (P02)');
|
|
168
173
|
assertNoSentinels(r.text, 'the 200 body');
|
|
169
174
|
});
|
|
170
175
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// tests/profile_fields.mjs — P02, task 1002798 (goal 1000045; real DB).
|
|
2
|
+
//
|
|
3
|
+
// Proves migration 009 + the profile-fields machinery against real SQL (the
|
|
4
|
+
// handle_machinery.mjs precedent):
|
|
5
|
+
// 1. setAccountProfileFields on an UNSEEN builder creates a fail-closed
|
|
6
|
+
// provisional account, seeds the handle, stores bio + links
|
|
7
|
+
// 2. the ADR 0156 mirror: setting display_name stamps source='user', and a
|
|
8
|
+
// later upsertAccount re-sync (the check-in shape) keeps its hands off the
|
|
9
|
+
// name while github_login/avatar_url still re-sync; a never-renamed
|
|
10
|
+
// account still follows GitHub renames
|
|
11
|
+
// 3. the UPDATE branch touches ONLY the fields sent — bio alone leaves
|
|
12
|
+
// links/display_name alone, and never demotes profile_state or flips
|
|
13
|
+
// hide_stats
|
|
14
|
+
// 4. links REPLACE wholesale; null clears; a rejected link writes nothing
|
|
15
|
+
// 5. the public read (getPublicProfileByHandle) carries bio + links
|
|
16
|
+
//
|
|
17
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
18
|
+
// INTEGRATION set so the default DB-free lane never runs it.
|
|
19
|
+
//
|
|
20
|
+
// Run: DATABASE_URL=... node tests/profile_fields.mjs
|
|
21
|
+
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const { Pool } = require('pg');
|
|
29
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
30
|
+
|
|
31
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
33
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
34
|
+
|
|
35
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
36
|
+
try {
|
|
37
|
+
await pool.query('SELECT 1');
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.log(`profile_fields.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
40
|
+
await pool.end().catch(() => {});
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// High-numbered fixture ids so a shared DB can never collide with real data.
|
|
45
|
+
const FRESH = 9002798001, SYNCED = 9002798002;
|
|
46
|
+
const ALL_IDS = [FRESH, SYNCED];
|
|
47
|
+
|
|
48
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
49
|
+
async function cleanup() {
|
|
50
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let pass = 0;
|
|
54
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
55
|
+
const acctOf = (id) => pi.getAccount(id, { pool });
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await q(migrationSql('platform_identity_001_tables.sql'));
|
|
59
|
+
await q(migrationSql('platform_identity_007_profile_consent.sql'));
|
|
60
|
+
await q(migrationSql('platform_identity_008_handle.sql'));
|
|
61
|
+
await q(migrationSql('platform_identity_009_profile_fields.sql'));
|
|
62
|
+
// Idempotence is part of what this proves.
|
|
63
|
+
await q(migrationSql('platform_identity_009_profile_fields.sql'));
|
|
64
|
+
await cleanup();
|
|
65
|
+
|
|
66
|
+
// --- 1. INSERT branch: unseen builder fills their profile ahead of time ---
|
|
67
|
+
const first = await pi.setAccountProfileFields({
|
|
68
|
+
githubId: FRESH, githubLogin: 'P02Fresh', displayName: 'GH Fresh', avatarUrl: 'https://a.example/f.png',
|
|
69
|
+
fields: { bio: ' Builds bongos in the cloud. ', links: { website: 'https://fresh.example', x: 'https://x.com/fresh' } },
|
|
70
|
+
}, { pool });
|
|
71
|
+
ok(first.bio === 'Builds bongos in the cloud.', 'INSERT branch stores the trimmed bio');
|
|
72
|
+
ok(first.links.website === 'https://fresh.example/' && first.links.x === 'https://x.com/fresh', 'INSERT branch stores normalized links');
|
|
73
|
+
ok(first.handle === 'p02fresh', 'the create path seeds the handle, like every other account-creating upsert');
|
|
74
|
+
let a = await acctOf(FRESH);
|
|
75
|
+
ok(a.profile_state === 'provisional', 'a profile-fields write never grants consent — fail-closed provisional');
|
|
76
|
+
ok(a.display_name === 'GH Fresh' && a.display_name_source === 'github', 'no display_name sent → GitHub-derived name, source stays github');
|
|
77
|
+
|
|
78
|
+
// --- 2. the ADR 0156 mirror ----------------------------------------------
|
|
79
|
+
const named = await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'P02Fresh', fields: { display_name: 'Fresh, Renamed' } }, { pool });
|
|
80
|
+
ok(named.display_name === 'Fresh, Renamed', 'setting display_name writes it');
|
|
81
|
+
a = await acctOf(FRESH);
|
|
82
|
+
ok(a.display_name_source === 'user', 'and stamps display_name_source=user');
|
|
83
|
+
|
|
84
|
+
await pi.upsertAccount({ githubId: FRESH, githubLogin: 'p02fresh-renamed', displayName: 'GH Clobber', avatarUrl: 'https://a.example/new.png' }, { pool });
|
|
85
|
+
a = await acctOf(FRESH);
|
|
86
|
+
ok(a.display_name === 'Fresh, Renamed', 'a check-in re-sync does NOT clobber a user-set name');
|
|
87
|
+
ok(a.github_login === 'p02fresh-renamed' && a.avatar_url === 'https://a.example/new.png', 'github_login + avatar_url still re-sync unconditionally');
|
|
88
|
+
|
|
89
|
+
await pi.upsertAccount({ githubId: SYNCED, githubLogin: 'p02synced', displayName: 'GH Old' }, { pool });
|
|
90
|
+
await pi.upsertAccount({ githubId: SYNCED, githubLogin: 'p02synced', displayName: 'GH New' }, { pool });
|
|
91
|
+
a = await acctOf(SYNCED);
|
|
92
|
+
ok(a.display_name === 'GH New', 'a never-renamed account still follows GitHub renames');
|
|
93
|
+
|
|
94
|
+
// --- 3. the UPDATE branch touches only what was sent ----------------------
|
|
95
|
+
await q(`UPDATE platform_identity_accounts SET profile_state = 'active', hide_stats = true WHERE github_id = $1`, [FRESH]);
|
|
96
|
+
await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { bio: 'Just the bio.' } }, { pool });
|
|
97
|
+
a = await acctOf(FRESH);
|
|
98
|
+
ok(a.bio === 'Just the bio.', 'bio alone updates');
|
|
99
|
+
ok(a.links.website === 'https://fresh.example/', 'links untouched by a bio-only write');
|
|
100
|
+
ok(a.display_name === 'Fresh, Renamed', 'display_name untouched by a bio-only write');
|
|
101
|
+
ok(a.profile_state === 'active' && a.hide_stats === true, 'profile_state + hide_stats never move on a profile-fields write');
|
|
102
|
+
|
|
103
|
+
// --- 4. links replace wholesale; null clears; rejects write nothing -------
|
|
104
|
+
await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { links: { discord: 'https://discord.gg/bongos' } } }, { pool });
|
|
105
|
+
a = await acctOf(FRESH);
|
|
106
|
+
ok(a.links.discord === 'https://discord.gg/bongos' && !a.links.website && !a.links.x, 'a links write replaces the whole set');
|
|
107
|
+
|
|
108
|
+
const bad = await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { bio: 'never lands', links: { linkedin: 'https://evil.example/in' } } }, { pool });
|
|
109
|
+
ok(bad.error === 'invalid_link' && bad.reason === 'wrong_host', 'a host-pinned type refuses a foreign host');
|
|
110
|
+
a = await acctOf(FRESH);
|
|
111
|
+
ok(a.bio === 'Just the bio.' && a.links.discord === 'https://discord.gg/bongos', 'a rejected write writes NOTHING — not even the valid fields beside it');
|
|
112
|
+
|
|
113
|
+
const none = await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: {} }, { pool });
|
|
114
|
+
ok(none.error === 'no_fields', 'an empty fields object is refused, not a silent no-op');
|
|
115
|
+
|
|
116
|
+
await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { bio: null, links: null } }, { pool });
|
|
117
|
+
a = await acctOf(FRESH);
|
|
118
|
+
ok(a.bio === null && a.links === null, 'null clears bio and links');
|
|
119
|
+
|
|
120
|
+
// --- 5. the public read carries the fields --------------------------------
|
|
121
|
+
await pi.setAccountProfileFields({ githubId: FRESH, githubLogin: 'p02fresh-renamed', fields: { bio: 'Public bio.', links: { website: 'https://fresh.example' } } }, { pool });
|
|
122
|
+
const page = await pi.getPublicProfileByHandle('p02fresh', { pool });
|
|
123
|
+
ok(page.account.bio === 'Public bio.' && page.account.links.website === 'https://fresh.example/', 'getPublicProfileByHandle serves bio + links');
|
|
124
|
+
ok(!('display_name_source' in page.account), 'display_name_source never crosses the public whitelist');
|
|
125
|
+
|
|
126
|
+
console.log(`\nprofile_fields: ${pass} checks passed`);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error(`\nFAIL after ${pass} checks: ${e.message}`);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
} finally {
|
|
131
|
+
await cleanup();
|
|
132
|
+
await pool.end().catch(() => {});
|
|
133
|
+
}
|