@bongos/core 1.19.171 → 1.19.172
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 +31 -21
- 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 +79 -3
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/platform-identity/migrations/platform_identity_008_handle.sql +70 -0
- package/modules/platform-identity/platform-identity.js +140 -5
- package/modules/platform-identity/routes/profile.js +47 -1
- package/modules/platform-identity/tests/platform-identity.mjs +139 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/handle_machinery.mjs +115 -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.172",
|
|
6
|
+
"core_contract": "1.19.172",
|
|
7
|
+
"source_commit": "d488f4a69b30ec2fe81f95cc1ec652c25af8fc68",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-16T19:
|
|
9
|
+
"built_at": "2026-08-16T19:39:18.102Z",
|
|
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": 1271,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1597,
|
|
20
|
+
"tree_sha256": "c7aad1a023cefa6bce6c0403ed5d0bf9c1e5a28b4e38e14a85f1cfc697d5edb3",
|
|
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": "a13c27a7d671408579dedf417b7f28486bebc52b7a685ba3900cd4eca843957c"
|
|
351
351
|
},
|
|
352
352
|
{
|
|
353
353
|
"path": "clients/bongos-client/bongos-client.global.js",
|
|
354
354
|
"mode": "0000644",
|
|
355
|
-
"sha256": "
|
|
355
|
+
"sha256": "7b6abb75150b8fdc9fba1e0d765c049979da7cdd1d6a20eeeb6f3d5e3886ebc7"
|
|
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": "a4a864366450b849bafc096807f37cd16c8ed624470473aaf4907b24ab104778"
|
|
366
366
|
},
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "c032e9796a9feeee86929323992511d5ed1cb448975481f98f86cee1b3790d50"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
374
374
|
"mode": "0000644",
|
|
375
|
-
"sha256": "
|
|
375
|
+
"sha256": "cc177720f5f3b0971edac3f03464e3dd5930b48d12811cd9ab39a7b6ceaaeb9c"
|
|
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": "d1595faad24aafba420199b3115b11ee5387de7c97c7f4fe7fe4f138f43dd79a"
|
|
1351
1351
|
},
|
|
1352
1352
|
{
|
|
1353
1353
|
"path": "docs/api/openapi.json",
|
|
1354
1354
|
"mode": "0000644",
|
|
1355
|
-
"sha256": "
|
|
1355
|
+
"sha256": "1435287ef26983d7088555abc9f18df97ceb4a8676be091ff49076e54a4366ab"
|
|
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": "a4a864366450b849bafc096807f37cd16c8ed624470473aaf4907b24ab104778"
|
|
2666
2666
|
},
|
|
2667
2667
|
{
|
|
2668
2668
|
"path": "modules/dev-box/app/test/unit.mjs",
|
|
@@ -3789,6 +3789,11 @@
|
|
|
3789
3789
|
"mode": "0000644",
|
|
3790
3790
|
"sha256": "cce2f971f030aa628f9d7855f2e421595ea71cb314e90965206dbe970b7beded"
|
|
3791
3791
|
},
|
|
3792
|
+
{
|
|
3793
|
+
"path": "modules/platform-identity/migrations/platform_identity_008_handle.sql",
|
|
3794
|
+
"mode": "0000644",
|
|
3795
|
+
"sha256": "f78eb8f7b6ee1ea3ddd789d6019b9715ae193ff0cb7890aeb24d8473033a911a"
|
|
3796
|
+
},
|
|
3792
3797
|
{
|
|
3793
3798
|
"path": "modules/platform-identity/module.json",
|
|
3794
3799
|
"mode": "0000644",
|
|
@@ -3797,7 +3802,7 @@
|
|
|
3797
3802
|
{
|
|
3798
3803
|
"path": "modules/platform-identity/platform-identity.js",
|
|
3799
3804
|
"mode": "0000644",
|
|
3800
|
-
"sha256": "
|
|
3805
|
+
"sha256": "115be49e667716d760610f8a9b167eeea4e0e6b61c40c7b9a439703847b61dcd"
|
|
3801
3806
|
},
|
|
3802
3807
|
{
|
|
3803
3808
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -3817,7 +3822,7 @@
|
|
|
3817
3822
|
{
|
|
3818
3823
|
"path": "modules/platform-identity/routes/profile.js",
|
|
3819
3824
|
"mode": "0000644",
|
|
3820
|
-
"sha256": "
|
|
3825
|
+
"sha256": "29e7f6304b4215fd79c35662c48370a04a5a9ffa2c3c4ae3d1fabf277d407eb2"
|
|
3821
3826
|
},
|
|
3822
3827
|
{
|
|
3823
3828
|
"path": "modules/platform-identity/routes/projects.js",
|
|
@@ -3837,7 +3842,7 @@
|
|
|
3837
3842
|
{
|
|
3838
3843
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
3839
3844
|
"mode": "0000644",
|
|
3840
|
-
"sha256": "
|
|
3845
|
+
"sha256": "4b7f13effc33d5d16a5f87d0915ed31e107f138ef135822745774f90192ad327"
|
|
3841
3846
|
},
|
|
3842
3847
|
{
|
|
3843
3848
|
"path": "modules/platform-identity/ui/.gitkeep",
|
|
@@ -4212,12 +4217,12 @@
|
|
|
4212
4217
|
{
|
|
4213
4218
|
"path": "package-lock.json",
|
|
4214
4219
|
"mode": "0000644",
|
|
4215
|
-
"sha256": "
|
|
4220
|
+
"sha256": "3da96cec73d1af6c4f1470d29cc26187cc949eba5b6fb7291b89da3da4c7eae7"
|
|
4216
4221
|
},
|
|
4217
4222
|
{
|
|
4218
4223
|
"path": "package.json",
|
|
4219
4224
|
"mode": "0000644",
|
|
4220
|
-
"sha256": "
|
|
4225
|
+
"sha256": "dbf95e145339c9e8d52b1e532e452eb2d344c0c0f06a28f58c6e19366e256dd6"
|
|
4221
4226
|
},
|
|
4222
4227
|
{
|
|
4223
4228
|
"path": "public-docs/index.html",
|
|
@@ -5822,7 +5827,7 @@
|
|
|
5822
5827
|
{
|
|
5823
5828
|
"path": "src/module-api.js",
|
|
5824
5829
|
"mode": "0000644",
|
|
5825
|
-
"sha256": "
|
|
5830
|
+
"sha256": "bc1f9966a0040b5239664c1a5d993c302b1fa9c56d2080c97b168115a4da847a"
|
|
5826
5831
|
},
|
|
5827
5832
|
{
|
|
5828
5833
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6904,6 +6909,11 @@
|
|
|
6904
6909
|
"mode": "0000644",
|
|
6905
6910
|
"sha256": "82bf1cc68150c247c108308d360d6408511f31a7af11d5e7a4721e7ea282f2bb"
|
|
6906
6911
|
},
|
|
6912
|
+
{
|
|
6913
|
+
"path": "tests/handle_machinery.mjs",
|
|
6914
|
+
"mode": "0000644",
|
|
6915
|
+
"sha256": "755f49404e97c60c8c264f5f4c9d563a7c4ee4f51b43afe96fd10acce52ebe7b"
|
|
6916
|
+
},
|
|
6907
6917
|
{
|
|
6908
6918
|
"path": "tests/helpers.mjs",
|
|
6909
6919
|
"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
|
+
- 274 operations across 45 resource groups
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -423,6 +423,8 @@ function createClient(opts = {}) {
|
|
|
423
423
|
getMeEventSoundPrefs: (args) => request("GET", "/me/event-sound-prefs", { hasBody: false }, args),
|
|
424
424
|
// PATCH /me/event-sound-prefs — rank: any-builder — PATCH /me/event-sound-prefs
|
|
425
425
|
patchMeEventSoundPrefs: (args) => request("PATCH", "/me/event-sound-prefs", { hasBody: true }, args),
|
|
426
|
+
// PATCH /me/handle — rank: any-builder — PATCH /me/handle
|
|
427
|
+
patchMeHandle: (args) => request("PATCH", "/me/handle", { hasBody: true }, args),
|
|
426
428
|
// GET /me/interaction — rank: any-builder — GET /me/interaction
|
|
427
429
|
getMeInteraction: (args) => request("GET", "/me/interaction", { hasBody: false }, args),
|
|
428
430
|
// PATCH /me/interaction — rank: any-builder — PATCH /me/interaction
|
|
@@ -422,6 +422,8 @@ function createClient(opts = {}) {
|
|
|
422
422
|
getMeEventSoundPrefs: (args) => request("GET", "/me/event-sound-prefs", { hasBody: false }, args),
|
|
423
423
|
// PATCH /me/event-sound-prefs — rank: any-builder — PATCH /me/event-sound-prefs
|
|
424
424
|
patchMeEventSoundPrefs: (args) => request("PATCH", "/me/event-sound-prefs", { hasBody: true }, args),
|
|
425
|
+
// PATCH /me/handle — rank: any-builder — PATCH /me/handle
|
|
426
|
+
patchMeHandle: (args) => request("PATCH", "/me/handle", { hasBody: true }, args),
|
|
425
427
|
// GET /me/interaction — rank: any-builder — GET /me/interaction
|
|
426
428
|
getMeInteraction: (args) => request("GET", "/me/interaction", { hasBody: false }, args),
|
|
427
429
|
// PATCH /me/interaction — rank: any-builder — PATCH /me/interaction
|
|
@@ -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; cross_project: unknown }
|
|
54
|
+
export interface GetMeCrossProjectResponse { hide_stats: boolean; profile_state: unknown; handle: 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 }
|
|
@@ -131,6 +131,8 @@ export interface PatchMeDisciplinesRequest { preferred_disciplines: string[] }
|
|
|
131
131
|
export interface PatchMeDisciplinesResponse { builder: unknown; onboarding: unknown }
|
|
132
132
|
export interface PatchMeDisplayNameResponse { display_name: unknown; max_length: unknown }
|
|
133
133
|
export interface PatchMeEventSoundPrefsResponse { prefs: unknown; effective: unknown; effective_wav: unknown; events: unknown }
|
|
134
|
+
export interface PatchMeHandleRequest { handle: string }
|
|
135
|
+
export interface PatchMeHandleResponse { handle: unknown; previous_handle: unknown }
|
|
134
136
|
export interface PatchMeSkillPrefsResponse { prefs: unknown; effective: unknown; defaults: unknown; known_skills: unknown; allowed_models: unknown }
|
|
135
137
|
export interface PatchMeSoundPrefsResponse { prefs: unknown; effective: unknown; effective_map: unknown; volume: unknown; defaults: unknown; known_sounds: unknown }
|
|
136
138
|
export interface PatchProjectsIdFeaturedRequest { featured: boolean }
|
|
@@ -662,6 +664,8 @@ export interface BongosClient {
|
|
|
662
664
|
getMeEventSoundPrefs(args?: RequestArgs): Promise<GetMeEventSoundPrefsResponse>;
|
|
663
665
|
/** PATCH /me/event-sound-prefs — rank: any-builder */
|
|
664
666
|
patchMeEventSoundPrefs(args?: RequestArgs): Promise<PatchMeEventSoundPrefsResponse>;
|
|
667
|
+
/** PATCH /me/handle — rank: any-builder */
|
|
668
|
+
patchMeHandle(args: RequestArgs & { body: PatchMeHandleRequest }): Promise<PatchMeHandleResponse>;
|
|
665
669
|
/** GET /me/interaction — rank: any-builder */
|
|
666
670
|
getMeInteraction(args?: RequestArgs): Promise<ApiResponse>;
|
|
667
671
|
/** PATCH /me/interaction — rank: any-builder */
|
|
@@ -419,6 +419,8 @@ export function createClient(opts = {}) {
|
|
|
419
419
|
getMeEventSoundPrefs: (args) => request("GET", "/me/event-sound-prefs", { hasBody: false }, args),
|
|
420
420
|
// PATCH /me/event-sound-prefs — rank: any-builder — PATCH /me/event-sound-prefs
|
|
421
421
|
patchMeEventSoundPrefs: (args) => request("PATCH", "/me/event-sound-prefs", { hasBody: true }, args),
|
|
422
|
+
// PATCH /me/handle — rank: any-builder — PATCH /me/handle
|
|
423
|
+
patchMeHandle: (args) => request("PATCH", "/me/handle", { hasBody: true }, args),
|
|
422
424
|
// GET /me/interaction — rank: any-builder — GET /me/interaction
|
|
423
425
|
getMeInteraction: (args) => request("GET", "/me/interaction", { hasBody: false }, args),
|
|
424
426
|
// PATCH /me/interaction — rank: any-builder — PATCH /me/interaction
|
package/docs/api/openapi.json
CHANGED
|
@@ -6575,6 +6575,55 @@
|
|
|
6575
6575
|
]
|
|
6576
6576
|
}
|
|
6577
6577
|
},
|
|
6578
|
+
"/me/handle": {
|
|
6579
|
+
"patch": {
|
|
6580
|
+
"operationId": "patch_me_handle",
|
|
6581
|
+
"tags": [
|
|
6582
|
+
"me"
|
|
6583
|
+
],
|
|
6584
|
+
"summary": "PATCH /me/handle",
|
|
6585
|
+
"description": "rename the caller's OWN handle (own data, keyed on the authenticated github_id). Rules live in setAccountHandle: canonical form, reserved words refused, one rename per window, previous handle kept for the 301 window. Upsert-first (the setAccountHideStats precedent) so a hub builder who never federated can still claim a handle — never demotes consent.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
6586
|
+
"x-rank": "any-builder",
|
|
6587
|
+
"x-source": "modules/platform-identity/routes/profile.js",
|
|
6588
|
+
"requestBody": {
|
|
6589
|
+
"required": true,
|
|
6590
|
+
"content": {
|
|
6591
|
+
"application/json": {
|
|
6592
|
+
"schema": {
|
|
6593
|
+
"$ref": "#/components/schemas/PatchMeHandleRequest"
|
|
6594
|
+
}
|
|
6595
|
+
}
|
|
6596
|
+
},
|
|
6597
|
+
"x-validated": true
|
|
6598
|
+
},
|
|
6599
|
+
"responses": {
|
|
6600
|
+
"200": {
|
|
6601
|
+
"description": "Success.",
|
|
6602
|
+
"content": {
|
|
6603
|
+
"application/json": {
|
|
6604
|
+
"schema": {
|
|
6605
|
+
"$ref": "#/components/schemas/PatchMeHandleResponse"
|
|
6606
|
+
}
|
|
6607
|
+
}
|
|
6608
|
+
}
|
|
6609
|
+
},
|
|
6610
|
+
"400": {
|
|
6611
|
+
"$ref": "#/components/responses/ValidationFailed"
|
|
6612
|
+
},
|
|
6613
|
+
"401": {
|
|
6614
|
+
"$ref": "#/components/responses/Unauthorized"
|
|
6615
|
+
},
|
|
6616
|
+
"403": {
|
|
6617
|
+
"$ref": "#/components/responses/Forbidden"
|
|
6618
|
+
}
|
|
6619
|
+
},
|
|
6620
|
+
"security": [
|
|
6621
|
+
{
|
|
6622
|
+
"builderSession": []
|
|
6623
|
+
}
|
|
6624
|
+
]
|
|
6625
|
+
}
|
|
6626
|
+
},
|
|
6578
6627
|
"/me/interaction": {
|
|
6579
6628
|
"get": {
|
|
6580
6629
|
"operationId": "get_me_interaction",
|
|
@@ -12942,11 +12991,13 @@
|
|
|
12942
12991
|
"type": "boolean"
|
|
12943
12992
|
},
|
|
12944
12993
|
"profile_state": {},
|
|
12994
|
+
"handle": {},
|
|
12945
12995
|
"cross_project": {}
|
|
12946
12996
|
},
|
|
12947
12997
|
"required": [
|
|
12948
12998
|
"hide_stats",
|
|
12949
12999
|
"profile_state",
|
|
13000
|
+
"handle",
|
|
12950
13001
|
"cross_project"
|
|
12951
13002
|
]
|
|
12952
13003
|
},
|
|
@@ -13984,6 +14035,31 @@
|
|
|
13984
14035
|
"events"
|
|
13985
14036
|
]
|
|
13986
14037
|
},
|
|
14038
|
+
"PatchMeHandleRequest": {
|
|
14039
|
+
"type": "object",
|
|
14040
|
+
"properties": {
|
|
14041
|
+
"handle": {
|
|
14042
|
+
"type": "string",
|
|
14043
|
+
"minLength": 1,
|
|
14044
|
+
"maxLength": 39
|
|
14045
|
+
}
|
|
14046
|
+
},
|
|
14047
|
+
"required": [
|
|
14048
|
+
"handle"
|
|
14049
|
+
],
|
|
14050
|
+
"additionalProperties": false
|
|
14051
|
+
},
|
|
14052
|
+
"PatchMeHandleResponse": {
|
|
14053
|
+
"type": "object",
|
|
14054
|
+
"properties": {
|
|
14055
|
+
"handle": {},
|
|
14056
|
+
"previous_handle": {}
|
|
14057
|
+
},
|
|
14058
|
+
"required": [
|
|
14059
|
+
"handle",
|
|
14060
|
+
"previous_handle"
|
|
14061
|
+
]
|
|
14062
|
+
},
|
|
13987
14063
|
"PatchMeSkillPrefsResponse": {
|
|
13988
14064
|
"type": "object",
|
|
13989
14065
|
"properties": {
|
|
@@ -17222,9 +17298,9 @@
|
|
|
17222
17298
|
"description": "A required dependency/feature is not configured or is temporarily down."
|
|
17223
17299
|
}
|
|
17224
17300
|
},
|
|
17225
|
-
"x-endpoint-count":
|
|
17226
|
-
"x-schema-count":
|
|
17301
|
+
"x-endpoint-count": 274,
|
|
17302
|
+
"x-schema-count": 308,
|
|
17227
17303
|
"x-undocumented-bodies": 8,
|
|
17228
|
-
"x-response-schemas":
|
|
17304
|
+
"x-response-schemas": 208,
|
|
17229
17305
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
17230
17306
|
}
|
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. 274 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,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` (24)
|
|
286
286
|
|
|
287
287
|
| Method | Path | Rank | Body | Description |
|
|
288
288
|
|---|---|---|---|---|
|
|
@@ -296,6 +296,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
296
296
|
| PATCH | `/api/bongos/me/display-name` | `any-builder` | _undocumented_ | PATCH the display name. |
|
|
297
297
|
| GET | `/api/bongos/me/event-sound-prefs` | `any-builder` | — | Per-builder per-event sound variant selection (task 582, migration 164). |
|
|
298
298
|
| PATCH | `/api/bongos/me/event-sound-prefs` | `any-builder` | _undocumented_ | PATCH a partial set of event sound overrides. |
|
|
299
|
+
| PATCH | `/api/bongos/me/handle` | `any-builder` | `handle` | rename the caller's OWN handle (own data, keyed on the authenticated github_id). |
|
|
299
300
|
| GET | `/api/bongos/me/interaction` | `any-builder` | — | Per-builder INTERACTION PROFILE (task 2010, shares the behavior_prefs jsonb / migration 119). |
|
|
300
301
|
| PATCH | `/api/bongos/me/interaction` | `any-builder` | _undocumented_ | PATCH the interaction profile. |
|
|
301
302
|
| GET | `/api/bongos/me/model-allocation` | `any-builder` | — | |
|
|
@@ -422,6 +422,8 @@ function createClient(opts = {}) {
|
|
|
422
422
|
getMeEventSoundPrefs: (args) => request("GET", "/me/event-sound-prefs", { hasBody: false }, args),
|
|
423
423
|
// PATCH /me/event-sound-prefs — rank: any-builder — PATCH /me/event-sound-prefs
|
|
424
424
|
patchMeEventSoundPrefs: (args) => request("PATCH", "/me/event-sound-prefs", { hasBody: true }, args),
|
|
425
|
+
// PATCH /me/handle — rank: any-builder — PATCH /me/handle
|
|
426
|
+
patchMeHandle: (args) => request("PATCH", "/me/handle", { hasBody: true }, args),
|
|
425
427
|
// GET /me/interaction — rank: any-builder — GET /me/interaction
|
|
426
428
|
getMeInteraction: (args) => request("GET", "/me/interaction", { hasBody: false }, args),
|
|
427
429
|
// PATCH /me/interaction — rank: any-builder — PATCH /me/interaction
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- platform_identity_008_handle.sql — the builder handle (P01, task 1002797 /
|
|
2
|
+
-- goal 1000045; profile-page spec Decision 3, ratified ADR 0171 D8).
|
|
3
|
+
--
|
|
4
|
+
-- The canonical profile URL must not hang on github_login: a login is mutable,
|
|
5
|
+
-- so every GitHub rename would silently 404 the builder's shared links. The
|
|
6
|
+
-- handle is presentation only — github_id stays the join key everywhere.
|
|
7
|
+
--
|
|
8
|
+
-- handle — unique case-insensitively; seeded from github_login.
|
|
9
|
+
-- previous_handle — the single-slot 301 window: kept on rename until the
|
|
10
|
+
-- next rename. R05's resolver 301s it to the current
|
|
11
|
+
-- handle (an exact current-handle match always wins).
|
|
12
|
+
-- handle_changed_at — set on RENAME only, never on seed; the low rate limit
|
|
13
|
+
-- reads it (one rename per window — setAccountHandle).
|
|
14
|
+
--
|
|
15
|
+
-- Seeding walks accounts oldest-first with exactly the go-forward JS algorithm
|
|
16
|
+
-- (ensureHandle in platform-identity.js): lowercase the login, strip anything
|
|
17
|
+
-- outside [a-z0-9-], trim hyphens; empty → builder-<github_id>; reserved or
|
|
18
|
+
-- taken → numeric suffix -2, -3, … . Sequential plpgsql on purpose: a set-based
|
|
19
|
+
-- ROW_NUMBER pass can manufacture collisions against its own suffixes (a
|
|
20
|
+
-- literal foo-2 login vs foo's second seed), and a failed prod migration costs
|
|
21
|
+
-- more than a slow loop over a small table. The reserved list below mirrors
|
|
22
|
+
-- RESERVED_HANDLES in platform-identity.js — keep them in lockstep.
|
|
23
|
+
--
|
|
24
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent: the
|
|
25
|
+
-- handle IS NULL guard makes a re-run a no-op.
|
|
26
|
+
|
|
27
|
+
BEGIN;
|
|
28
|
+
|
|
29
|
+
ALTER TABLE platform_identity_accounts
|
|
30
|
+
ADD COLUMN IF NOT EXISTS handle text,
|
|
31
|
+
ADD COLUMN IF NOT EXISTS previous_handle text,
|
|
32
|
+
ADD COLUMN IF NOT EXISTS handle_changed_at timestamptz;
|
|
33
|
+
|
|
34
|
+
CREATE UNIQUE INDEX IF NOT EXISTS platform_identity_accounts_handle_uniq
|
|
35
|
+
ON platform_identity_accounts (lower(handle)) WHERE handle IS NOT NULL;
|
|
36
|
+
|
|
37
|
+
-- The 301-window lookup path (R05 resolves previous handles too).
|
|
38
|
+
CREATE INDEX IF NOT EXISTS platform_identity_accounts_prev_handle_idx
|
|
39
|
+
ON platform_identity_accounts (lower(previous_handle)) WHERE previous_handle IS NOT NULL;
|
|
40
|
+
|
|
41
|
+
DO $$
|
|
42
|
+
DECLARE
|
|
43
|
+
r RECORD;
|
|
44
|
+
base text;
|
|
45
|
+
cand text;
|
|
46
|
+
n int;
|
|
47
|
+
BEGIN
|
|
48
|
+
FOR r IN
|
|
49
|
+
SELECT github_id, github_login
|
|
50
|
+
FROM platform_identity_accounts
|
|
51
|
+
WHERE handle IS NULL
|
|
52
|
+
ORDER BY created_at, id
|
|
53
|
+
LOOP
|
|
54
|
+
base := trim(both '-' from regexp_replace(lower(coalesce(r.github_login, '')), '[^a-z0-9-]', '', 'g'));
|
|
55
|
+
IF base = '' THEN
|
|
56
|
+
base := 'builder-' || r.github_id;
|
|
57
|
+
END IF;
|
|
58
|
+
cand := base;
|
|
59
|
+
n := 1;
|
|
60
|
+
WHILE cand IN ('me', 'new', 'edit', 'settings', 'admin', 'api', 'u')
|
|
61
|
+
OR EXISTS (SELECT 1 FROM platform_identity_accounts WHERE lower(handle) = lower(cand))
|
|
62
|
+
LOOP
|
|
63
|
+
n := n + 1;
|
|
64
|
+
cand := base || '-' || n;
|
|
65
|
+
END LOOP;
|
|
66
|
+
UPDATE platform_identity_accounts SET handle = cand WHERE github_id = r.github_id;
|
|
67
|
+
END LOOP;
|
|
68
|
+
END $$;
|
|
69
|
+
|
|
70
|
+
COMMIT;
|
|
@@ -117,10 +117,13 @@ async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, ac
|
|
|
117
117
|
avatar_url = EXCLUDED.avatar_url,
|
|
118
118
|
profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END,
|
|
119
119
|
updated_at = now()
|
|
120
|
-
RETURNING id, github_id, github_login, display_name, avatar_url, profile_state`,
|
|
120
|
+
RETURNING id, github_id, github_login, display_name, avatar_url, profile_state, handle`,
|
|
121
121
|
[githubId, githubLogin, displayName ?? null, avatarUrl ?? null, !!activate],
|
|
122
122
|
);
|
|
123
|
-
|
|
123
|
+
const row = rows[0];
|
|
124
|
+
// Seed the handle on first sight (spec Decision 3); a no-op once set.
|
|
125
|
+
if (row && !row.handle) row.handle = await ensureHandle({ githubId, githubLogin }, { pool });
|
|
126
|
+
return row;
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
// Resolve the platform account for a github_login (for invitations by handle). A
|
|
@@ -138,7 +141,8 @@ async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
|
|
|
138
141
|
|
|
139
142
|
async function getAccount(githubId, { pool = defaultPool } = {}) {
|
|
140
143
|
const { rows } = await pool.query(
|
|
141
|
-
`SELECT id, github_id, github_login, display_name, avatar_url, profile_state, hide_stats,
|
|
144
|
+
`SELECT id, github_id, github_login, display_name, avatar_url, profile_state, hide_stats,
|
|
145
|
+
handle, previous_handle, handle_changed_at, created_at, updated_at
|
|
142
146
|
FROM platform_identity_accounts WHERE github_id = $1`,
|
|
143
147
|
[githubId],
|
|
144
148
|
);
|
|
@@ -615,10 +619,133 @@ async function setAccountHideStats({ githubId, githubLogin, displayName, avatarU
|
|
|
615
619
|
ON CONFLICT (github_id) DO UPDATE SET
|
|
616
620
|
hide_stats = EXCLUDED.hide_stats,
|
|
617
621
|
updated_at = now()
|
|
618
|
-
RETURNING github_id, github_login, profile_state, hide_stats`,
|
|
622
|
+
RETURNING github_id, github_login, profile_state, hide_stats, handle`,
|
|
619
623
|
[githubId, githubLogin, displayName ?? null, avatarUrl ?? null, !!hide],
|
|
620
624
|
);
|
|
621
|
-
|
|
625
|
+
const row = rows[0];
|
|
626
|
+
// This upsert can CREATE the account row (hide-before-federating) — seed the
|
|
627
|
+
// handle then, same as upsertAccount.
|
|
628
|
+
if (row && !row.handle) row.handle = await ensureHandle({ githubId, githubLogin }, { pool });
|
|
629
|
+
return row;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// -------------------------------------------------------------------------
|
|
633
|
+
// Builder handle (P01, task 1002797 — profile-page spec Decision 3, ADR 0171 D8)
|
|
634
|
+
// -------------------------------------------------------------------------
|
|
635
|
+
//
|
|
636
|
+
// The canonical profile URL key (cloudbongos.com/u/<handle>). github_id stays
|
|
637
|
+
// the join key everywhere — the handle is presentation only, unique
|
|
638
|
+
// case-insensitively, seeded from github_login (which is mutable and therefore
|
|
639
|
+
// unfit to hang a URL on).
|
|
640
|
+
|
|
641
|
+
// Route words the /u namespace uses (or will) — a handle may never shadow them.
|
|
642
|
+
// Mirrored by the seeding list in migrations/platform_identity_008_handle.sql;
|
|
643
|
+
// keep the two in lockstep.
|
|
644
|
+
const RESERVED_HANDLES = ['me', 'new', 'edit', 'settings', 'admin', 'api', 'u'];
|
|
645
|
+
|
|
646
|
+
// One rename per window — low by design: a handle is a shared URL, and churning
|
|
647
|
+
// it breaks links faster than the single-slot 301 window can cover.
|
|
648
|
+
const HANDLE_RENAME_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
649
|
+
|
|
650
|
+
// Canonical form: lowercase, 1–39 chars of [a-z0-9-], no leading/trailing
|
|
651
|
+
// hyphen (github_login's charset, lowercased). Returns the normalized handle,
|
|
652
|
+
// or null when the input can't be one.
|
|
653
|
+
function normalizeHandle(raw) {
|
|
654
|
+
if (typeof raw !== 'string') return null;
|
|
655
|
+
const h = raw.trim().toLowerCase();
|
|
656
|
+
return /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(h) ? h : null;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function isReservedHandle(handle) {
|
|
660
|
+
return RESERVED_HANDLES.includes(handle);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// The seed base for an account: its login squeezed into the handle charset; a
|
|
664
|
+
// login that contributes nothing falls back to builder-<github_id>.
|
|
665
|
+
function handleSeedBase({ githubId, githubLogin }) {
|
|
666
|
+
const squeezed = String(githubLogin || '')
|
|
667
|
+
.toLowerCase()
|
|
668
|
+
.replace(/[^a-z0-9-]/g, '')
|
|
669
|
+
.replace(/^-+|-+$/g, '');
|
|
670
|
+
return normalizeHandle(squeezed) || `builder-${githubId}`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Seed the handle for an account that has none (new accounts at upsert; legacy
|
|
674
|
+
// rows were seeded by migration 008 with the same algorithm). First free of
|
|
675
|
+
// base, base-2, base-3, … wins; reserved words are never handed out bare. The
|
|
676
|
+
// unique index is the arbiter — losing a race just moves to the next suffix,
|
|
677
|
+
// and a concurrent seeder winning the row (handle no longer NULL) ends the loop.
|
|
678
|
+
async function ensureHandle({ githubId, githubLogin }, { pool = defaultPool } = {}) {
|
|
679
|
+
if (!isNumericGithubId(githubId)) return null;
|
|
680
|
+
const base = handleSeedBase({ githubId, githubLogin });
|
|
681
|
+
let cand = isReservedHandle(base) ? `${base}-2` : base;
|
|
682
|
+
let next = isReservedHandle(base) ? 3 : 2;
|
|
683
|
+
for (let tries = 0; tries < 50; tries++) {
|
|
684
|
+
try {
|
|
685
|
+
const { rows, rowCount } = await pool.query(
|
|
686
|
+
`UPDATE platform_identity_accounts
|
|
687
|
+
SET handle = $2, updated_at = now()
|
|
688
|
+
WHERE github_id = $1 AND handle IS NULL
|
|
689
|
+
RETURNING handle`,
|
|
690
|
+
[githubId, cand],
|
|
691
|
+
);
|
|
692
|
+
if (rowCount === 0) break; // seeded concurrently (or no row) — read below
|
|
693
|
+
return rows[0].handle;
|
|
694
|
+
} catch (err) {
|
|
695
|
+
if (err && err.code === '23505') { cand = `${base}-${next++}`; continue; }
|
|
696
|
+
throw err;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const acct = await getAccount(githubId, { pool });
|
|
700
|
+
return acct ? acct.handle : null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// The rename path (own-scoped; callers key by the AUTHENTICATED github_id).
|
|
704
|
+
// Canonical form only, reserved words refused, one rename per
|
|
705
|
+
// HANDLE_RENAME_WINDOW_MS, uniqueness arbitrated by the index (23505 → taken).
|
|
706
|
+
// On success the old handle drops into previous_handle — the single-slot 301
|
|
707
|
+
// window R05 resolves — and handle_changed_at starts the rate-limit clock
|
|
708
|
+
// (seeding never sets it, so the first rename is always free). Renaming to the
|
|
709
|
+
// current handle is a no-op that burns nothing. Returns { handle,
|
|
710
|
+
// previous_handle } or { error: 'invalid_handle' | 'reserved_handle' |
|
|
711
|
+
// 'no_account' | 'rate_limited' (+ retry_at) | 'handle_taken' }.
|
|
712
|
+
async function setAccountHandle({ githubId, handle }, { pool = defaultPool } = {}) {
|
|
713
|
+
if (!isNumericGithubId(githubId)) return { error: 'no_account' };
|
|
714
|
+
const norm = normalizeHandle(handle);
|
|
715
|
+
if (!norm) return { error: 'invalid_handle' };
|
|
716
|
+
if (isReservedHandle(norm)) return { error: 'reserved_handle' };
|
|
717
|
+
const acct = await getAccount(githubId, { pool });
|
|
718
|
+
if (!acct) return { error: 'no_account' };
|
|
719
|
+
if (acct.handle === norm) return { handle: acct.handle, previous_handle: acct.previous_handle || null };
|
|
720
|
+
const rateLimited = (a) => ({
|
|
721
|
+
error: 'rate_limited',
|
|
722
|
+
retry_at: new Date(new Date(a.handle_changed_at).getTime() + HANDLE_RENAME_WINDOW_MS).toISOString(),
|
|
723
|
+
});
|
|
724
|
+
const changedAt = acct.handle_changed_at ? new Date(acct.handle_changed_at).getTime() : 0;
|
|
725
|
+
if (changedAt && Date.now() - changedAt < HANDLE_RENAME_WINDOW_MS) return rateLimited(acct);
|
|
726
|
+
try {
|
|
727
|
+
// The window is re-checked IN the UPDATE so the read above can't race a
|
|
728
|
+
// concurrent rename past the rate limit — the DB arbitrates, exactly as the
|
|
729
|
+
// unique index arbitrates collisions.
|
|
730
|
+
const { rows, rowCount } = await pool.query(
|
|
731
|
+
`UPDATE platform_identity_accounts
|
|
732
|
+
SET previous_handle = handle, handle = $2, handle_changed_at = now(), updated_at = now()
|
|
733
|
+
WHERE github_id = $1
|
|
734
|
+
AND (handle_changed_at IS NULL
|
|
735
|
+
OR handle_changed_at <= now() - ($3::bigint * interval '1 millisecond'))
|
|
736
|
+
RETURNING handle, previous_handle`,
|
|
737
|
+
[githubId, norm, HANDLE_RENAME_WINDOW_MS],
|
|
738
|
+
);
|
|
739
|
+
if (rowCount) return rows[0];
|
|
740
|
+
// 0 rows: the account vanished, or a concurrent rename consumed the window
|
|
741
|
+
// between our read and this write — re-read to answer honestly.
|
|
742
|
+
const fresh = await getAccount(githubId, { pool });
|
|
743
|
+
if (!fresh) return { error: 'no_account' };
|
|
744
|
+
return fresh.handle_changed_at ? rateLimited(fresh) : { error: 'no_account' };
|
|
745
|
+
} catch (err) {
|
|
746
|
+
if (err && err.code === '23505') return { error: 'handle_taken' };
|
|
747
|
+
throw err;
|
|
748
|
+
}
|
|
622
749
|
}
|
|
623
750
|
|
|
624
751
|
// -------------------------------------------------------------------------
|
|
@@ -721,6 +848,14 @@ module.exports = {
|
|
|
721
848
|
getPublicProfileExtras,
|
|
722
849
|
getOwnProfileExtras,
|
|
723
850
|
setAccountHideStats,
|
|
851
|
+
// builder handle (P01)
|
|
852
|
+
RESERVED_HANDLES,
|
|
853
|
+
HANDLE_RENAME_WINDOW_MS,
|
|
854
|
+
normalizeHandle,
|
|
855
|
+
isReservedHandle,
|
|
856
|
+
handleSeedBase,
|
|
857
|
+
ensureHandle,
|
|
858
|
+
setAccountHandle,
|
|
724
859
|
// codes
|
|
725
860
|
mintAuthCode,
|
|
726
861
|
redeemAuthCode,
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
// /scouting): enforcement lives in the module's read
|
|
14
14
|
// functions (D2) and the hub profile response is
|
|
15
15
|
// no-store (D5), so there is no cache to outwait.
|
|
16
|
+
// PATCH /me/handle — rename the caller's own handle (P01, task 1002797;
|
|
17
|
+
// spec Decision 3). Low rate limit, previous handle
|
|
18
|
+
// kept for the 301 window.
|
|
16
19
|
//
|
|
17
20
|
// Reaches core only through the doorway (ADR 0083).
|
|
18
21
|
'use strict';
|
|
@@ -32,7 +35,7 @@ module.exports = function profileRoutes() {
|
|
|
32
35
|
// Core builders include system:* principals whose github_id is not numeric;
|
|
33
36
|
// they have no platform identity — answer the empty shape, not a 500.
|
|
34
37
|
if (!pi.isNumericGithubId(req.builder.github_id)) {
|
|
35
|
-
return res.json({ hide_stats: false, profile_state: null, cross_project: null });
|
|
38
|
+
return res.json({ hide_stats: false, profile_state: null, handle: null, cross_project: null });
|
|
36
39
|
}
|
|
37
40
|
const [acct, extras] = await Promise.all([
|
|
38
41
|
pi.getAccount(req.builder.github_id, { pool }),
|
|
@@ -41,6 +44,7 @@ module.exports = function profileRoutes() {
|
|
|
41
44
|
res.json({
|
|
42
45
|
hide_stats: !!(acct && acct.hide_stats),
|
|
43
46
|
profile_state: acct ? acct.profile_state : null,
|
|
47
|
+
handle: (acct && acct.handle) || null,
|
|
44
48
|
cross_project: extras,
|
|
45
49
|
});
|
|
46
50
|
} catch (err) {
|
|
@@ -73,5 +77,47 @@ module.exports = function profileRoutes() {
|
|
|
73
77
|
}
|
|
74
78
|
});
|
|
75
79
|
|
|
80
|
+
// rank: any-builder — rename the caller's OWN handle (own data, keyed on the
|
|
81
|
+
// authenticated github_id). Rules live in setAccountHandle: canonical form,
|
|
82
|
+
// reserved words refused, one rename per window, previous handle kept for the
|
|
83
|
+
// 301 window. Upsert-first (the setAccountHideStats precedent) so a hub
|
|
84
|
+
// builder who never federated can still claim a handle — never demotes consent.
|
|
85
|
+
router.patch('/me/handle', requireBuilder, async (req, res) => {
|
|
86
|
+
if (validateOrRespond(req, res, {
|
|
87
|
+
handle: { required: true, type: 'string', minLength: 1, maxLength: 39 },
|
|
88
|
+
})) return;
|
|
89
|
+
try {
|
|
90
|
+
if (!pi.isNumericGithubId(req.builder.github_id)) {
|
|
91
|
+
return res.fail('no_platform_identity', 400);
|
|
92
|
+
}
|
|
93
|
+
await pi.upsertAccount({
|
|
94
|
+
githubId: req.builder.github_id,
|
|
95
|
+
githubLogin: req.builder.github_login,
|
|
96
|
+
displayName: req.builder.display_name,
|
|
97
|
+
avatarUrl: req.builder.avatar_url,
|
|
98
|
+
}, { pool });
|
|
99
|
+
const out = await pi.setAccountHandle({ githubId: req.builder.github_id, handle: req.body.handle }, { pool });
|
|
100
|
+
if (out.error === 'invalid_handle') {
|
|
101
|
+
return res.fail('invalid_handle', { status: 400, message: 'a handle is 1–39 characters of a–z, 0–9, and hyphens (not at the ends)' });
|
|
102
|
+
}
|
|
103
|
+
if (out.error === 'reserved_handle') {
|
|
104
|
+
return res.fail('reserved_handle', { status: 400, message: 'that handle is a reserved word' });
|
|
105
|
+
}
|
|
106
|
+
if (out.error === 'handle_taken') {
|
|
107
|
+
return res.fail('handle_taken', { status: 409, message: 'that handle is already in use' });
|
|
108
|
+
}
|
|
109
|
+
if (out.error === 'rate_limited') {
|
|
110
|
+
return res.fail('handle_rate_limited', { status: 429, message: `handles change at most once per ${Math.round(pi.HANDLE_RENAME_WINDOW_MS / 86400000)} days — try again after ${out.retry_at}` });
|
|
111
|
+
}
|
|
112
|
+
if (out.error) {
|
|
113
|
+
return res.fail('handle_write_failed', { status: 500, message: 'internal error' });
|
|
114
|
+
}
|
|
115
|
+
res.json({ handle: out.handle, previous_handle: out.previous_handle || null });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('[platform-identity] PATCH /me/handle', err);
|
|
118
|
+
res.fail('handle_write_failed', { status: 500, message: 'internal error' });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
76
122
|
return router;
|
|
77
123
|
};
|
|
@@ -141,7 +141,9 @@ await ta('upsertAccount upserts on github_id and returns the row (default: NOT a
|
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
await ta('upsertAccount consent upgrade is one-way (ADR 0171 D3): activate flips to active, default preserves the stored state', async () => {
|
|
144
|
-
|
|
144
|
+
// handle present on the returned rows → the P01 seeding hook stays quiet, so
|
|
145
|
+
// the two upserts are the only two calls.
|
|
146
|
+
const pool = mockPool([{ rows: [{ id: '1', handle: 'octocat' }] }, { rows: [{ id: '1', handle: 'octocat' }] }]);
|
|
145
147
|
await P.upsertAccount({ githubId: 42, githubLogin: 'octocat', activate: true }, { pool });
|
|
146
148
|
await P.upsertAccount({ githubId: 42, githubLogin: 'octocat' }, { pool });
|
|
147
149
|
const sql = pool.calls[0].text;
|
|
@@ -581,5 +583,141 @@ await ta('sweepExpiredCodes deletes expired + long-consumed rows', async () => {
|
|
|
581
583
|
assert.match(pool.calls[0].text, /expires_at < now\(\)/);
|
|
582
584
|
});
|
|
583
585
|
|
|
586
|
+
console.log('\nbuilder handle (P01, task 1002797):');
|
|
587
|
+
|
|
588
|
+
// A pool that throws queued errors (code-carrying) or returns queued responses —
|
|
589
|
+
// for the unique-violation retry paths mockPool can't express.
|
|
590
|
+
function throwingPool(script) {
|
|
591
|
+
const calls = [];
|
|
592
|
+
let i = 0;
|
|
593
|
+
return {
|
|
594
|
+
calls,
|
|
595
|
+
query: async (text, params) => {
|
|
596
|
+
calls.push({ text: String(text), params: params || [] });
|
|
597
|
+
const step = script[i++] || { rows: [], rowCount: 0 };
|
|
598
|
+
if (step.throwCode) { const e = new Error(`pg error ${step.throwCode}`); e.code = step.throwCode; throw e; }
|
|
599
|
+
return step;
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
t('normalizeHandle: canonical form only', () => {
|
|
605
|
+
assert.equal(P.normalizeHandle(' Jaxri '), 'jaxri');
|
|
606
|
+
assert.equal(P.normalizeHandle('a'), 'a');
|
|
607
|
+
assert.equal(P.normalizeHandle('a-b-1'), 'a-b-1');
|
|
608
|
+
assert.equal(P.normalizeHandle('has_underscore'), null);
|
|
609
|
+
assert.equal(P.normalizeHandle('-lead'), null);
|
|
610
|
+
assert.equal(P.normalizeHandle('trail-'), null);
|
|
611
|
+
assert.equal(P.normalizeHandle('x'.repeat(39)), 'x'.repeat(39));
|
|
612
|
+
assert.equal(P.normalizeHandle('x'.repeat(40)), null);
|
|
613
|
+
assert.equal(P.normalizeHandle(''), null);
|
|
614
|
+
assert.equal(P.normalizeHandle(42), null);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
t('the reserved list is exactly the spec Decision 3 set', () => {
|
|
618
|
+
assert.deepEqual([...P.RESERVED_HANDLES].sort(), ['admin', 'api', 'edit', 'me', 'new', 'settings', 'u'].sort());
|
|
619
|
+
assert.equal(P.isReservedHandle('me'), true);
|
|
620
|
+
assert.equal(P.isReservedHandle('jaxri'), false);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
t('handleSeedBase squeezes the login; contributes-nothing → builder-<id>', () => {
|
|
624
|
+
assert.equal(P.handleSeedBase({ githubId: 7, githubLogin: 'Jax_Ri!' }), 'jaxri');
|
|
625
|
+
assert.equal(P.handleSeedBase({ githubId: 7, githubLogin: '-Wrapped-' }), 'wrapped');
|
|
626
|
+
assert.equal(P.handleSeedBase({ githubId: 7, githubLogin: '___' }), 'builder-7');
|
|
627
|
+
assert.equal(P.handleSeedBase({ githubId: 7, githubLogin: null }), 'builder-7');
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
await ta('ensureHandle takes the bare base when free', async () => {
|
|
631
|
+
const pool = throwingPool([{ rows: [{ handle: 'jaxri' }], rowCount: 1 }]);
|
|
632
|
+
const h = await P.ensureHandle({ githubId: 7, githubLogin: 'jaxri' }, { pool });
|
|
633
|
+
assert.equal(h, 'jaxri');
|
|
634
|
+
assert.match(pool.calls[0].text, /SET handle = \$2/);
|
|
635
|
+
assert.match(pool.calls[0].text, /handle IS NULL/, 'seed must never clobber an existing handle');
|
|
636
|
+
assert.equal(pool.calls[0].params[1], 'jaxri');
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
await ta('ensureHandle dedupes a collision with the numeric suffix', async () => {
|
|
640
|
+
const pool = throwingPool([{ throwCode: '23505' }, { rows: [{ handle: 'jaxri-2' }], rowCount: 1 }]);
|
|
641
|
+
const h = await P.ensureHandle({ githubId: 7, githubLogin: 'jaxri' }, { pool });
|
|
642
|
+
assert.equal(h, 'jaxri-2');
|
|
643
|
+
assert.equal(pool.calls[1].params[1], 'jaxri-2');
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
await ta('ensureHandle never hands out a reserved word bare', async () => {
|
|
647
|
+
const pool = throwingPool([{ rows: [{ handle: 'admin-2' }], rowCount: 1 }]);
|
|
648
|
+
const h = await P.ensureHandle({ githubId: 7, githubLogin: 'admin' }, { pool });
|
|
649
|
+
assert.equal(h, 'admin-2');
|
|
650
|
+
assert.equal(pool.calls[0].params[1], 'admin-2', 'a reserved base starts at -2');
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
await ta('ensureHandle: non-numeric github_id (system:*) → null, no query', async () => {
|
|
654
|
+
const pool = throwingPool([]);
|
|
655
|
+
assert.equal(await P.ensureHandle({ githubId: 'system:bfg', githubLogin: 'bfg' }, { pool }), null);
|
|
656
|
+
assert.equal(pool.calls.length, 0);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
await ta('setAccountHandle refuses invalid + reserved before touching the DB', async () => {
|
|
660
|
+
const pool = throwingPool([]);
|
|
661
|
+
assert.deepEqual(await P.setAccountHandle({ githubId: 7, handle: 'bad_handle' }, { pool }), { error: 'invalid_handle' });
|
|
662
|
+
assert.deepEqual(await P.setAccountHandle({ githubId: 7, handle: 'settings' }, { pool }), { error: 'reserved_handle' });
|
|
663
|
+
assert.equal(pool.calls.length, 0);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
await ta('setAccountHandle renames: previous handle kept, clock started', async () => {
|
|
667
|
+
const acct = { github_id: 7, handle: 'old', previous_handle: null, handle_changed_at: null };
|
|
668
|
+
const pool = throwingPool([
|
|
669
|
+
{ rows: [acct], rowCount: 1 },
|
|
670
|
+
{ rows: [{ handle: 'newname', previous_handle: 'old' }], rowCount: 1 },
|
|
671
|
+
]);
|
|
672
|
+
const out = await P.setAccountHandle({ githubId: 7, handle: 'NewName' }, { pool });
|
|
673
|
+
assert.deepEqual(out, { handle: 'newname', previous_handle: 'old' });
|
|
674
|
+
const upd = pool.calls[1];
|
|
675
|
+
assert.match(upd.text, /previous_handle = handle/, 'the old handle drops into the 301 slot');
|
|
676
|
+
assert.match(upd.text, /handle_changed_at = now\(\)/);
|
|
677
|
+
assert.match(upd.text, /handle_changed_at IS NULL\s+OR handle_changed_at <= now\(\)/,
|
|
678
|
+
'the window is re-checked IN the UPDATE — the rate limit must not be check-then-write');
|
|
679
|
+
assert.equal(upd.params[1], 'newname', 'stored lowercased');
|
|
680
|
+
assert.equal(upd.params[2], P.HANDLE_RENAME_WINDOW_MS);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
await ta('setAccountHandle: losing the window race → rate_limited, not a silent write', async () => {
|
|
684
|
+
// The read sees a free window, but the guarded UPDATE matches 0 rows (a
|
|
685
|
+
// concurrent rename consumed it) — the re-read answers rate_limited.
|
|
686
|
+
const before = { github_id: 7, handle: 'old', previous_handle: null, handle_changed_at: null };
|
|
687
|
+
const after = { ...before, handle: 'stolen', handle_changed_at: new Date().toISOString() };
|
|
688
|
+
const pool = throwingPool([
|
|
689
|
+
{ rows: [before], rowCount: 1 },
|
|
690
|
+
{ rows: [], rowCount: 0 },
|
|
691
|
+
{ rows: [after], rowCount: 1 },
|
|
692
|
+
]);
|
|
693
|
+
const out = await P.setAccountHandle({ githubId: 7, handle: 'newname' }, { pool });
|
|
694
|
+
assert.equal(out.error, 'rate_limited');
|
|
695
|
+
assert.ok(out.retry_at);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
await ta('setAccountHandle: a rename inside the window is rate-limited', async () => {
|
|
699
|
+
const acct = { github_id: 7, handle: 'old', previous_handle: null, handle_changed_at: new Date().toISOString() };
|
|
700
|
+
const pool = throwingPool([{ rows: [acct], rowCount: 1 }]);
|
|
701
|
+
const out = await P.setAccountHandle({ githubId: 7, handle: 'newname' }, { pool });
|
|
702
|
+
assert.equal(out.error, 'rate_limited');
|
|
703
|
+
assert.ok(out.retry_at, 'carries when the window reopens');
|
|
704
|
+
assert.equal(pool.calls.length, 1, 'no UPDATE fired');
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
await ta('setAccountHandle: renaming to the current handle is a free no-op', async () => {
|
|
708
|
+
const acct = { github_id: 7, handle: 'same', previous_handle: 'older', handle_changed_at: new Date().toISOString() };
|
|
709
|
+
const pool = throwingPool([{ rows: [acct], rowCount: 1 }]);
|
|
710
|
+
const out = await P.setAccountHandle({ githubId: 7, handle: 'Same' }, { pool });
|
|
711
|
+
assert.deepEqual(out, { handle: 'same', previous_handle: 'older' });
|
|
712
|
+
assert.equal(pool.calls.length, 1, 'read only — no clock burn');
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
await ta('setAccountHandle: a taken handle → handle_taken', async () => {
|
|
716
|
+
const acct = { github_id: 7, handle: 'old', previous_handle: null, handle_changed_at: null };
|
|
717
|
+
const pool = throwingPool([{ rows: [acct], rowCount: 1 }, { throwCode: '23505' }]);
|
|
718
|
+
const out = await P.setAccountHandle({ githubId: 7, handle: 'wanted' }, { pool });
|
|
719
|
+
assert.deepEqual(out, { error: 'handle_taken' });
|
|
720
|
+
});
|
|
721
|
+
|
|
584
722
|
console.log(`\nplatform-identity: ${passed} passed, ${failed} failed`);
|
|
585
723
|
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.172",
|
|
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.172",
|
|
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.172",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -769,8 +769,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
769
769
|
// landed since 1.19.169 with no explicit bump. run 31965483417. (task 1002620)
|
|
770
770
|
// 1.19.171 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
771
771
|
// landed since 1.19.170 with no explicit bump. run 31967255167. (task 1002620)
|
|
772
|
+
// 1.19.172 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
773
|
+
// landed since 1.19.171 with no explicit bump. run 31968188738. (task 1002620)
|
|
772
774
|
// ---------------------------------------------------------------------------
|
|
773
|
-
const CORE_VERSION = '1.19.
|
|
775
|
+
const CORE_VERSION = '1.19.172'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
774
776
|
|
|
775
777
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
776
778
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// tests/handle_machinery.mjs — P01, task 1002797 (goal 1000045; real DB).
|
|
2
|
+
//
|
|
3
|
+
// Proves migration 008's seeding + the go-forward handle machinery against real
|
|
4
|
+
// SQL (the plpgsql seeding loop and the partial unique index cannot run on
|
|
5
|
+
// pg-mem — the tests/profile_rollup_consent.mjs precedent):
|
|
6
|
+
// 1. seeding dedupes case-colliding logins oldest-first with -2, -3 suffixes;
|
|
7
|
+
// reserved logins never seed bare; an unusable login → builder-<github_id>
|
|
8
|
+
// 2. upsertAccount seeds a NEW account's handle, suffixing past taken ones
|
|
9
|
+
// 3. ensureHandle never clobbers a seeded handle
|
|
10
|
+
// 4. setAccountHandle: rename keeps the old handle in previous_handle and
|
|
11
|
+
// starts the rate-limit clock; a second rename inside the window is
|
|
12
|
+
// refused; an expired window allows; a case-insensitive collision → taken;
|
|
13
|
+
// reserved words are refused
|
|
14
|
+
//
|
|
15
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
16
|
+
// INTEGRATION set so the default DB-free lane never runs it.
|
|
17
|
+
//
|
|
18
|
+
// Run: DATABASE_URL=... node tests/handle_machinery.mjs
|
|
19
|
+
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const { Pool } = require('pg');
|
|
27
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
28
|
+
|
|
29
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
31
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
32
|
+
|
|
33
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
34
|
+
try {
|
|
35
|
+
await pool.query('SELECT 1');
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.log(`handle_machinery.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
38
|
+
await pool.end().catch(() => {});
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// High-numbered fixture ids so a shared DB can never collide with real data.
|
|
43
|
+
const TWIN_A = 9002797001, TWIN_B = 9002797002, RESV = 9002797003, JUNK = 9002797004, FRESH = 9002797005;
|
|
44
|
+
const ALL_IDS = [TWIN_A, TWIN_B, RESV, JUNK, FRESH];
|
|
45
|
+
|
|
46
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
47
|
+
async function cleanup() {
|
|
48
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let pass = 0;
|
|
52
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
53
|
+
const handleOf = async (id) => (await pi.getAccount(id, { pool })).handle;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// The module's actual migrations (idempotent). 008 runs AFTER the fixture
|
|
57
|
+
// rows exist so its seeding loop has NULL handles to seed — exactly the
|
|
58
|
+
// legacy-rows-at-deploy shape.
|
|
59
|
+
await q(migrationSql('platform_identity_001_tables.sql'));
|
|
60
|
+
await q(migrationSql('platform_identity_007_profile_consent.sql'));
|
|
61
|
+
// On a DB where 008 already ran the columns exist and the raw inserts below
|
|
62
|
+
// simply land with handle NULL — the re-run seeds only those. Idempotence is
|
|
63
|
+
// part of what this proves.
|
|
64
|
+
await q(migrationSql('platform_identity_008_handle.sql')).catch(() => {});
|
|
65
|
+
await cleanup();
|
|
66
|
+
|
|
67
|
+
await q(
|
|
68
|
+
`INSERT INTO platform_identity_accounts (github_id, github_login, created_at)
|
|
69
|
+
VALUES ($1, 'P01SeedTwin', now() - interval '10 minutes'),
|
|
70
|
+
($2, 'p01seedtwin', now() - interval '9 minutes'),
|
|
71
|
+
($3, 'admin', now() - interval '8 minutes'),
|
|
72
|
+
($4, '___', now() - interval '7 minutes')`,
|
|
73
|
+
[TWIN_A, TWIN_B, RESV, JUNK],
|
|
74
|
+
);
|
|
75
|
+
await q(migrationSql('platform_identity_008_handle.sql'));
|
|
76
|
+
|
|
77
|
+
// --- 1. seeding ----------------------------------------------------------
|
|
78
|
+
ok((await handleOf(TWIN_A)) === 'p01seedtwin', 'oldest of a case-colliding pair seeds the bare handle');
|
|
79
|
+
ok((await handleOf(TWIN_B)) === 'p01seedtwin-2', 'the younger twin gets the -2 suffix');
|
|
80
|
+
ok((await handleOf(RESV)) === 'admin-2', 'a reserved login never seeds bare — suffixed from -2');
|
|
81
|
+
ok((await handleOf(JUNK)) === `builder-${JUNK}`, 'a login that contributes nothing seeds builder-<github_id>');
|
|
82
|
+
|
|
83
|
+
// --- 2. go-forward seeding via upsertAccount ------------------------------
|
|
84
|
+
const fresh = await pi.upsertAccount({ githubId: FRESH, githubLogin: 'P01SeedTwin' }, { pool });
|
|
85
|
+
ok(fresh.handle === 'p01seedtwin-3', 'a new account collides past every taken suffix');
|
|
86
|
+
|
|
87
|
+
// --- 3. ensureHandle never clobbers ---------------------------------------
|
|
88
|
+
const again = await pi.ensureHandle({ githubId: FRESH, githubLogin: 'renamed-login' }, { pool });
|
|
89
|
+
ok(again === 'p01seedtwin-3', 'ensureHandle on a seeded account returns the existing handle untouched');
|
|
90
|
+
|
|
91
|
+
// --- 4. renames -----------------------------------------------------------
|
|
92
|
+
const r1 = await pi.setAccountHandle({ githubId: FRESH, handle: 'P01-Renamed' }, { pool });
|
|
93
|
+
ok(r1.handle === 'p01-renamed' && r1.previous_handle === 'p01seedtwin-3',
|
|
94
|
+
'rename lowercases, keeps the old handle in the 301 slot');
|
|
95
|
+
|
|
96
|
+
const r2 = await pi.setAccountHandle({ githubId: FRESH, handle: 'p01-again' }, { pool });
|
|
97
|
+
ok(r2.error === 'rate_limited' && !!r2.retry_at, 'a second rename inside the window is refused with retry_at');
|
|
98
|
+
ok((await handleOf(FRESH)) === 'p01-renamed', 'the refused rename changed nothing');
|
|
99
|
+
|
|
100
|
+
await q(`UPDATE platform_identity_accounts SET handle_changed_at = now() - interval '8 days' WHERE github_id = $1`, [FRESH]);
|
|
101
|
+
const r3 = await pi.setAccountHandle({ githubId: FRESH, handle: 'p01-again' }, { pool });
|
|
102
|
+
ok(r3.handle === 'p01-again' && r3.previous_handle === 'p01-renamed', 'an expired window allows the rename; the 301 slot rolls forward');
|
|
103
|
+
|
|
104
|
+
await q(`UPDATE platform_identity_accounts SET handle_changed_at = now() - interval '8 days' WHERE github_id = $1`, [FRESH]);
|
|
105
|
+
const r4 = await pi.setAccountHandle({ githubId: FRESH, handle: 'P01SEEDTWIN' }, { pool });
|
|
106
|
+
ok(r4.error === 'handle_taken', 'a case-insensitive collision with a live handle is refused');
|
|
107
|
+
|
|
108
|
+
const r5 = await pi.setAccountHandle({ githubId: FRESH, handle: 'u' }, { pool });
|
|
109
|
+
ok(r5.error === 'reserved_handle', 'reserved words are refused on rename');
|
|
110
|
+
|
|
111
|
+
console.log(`\nhandle_machinery: ${pass} assertions passed`);
|
|
112
|
+
} finally {
|
|
113
|
+
await cleanup();
|
|
114
|
+
await pool.end().catch(() => {});
|
|
115
|
+
}
|