@bongos/core 1.19.172 → 1.19.174

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 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.172",
6
- "core_contract": "1.19.172",
7
- "source_commit": "d488f4a69b30ec2fe81f95cc1ec652c25af8fc68",
5
+ "core_version": "1.19.174",
6
+ "core_contract": "1.19.174",
7
+ "source_commit": "01b7a9adb8720d04035ce515c8ed16155486b694",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-16T19:39:18.102Z",
9
+ "built_at": "2026-08-16T19:56:25.262Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 305,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1271,
14
+ "functional_verbatim": 1273,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1597,
20
- "tree_sha256": "c7aad1a023cefa6bce6c0403ed5d0bf9c1e5a28b4e38e14a85f1cfc697d5edb3",
19
+ "file_count": 1599,
20
+ "tree_sha256": "8bdaa736f97566f66d4afb3f53a7a779eaf343a244651e18831cbf3629c05957",
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": "a13c27a7d671408579dedf417b7f28486bebc52b7a685ba3900cd4eca843957c"
350
+ "sha256": "6b865314ad2d5e2e3e0e017a6bed7ae435ecfbca3859bdf763b59411a1d3d9f8"
351
351
  },
352
352
  {
353
353
  "path": "clients/bongos-client/bongos-client.global.js",
354
354
  "mode": "0000644",
355
- "sha256": "7b6abb75150b8fdc9fba1e0d765c049979da7cdd1d6a20eeeb6f3d5e3886ebc7"
355
+ "sha256": "c137a0e79a5e9065b071f87150639bff98cbda8f0d50a27027b42fa48ae200f6"
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": "a4a864366450b849bafc096807f37cd16c8ed624470473aaf4907b24ab104778"
365
+ "sha256": "2fef3ad1be5b853de1d6df155bb5717b581cf8b6afa3851add9ea81032c159a6"
366
366
  },
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "c032e9796a9feeee86929323992511d5ed1cb448975481f98f86cee1b3790d50"
370
+ "sha256": "52dbea93e7039796ebf579e816bd3ed54727077e5383d27a348d36572a85b90d"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
374
374
  "mode": "0000644",
375
- "sha256": "cc177720f5f3b0971edac3f03464e3dd5930b48d12811cd9ab39a7b6ceaaeb9c"
375
+ "sha256": "b2d21163a9fb149a98051dfde08e1cee81969194e74d3e272bb7779da9461c1b"
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": "d1595faad24aafba420199b3115b11ee5387de7c97c7f4fe7fe4f138f43dd79a"
1350
+ "sha256": "74ee845dae6f2757b564c1a760042391f3eb3af21dd3abe4a8590b3c3f88c4b3"
1351
1351
  },
1352
1352
  {
1353
1353
  "path": "docs/api/openapi.json",
1354
1354
  "mode": "0000644",
1355
- "sha256": "1435287ef26983d7088555abc9f18df97ceb4a8676be091ff49076e54a4366ab"
1355
+ "sha256": "1c8b42344fa248bd6e38d370cc117c5017fdd58071ba9cb2ca6e55a1678c5e56"
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": "a4a864366450b849bafc096807f37cd16c8ed624470473aaf4907b24ab104778"
2665
+ "sha256": "2fef3ad1be5b853de1d6df155bb5717b581cf8b6afa3851add9ea81032c159a6"
2666
2666
  },
2667
2667
  {
2668
2668
  "path": "modules/dev-box/app/test/unit.mjs",
@@ -3797,12 +3797,12 @@
3797
3797
  {
3798
3798
  "path": "modules/platform-identity/module.json",
3799
3799
  "mode": "0000644",
3800
- "sha256": "e993fb26e61249bf89c0b0a5977c60e9d974810ffe1b67e94d909e8b52e00d5e"
3800
+ "sha256": "c4c02577f7318e994301bbe700e20dfb24519e411fee04539755f7374a73590b"
3801
3801
  },
3802
3802
  {
3803
3803
  "path": "modules/platform-identity/platform-identity.js",
3804
3804
  "mode": "0000644",
3805
- "sha256": "115be49e667716d760610f8a9b167eeea4e0e6b61c40c7b9a439703847b61dcd"
3805
+ "sha256": "59d9ef7d052890fdf09fd0a9f04432ab1baddc7c8dfe49950893b9dd67b89d66"
3806
3806
  },
3807
3807
  {
3808
3808
  "path": "modules/platform-identity/pollers/code-sweep.js",
@@ -3829,6 +3829,11 @@
3829
3829
  "mode": "0000644",
3830
3830
  "sha256": "a3e25369ad9edc6aed1a8a6997d6b6cb794dce14abb3605954d535525af1a93a"
3831
3831
  },
3832
+ {
3833
+ "path": "modules/platform-identity/routes/public-profile.js",
3834
+ "mode": "0000644",
3835
+ "sha256": "9032ff152e6440187f0f9bd2b1a3bb9ea24167c9f6d79ae3345a26a0e2fdc356"
3836
+ },
3832
3837
  {
3833
3838
  "path": "modules/platform-identity/routes/scouting.js",
3834
3839
  "mode": "0000644",
@@ -3842,7 +3847,7 @@
3842
3847
  {
3843
3848
  "path": "modules/platform-identity/tests/platform-identity.mjs",
3844
3849
  "mode": "0000644",
3845
- "sha256": "4b7f13effc33d5d16a5f87d0915ed31e107f138ef135822745774f90192ad327"
3850
+ "sha256": "53b5bdd5c9eb4a14695289dd1396231d29e0f53c8709bd591dc328362da88072"
3846
3851
  },
3847
3852
  {
3848
3853
  "path": "modules/platform-identity/ui/.gitkeep",
@@ -4217,12 +4222,12 @@
4217
4222
  {
4218
4223
  "path": "package-lock.json",
4219
4224
  "mode": "0000644",
4220
- "sha256": "3da96cec73d1af6c4f1470d29cc26187cc949eba5b6fb7291b89da3da4c7eae7"
4225
+ "sha256": "3218421cd172f2d2201d5ea5573d36a5534f08a47e82eca9940eb3084275ef52"
4221
4226
  },
4222
4227
  {
4223
4228
  "path": "package.json",
4224
4229
  "mode": "0000644",
4225
- "sha256": "dbf95e145339c9e8d52b1e532e452eb2d344c0c0f06a28f58c6e19366e256dd6"
4230
+ "sha256": "907133ee8dd8cab837d620480df2af2d001825a2a9094ef528cf37a7e93a58da"
4226
4231
  },
4227
4232
  {
4228
4233
  "path": "public-docs/index.html",
@@ -5827,7 +5832,7 @@
5827
5832
  {
5828
5833
  "path": "src/module-api.js",
5829
5834
  "mode": "0000644",
5830
- "sha256": "bc1f9966a0040b5239664c1a5d993c302b1fa9c56d2080c97b168115a4da847a"
5835
+ "sha256": "c4c789739d3759c20717b498e03783b14b3d8db3e53995415616885fa99d2cfc"
5831
5836
  },
5832
5837
  {
5833
5838
  "path": "src/module-loader/catalog.js",
@@ -6912,7 +6917,7 @@
6912
6917
  {
6913
6918
  "path": "tests/handle_machinery.mjs",
6914
6919
  "mode": "0000644",
6915
- "sha256": "755f49404e97c60c8c264f5f4c9d563a7c4ee4f51b43afe96fd10acce52ebe7b"
6920
+ "sha256": "a86e615af8b148572abf562b3c7b0d556076286e76e2d8d576f782a90ba3d457"
6916
6921
  },
6917
6922
  {
6918
6923
  "path": "tests/helpers.mjs",
@@ -6939,6 +6944,11 @@
6939
6944
  "mode": "0000644",
6940
6945
  "sha256": "7d384cd84cdc93b8e7b57df291a4600a40f43346fd2a52168324ecde219f1388"
6941
6946
  },
6947
+ {
6948
+ "path": "tests/hub_profile_leak.mjs",
6949
+ "mode": "0000644",
6950
+ "sha256": "f0934fad2cd869a0955b9b087cab449932e921032ca94fc356a952e88f528d82"
6951
+ },
6942
6952
  {
6943
6953
  "path": "tests/idea_completeness.mjs",
6944
6954
  "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
- - 274 operations across 45 resource groups
8
+ - 275 operations across 46 resource groups
9
9
 
10
10
  ## Install
11
11
 
@@ -502,6 +502,10 @@ function createClient(opts = {}) {
502
502
  // POST /override-requests/{id}/decide — rank: metic+archon — POST /override-requests/:id/decide
503
503
  postOverrideRequestsIdDecide: (args) => request("POST", "/override-requests/{id}/decide", { hasBody: true }, args),
504
504
  },
505
+ "profiles": {
506
+ // GET /profiles/{handle} — rank: public — GET /profiles/:handle
507
+ getProfilesHandle: (args) => request("GET", "/profiles/{handle}", { hasBody: false }, args),
508
+ },
505
509
  "projects": {
506
510
  // GET /projects — rank: metic+archon — GET /projects
507
511
  getProjects: (args) => request("GET", "/projects", { hasBody: false }, args),
@@ -501,6 +501,10 @@ function createClient(opts = {}) {
501
501
  // POST /override-requests/{id}/decide — rank: metic+archon — POST /override-requests/:id/decide
502
502
  postOverrideRequestsIdDecide: (args) => request("POST", "/override-requests/{id}/decide", { hasBody: true }, args),
503
503
  },
504
+ "profiles": {
505
+ // GET /profiles/{handle} — rank: public — GET /profiles/:handle
506
+ getProfilesHandle: (args) => request("GET", "/profiles/{handle}", { hasBody: false }, args),
507
+ },
504
508
  "projects": {
505
509
  // GET /projects — rank: metic+archon — GET /projects
506
510
  getProjects: (args) => request("GET", "/projects", { hasBody: false }, args),
@@ -68,6 +68,7 @@ export interface GetModulesSubmissionsResponse { submissions: unknown }
68
68
  export interface GetMyProjectsInvitesResponse { invites: unknown }
69
69
  export interface GetMyProjectsResponse { owned: unknown; building: unknown }
70
70
  export interface GetOverrideRequestsResponse { requests: unknown }
71
+ export interface GetProfilesHandleResponse { account: unknown; cross_project: unknown }
71
72
  export interface GetProjectsFeaturedResponse { projects: unknown }
72
73
  export interface GetProjectsResponse { projects: unknown }
73
74
  export interface GetProvisioningFleetResponse { instances: unknown }
@@ -743,6 +744,10 @@ export interface BongosClient {
743
744
  /** POST /override-requests/{id}/decide — rank: metic+archon */
744
745
  postOverrideRequestsIdDecide(args: RequestArgs & { body: PostOverrideRequestsIdDecideRequest }): Promise<ApiResponse>;
745
746
  };
747
+ "profiles": {
748
+ /** GET /profiles/{handle} — rank: public */
749
+ getProfilesHandle(args?: RequestArgs): Promise<GetProfilesHandleResponse>;
750
+ };
746
751
  "projects": {
747
752
  /** GET /projects — rank: metic+archon */
748
753
  getProjects(args?: RequestArgs): Promise<GetProjectsResponse>;
@@ -498,6 +498,10 @@ export function createClient(opts = {}) {
498
498
  // POST /override-requests/{id}/decide — rank: metic+archon — POST /override-requests/:id/decide
499
499
  postOverrideRequestsIdDecide: (args) => request("POST", "/override-requests/{id}/decide", { hasBody: true }, args),
500
500
  },
501
+ "profiles": {
502
+ // GET /profiles/{handle} — rank: public — GET /profiles/:handle
503
+ getProfilesHandle: (args) => request("GET", "/profiles/{handle}", { hasBody: false }, args),
504
+ },
501
505
  "projects": {
502
506
  // GET /projects — rank: metic+archon — GET /projects
503
507
  getProjects: (args) => request("GET", "/projects", { hasBody: false }, args),
@@ -157,6 +157,10 @@
157
157
  "name": "override-requests",
158
158
  "description": "Endpoints under `/api/bongos/override-requests`."
159
159
  },
160
+ {
161
+ "name": "profiles",
162
+ "description": "Endpoints under `/api/bongos/profiles`."
163
+ },
160
164
  {
161
165
  "name": "projects",
162
166
  "description": "Endpoints under `/api/bongos/projects`."
@@ -8163,6 +8167,48 @@
8163
8167
  ]
8164
8168
  }
8165
8169
  },
8170
+ "/profiles/{handle}": {
8171
+ "get": {
8172
+ "operationId": "get_profiles_handle",
8173
+ "tags": [
8174
+ "profiles"
8175
+ ],
8176
+ "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.",
8178
+ "x-rank": "public",
8179
+ "x-source": "modules/platform-identity/routes/public-profile.js",
8180
+ "parameters": [
8181
+ {
8182
+ "name": "handle",
8183
+ "in": "path",
8184
+ "required": true,
8185
+ "schema": {
8186
+ "type": "string"
8187
+ },
8188
+ "description": "Path parameter `handle`."
8189
+ }
8190
+ ],
8191
+ "responses": {
8192
+ "200": {
8193
+ "description": "Success.",
8194
+ "content": {
8195
+ "application/json": {
8196
+ "schema": {
8197
+ "$ref": "#/components/schemas/GetProfilesHandleResponse"
8198
+ }
8199
+ }
8200
+ }
8201
+ },
8202
+ "400": {
8203
+ "$ref": "#/components/responses/BadRequest"
8204
+ },
8205
+ "404": {
8206
+ "$ref": "#/components/responses/NotFound"
8207
+ }
8208
+ },
8209
+ "security": []
8210
+ }
8211
+ },
8166
8212
  "/projects": {
8167
8213
  "get": {
8168
8214
  "operationId": "get_projects",
@@ -13253,6 +13299,17 @@
13253
13299
  "requests"
13254
13300
  ]
13255
13301
  },
13302
+ "GetProfilesHandleResponse": {
13303
+ "type": "object",
13304
+ "properties": {
13305
+ "account": {},
13306
+ "cross_project": {}
13307
+ },
13308
+ "required": [
13309
+ "account",
13310
+ "cross_project"
13311
+ ]
13312
+ },
13256
13313
  "GetProjectsFeaturedResponse": {
13257
13314
  "type": "object",
13258
13315
  "properties": {
@@ -17298,9 +17355,9 @@
17298
17355
  "description": "A required dependency/feature is not configured or is temporarily down."
17299
17356
  }
17300
17357
  },
17301
- "x-endpoint-count": 274,
17302
- "x-schema-count": 308,
17358
+ "x-endpoint-count": 275,
17359
+ "x-schema-count": 309,
17303
17360
  "x-undocumented-bodies": 8,
17304
- "x-response-schemas": 208,
17361
+ "x-response-schemas": 209,
17305
17362
  "x-generated-by": "scripts/gds/gen-api-docs.js"
17306
17363
  }
@@ -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. 274 endpoints across 45 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 275 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`.
@@ -352,6 +352,12 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
352
352
  | GET | `/api/bongos/override-requests` | `metic+archon` | — | GET /override-requests?status=open — Archon queue. |
353
353
  | POST | `/api/bongos/override-requests/:id/decide` | `metic+archon` | `decision`, `note` | POST /override-requests/:id/decide — body: { decision: 'approve'\|'deny', note? } On approve, atomically flips the task completed→confirme… |
354
354
 
355
+ ## `profiles` (1)
356
+
357
+ | Method | Path | Rank | Body | Description |
358
+ |---|---|---|---|---|
359
+ | GET | `/api/bongos/profiles/:handle` | `public` | — | a handle-addressed public profile. |
360
+
355
361
  ## `projects` (5)
356
362
 
357
363
  | Method | Path | Rank | Body | Description |
@@ -501,6 +501,10 @@ function createClient(opts = {}) {
501
501
  // POST /override-requests/{id}/decide — rank: metic+archon — POST /override-requests/:id/decide
502
502
  postOverrideRequestsIdDecide: (args) => request("POST", "/override-requests/{id}/decide", { hasBody: true }, args),
503
503
  },
504
+ "profiles": {
505
+ // GET /profiles/{handle} — rank: public — GET /profiles/:handle
506
+ getProfilesHandle: (args) => request("GET", "/profiles/{handle}", { hasBody: false }, args),
507
+ },
504
508
  "projects": {
505
509
  // GET /projects — rank: metic+archon — GET /projects
506
510
  getProjects: (args) => request("GET", "/projects", { hasBody: false }, args),
@@ -10,7 +10,7 @@
10
10
  "maintainer": "Cloud Bongos maintainers",
11
11
  "maintenance": { "status": "core-maintained" },
12
12
  "contributes": {
13
- "routes": ["sso", "projects", "my-projects", "scouting", "profile"],
13
+ "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile"],
14
14
  "migrations": true,
15
15
  "pollers": ["code-sweep"]
16
16
  },
@@ -748,6 +748,60 @@ async function setAccountHandle({ githubId, handle }, { pool = defaultPool } = {
748
748
  }
749
749
  }
750
750
 
751
+ // Resolve a public handle → its account (R05, task 1002281 — the /u/<handle>
752
+ // resolution). Case-insensitive. An exact current-handle match always wins;
753
+ // otherwise the single-slot previous_handle matches and answers a move to the
754
+ // current handle (the 301 window from P01). Only publicly addressable accounts
755
+ // resolve (profile_state = 'active', ADR 0171 D3) — provisional and unknown are
756
+ // the same null (the 404, indistinguishable by design). hide_stats deliberately
757
+ // does NOT gate resolution: hiding removes the rollup, not the page (D2 —
758
+ // addressability and hide are different invariants).
759
+ // Returns null | { moved_to } | { account: { github_id, github_login,
760
+ // display_name, avatar_url, handle, created_at } }.
761
+ async function resolveHandle(handle, { pool = defaultPool } = {}) {
762
+ const norm = normalizeHandle(handle);
763
+ if (!norm) return null;
764
+ const { rows } = await pool.query(
765
+ `SELECT github_id, github_login, display_name, avatar_url, handle, created_at,
766
+ (lower(handle) = $1) AS is_current
767
+ FROM platform_identity_accounts
768
+ WHERE profile_state = 'active'
769
+ AND (lower(handle) = $1 OR lower(previous_handle) = $1)
770
+ ORDER BY (lower(handle) = $1) DESC
771
+ LIMIT 1`,
772
+ [norm],
773
+ );
774
+ const row = rows[0];
775
+ if (!row) return null;
776
+ if (!row.is_current) return { moved_to: row.handle };
777
+ const { is_current, ...account } = row;
778
+ return { account };
779
+ }
780
+
781
+ // The one public read the canonical /u/<handle> page consumes (R05; the page
782
+ // itself is goal 1000048 R14, task 1002365 — exactly one page gets built, this
783
+ // is its read). Whitelist-shaped INSIDE the module: the identity-band fields
784
+ // plus the same cross_project payload the numeric instance profile route serves
785
+ // (null = hidden / none, indistinguishable — D4). profile_state and github_id
786
+ // never cross — a 200 IS the addressability signal, and the join key stays
787
+ // internal.
788
+ async function getPublicProfileByHandle(handle, { pool = defaultPool } = {}) {
789
+ const hit = await resolveHandle(handle, { pool });
790
+ if (!hit) return null;
791
+ if (hit.moved_to) return { moved_to: hit.moved_to };
792
+ const a = hit.account;
793
+ return {
794
+ account: {
795
+ handle: a.handle,
796
+ github_login: a.github_login,
797
+ display_name: a.display_name,
798
+ avatar_url: a.avatar_url,
799
+ member_since: a.created_at,
800
+ },
801
+ cross_project: await getPublicProfileExtras(a.github_id, { pool }),
802
+ };
803
+ }
804
+
751
805
  // -------------------------------------------------------------------------
752
806
  // Authorization codes (single-use, short-lived, bound)
753
807
  // -------------------------------------------------------------------------
@@ -856,6 +910,8 @@ module.exports = {
856
910
  handleSeedBase,
857
911
  ensureHandle,
858
912
  setAccountHandle,
913
+ resolveHandle,
914
+ getPublicProfileByHandle,
859
915
  // codes
860
916
  mintAuthCode,
861
917
  redeemAuthCode,
@@ -0,0 +1,49 @@
1
+ // modules/platform-identity/routes/public-profile.js — the public handle-based
2
+ // profile read (R05, task 1002281; profile-page spec Decisions 1/3, ADR 0171 D8).
3
+ //
4
+ // GET /profiles/:handle — resolve a handle to the public profile payload the
5
+ // canonical /u/<handle> page consumes (goal 1000048 R14 / task 1002365 builds
6
+ // the PAGE; this is its read — exactly one page gets built). Answers:
7
+ // 404 — unknown handle, or a provisional account (indistinguishable by
8
+ // design: fail-closed consent, ADR 0171 D3)
9
+ // 301 — the request named a previous handle inside P01's single-slot
10
+ // window; Location + body point at the current handle
11
+ // 200 — { account: identity-band fields, cross_project: … | null }
12
+ //
13
+ // Reaches core only through the doorway (ADR 0083).
14
+ 'use strict';
15
+
16
+ const express = require('express');
17
+ const api = require('../../../src/module-api');
18
+ const pi = require('../platform-identity');
19
+
20
+ const { pool, corsPublicGet } = api;
21
+
22
+ module.exports = function publicProfileRoutes() {
23
+ const router = express.Router();
24
+
25
+ router.use('/profiles/:handle', corsPublicGet('/profiles/:handle'));
26
+
27
+ // rank: public — a handle-addressed public profile. Every field is already
28
+ // world-readable on the numeric profile route + the leaderboard; the handle is
29
+ // just the rename-proof address (spec Decision 3). Whitelisting happens inside
30
+ // the module (getPublicProfileByHandle); no-store because the payload embeds
31
+ // cross_project and hide must beat the cache (ADR 0171 D5).
32
+ router.get('/profiles/:handle', async (req, res) => {
33
+ try {
34
+ const out = await pi.getPublicProfileByHandle(req.params.handle, { pool });
35
+ res.set('Cache-Control', 'no-store');
36
+ if (!out) return res.fail('profile_not_found', 404);
37
+ if (out.moved_to) {
38
+ res.set('Location', req.originalUrl.replace(/\/profiles\/[^/?]+/, `/profiles/${encodeURIComponent(out.moved_to)}`));
39
+ return res.status(301).json({ moved_to: out.moved_to });
40
+ }
41
+ res.json({ account: out.account, cross_project: out.cross_project });
42
+ } catch (err) {
43
+ console.error('[platform-identity] GET /profiles/:handle', err);
44
+ res.fail('profile_read_failed', { status: 500, message: 'internal error' });
45
+ }
46
+ });
47
+
48
+ return router;
49
+ };
@@ -719,5 +719,58 @@ await ta('setAccountHandle: a taken handle → handle_taken', async () => {
719
719
  assert.deepEqual(out, { error: 'handle_taken' });
720
720
  });
721
721
 
722
+ console.log('\nhandle resolution (R05, task 1002281):');
723
+
724
+ await ta('resolveHandle: active-only, current-beats-previous, lowercased lookup', async () => {
725
+ const row = { github_id: '7', github_login: 'x', display_name: 'X', avatar_url: null, handle: 'x', created_at: 'T', is_current: true };
726
+ const pool = mockPool([{ rows: [row], rowCount: 1 }]);
727
+ const out = await P.resolveHandle('X', { pool });
728
+ const c = pool.calls[0];
729
+ assert.match(c.text, /profile_state = 'active'/, 'provisional accounts are not addressable (ADR 0171 D3)');
730
+ assert.match(c.text, /ORDER BY \(lower\(handle\) = \$1\) DESC/, 'an exact current-handle match always wins over a previous one');
731
+ assert.doesNotMatch(c.text, /hide_stats/, 'hide gates the rollup, never addressability (D2)');
732
+ assert.equal(c.params[0], 'x', 'the lookup is lowercased');
733
+ assert.equal(out.account.handle, 'x');
734
+ assert.ok(!('is_current' in out.account), 'the SQL flag stays internal');
735
+ });
736
+
737
+ await ta('resolveHandle: a previous-handle hit answers the move to the current handle', async () => {
738
+ const row = { github_id: '7', github_login: 'x', display_name: 'X', avatar_url: null, handle: 'newname', created_at: 'T', is_current: false };
739
+ const pool = mockPool([{ rows: [row], rowCount: 1 }]);
740
+ assert.deepEqual(await P.resolveHandle('oldname', { pool }), { moved_to: 'newname' });
741
+ });
742
+
743
+ await ta('resolveHandle: an invalid handle → null without touching the DB', async () => {
744
+ const pool = mockPool([]);
745
+ assert.equal(await P.resolveHandle('not a handle!', { pool }), null);
746
+ assert.equal(await P.resolveHandle('', { pool }), null);
747
+ assert.equal(pool.calls.length, 0);
748
+ });
749
+
750
+ await ta('getPublicProfileByHandle: identity-band whitelist — github_id/profile_state never cross', async () => {
751
+ const row = { github_id: '7', github_login: 'x', display_name: 'X', avatar_url: 'a', handle: 'x', created_at: 'T', is_current: true };
752
+ const pool = mockPool([
753
+ { rows: [row], rowCount: 1 },
754
+ // getPublicProfileExtras' flags read: hidden → the extras stay null and no
755
+ // rollup query fires.
756
+ { rows: [{ profile_state: 'active', hide_stats: true }], rowCount: 1 },
757
+ ]);
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());
760
+ assert.equal(out.cross_project, null, 'hidden → null, indistinguishable from none');
761
+ const text = JSON.stringify(out);
762
+ assert.doesNotMatch(text, /github_id/, 'the join key stays internal');
763
+ assert.doesNotMatch(text, /profile_state/, 'a 200 IS the addressability signal');
764
+ assert.doesNotMatch(text, /hide_stats/);
765
+ });
766
+
767
+ await ta('getPublicProfileByHandle: unknown → null; moved passes through', async () => {
768
+ const pool1 = mockPool([{ rows: [], rowCount: 0 }]);
769
+ assert.equal(await P.getPublicProfileByHandle('ghost', { pool: pool1 }), null);
770
+ const moved = { github_id: '7', github_login: 'x', display_name: 'X', avatar_url: null, handle: 'now', created_at: 'T', is_current: false };
771
+ const pool2 = mockPool([{ rows: [moved], rowCount: 1 }]);
772
+ assert.deepEqual(await P.getPublicProfileByHandle('then', { pool: pool2 }), { moved_to: 'now' });
773
+ });
774
+
722
775
  console.log(`\nplatform-identity: ${passed} passed, ${failed} failed`);
723
776
  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.172",
3
+ "version": "1.19.174",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.172",
9
+ "version": "1.19.174",
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.172",
3
+ "version": "1.19.174",
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
@@ -771,8 +771,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
771
771
  // landed since 1.19.170 with no explicit bump. run 31967255167. (task 1002620)
772
772
  // 1.19.172 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
773
773
  // landed since 1.19.171 with no explicit bump. run 31968188738. (task 1002620)
774
+ // 1.19.173 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
775
+ // landed since 1.19.172 with no explicit bump. run 31968656001. (task 1002620)
776
+ // 1.19.174 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
777
+ // landed since 1.19.173 with no explicit bump. run 31969032984. (task 1002620)
774
778
  // ---------------------------------------------------------------------------
775
- const CORE_VERSION = '1.19.172'; // CI auto-patch carrier (ADR 0161); see changelog above
779
+ const CORE_VERSION = '1.19.174'; // CI auto-patch carrier (ADR 0161); see changelog above
776
780
 
777
781
  // A namespaced logger so a module's log lines are attributable + consistent.
778
782
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -57,6 +57,8 @@ try {
57
57
  // rows exist so its seeding loop has NULL handles to seed — exactly the
58
58
  // legacy-rows-at-deploy shape.
59
59
  await q(migrationSql('platform_identity_001_tables.sql'));
60
+ await q(migrationSql('platform_identity_004_projects.sql'));
61
+ await q(migrationSql('platform_identity_005_builder_activity.sql'));
60
62
  await q(migrationSql('platform_identity_007_profile_consent.sql'));
61
63
  // On a DB where 008 already ran the columns exist and the raw inserts below
62
64
  // simply land with handle NULL — the re-run seeds only those. Idempotence is
@@ -108,6 +110,32 @@ try {
108
110
  const r5 = await pi.setAccountHandle({ githubId: FRESH, handle: 'u' }, { pool });
109
111
  ok(r5.error === 'reserved_handle', 'reserved words are refused on rename');
110
112
 
113
+ // --- 5. resolution (R05, task 1002281) ------------------------------------
114
+ // FRESH is 'p01-again' with previous 'p01-renamed'; everything is still
115
+ // provisional (fail-closed) at this point.
116
+ const prov = await pi.resolveHandle('p01-again', { pool });
117
+ ok(prov === null, 'a provisional account is not addressable — even by its current handle');
118
+
119
+ await q(`UPDATE platform_identity_accounts SET profile_state = 'active' WHERE github_id = ANY($1::bigint[])`, [[FRESH, TWIN_A]]);
120
+
121
+ const cur = await pi.resolveHandle('P01-Again', { pool });
122
+ ok(cur && cur.account && cur.account.handle === 'p01-again', 'the current handle resolves case-insensitively');
123
+
124
+ const moved = await pi.resolveHandle('p01-renamed', { pool });
125
+ ok(moved && moved.moved_to === 'p01-again', 'the previous handle answers a move to the current one');
126
+
127
+ const page = await pi.getPublicProfileByHandle('p01-again', { pool });
128
+ ok(page && page.account && page.account.member_since && !('github_id' in page.account),
129
+ 'the public read carries the identity band, never the join key');
130
+ ok('cross_project' in page, 'the cross_project key is always present on a 200 (null = hidden/none)');
131
+
132
+ // Another active account claims the freed previous handle → the exact
133
+ // current-handle match beats the stale 301 (documented squat-forward rule).
134
+ await q(`UPDATE platform_identity_accounts SET handle = 'p01-renamed' WHERE github_id = $1`, [TWIN_A]);
135
+ const claimed = await pi.resolveHandle('p01-renamed', { pool });
136
+ ok(claimed && claimed.account && Number(claimed.account.github_id) === TWIN_A,
137
+ 'an exact current-handle match wins over another account previous-handle claim');
138
+
111
139
  console.log(`\nhandle_machinery: ${pass} assertions passed`);
112
140
  } finally {
113
141
  await cleanup();
@@ -0,0 +1,220 @@
1
+ // tests/hub_profile_leak.mjs — P05, task 1002801 (goal 1000045; real DB).
2
+ //
3
+ // The mirror of tests/profile_route.mjs for the HUB profile surface (spec
4
+ // Constraints §1: "the whitelist is the pattern, not the exception" — the new
5
+ // cross-project read needs its OWN sentinel leak test over the account + rollup
6
+ // projection before it is public). Where profile_route.mjs stubs the hub port,
7
+ // this runs the REAL module projection over real SQL and the REAL public route
8
+ // (GET /profiles/:handle, R05), with sentinel strings planted on every
9
+ // module-internal value that must never serialize:
10
+ //
11
+ // - client_id + secret_hash on both reporting sso_clients,
12
+ // - a non-public project's name (counted in more_projects, never named),
13
+ // - the account's previous_handle (the 301 window key — resolvable as a URL,
14
+ // never a response value),
15
+ // - and the consent/join machinery itself: profile_state, hide_stats,
16
+ // github_id must not appear anywhere in a 200 body.
17
+ //
18
+ // Plus the behaviour edges: 404 on unknown / invalid / provisional
19
+ // (indistinguishable, fail-closed D3), hidden → cross_project null with no
20
+ // partial numbers, previous handle → 301 to the current one, no-store.
21
+ //
22
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
23
+ // INTEGRATION set so the default DB-free lane never runs it.
24
+ //
25
+ // Run: DATABASE_URL=... node tests/hub_profile_leak.mjs
26
+
27
+ import { strict as assert } from 'node:assert';
28
+ import { createRequire } from 'node:module';
29
+ import http from 'node:http';
30
+ import { readFileSync } from 'node:fs';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { dirname, join } from 'node:path';
33
+ const require = createRequire(import.meta.url);
34
+ const express = require('express');
35
+ const { Pool } = require('pg');
36
+
37
+ const here = dirname(fileURLToPath(import.meta.url));
38
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
39
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
40
+
41
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
42
+ try {
43
+ await pool.query('SELECT 1');
44
+ } catch (e) {
45
+ console.log(`hub_profile_leak.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
46
+ await pool.end().catch(() => {});
47
+ process.exit(0);
48
+ }
49
+
50
+ // The real route over the real domain — only the doorway is pinned so the
51
+ // route's pool IS the fixture DB (the profile_route.mjs require.cache pattern).
52
+ const moduleApiPath = require.resolve('../src/module-api.js');
53
+ require.cache[moduleApiPath] = {
54
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
55
+ exports: { pool, corsPublicGet: () => (_req, _res, next) => next() },
56
+ };
57
+ const pi = require('../modules/platform-identity/platform-identity.js');
58
+ const buildPublicProfileRoutes = require('../modules/platform-identity/routes/public-profile.js');
59
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
60
+
61
+ // ---- fixtures (sentinels on everything that must never serialize) ---------
62
+
63
+ const ACTIVE = 9002801001, HIDDEN = 9002801002, PROV = 9002801003;
64
+ const ALL_IDS = [ACTIVE, HIDDEN, PROV];
65
+ const SECRET_CLIENT_PUB = 'cli-DO-NOT-LEAK-p05-pub';
66
+ const SECRET_CLIENT_PRIV = 'cli-DO-NOT-LEAK-p05-priv';
67
+ const SECRET_HASH = 'HASH-DO-NOT-LEAK-p05';
68
+ const SECRET_PROJECT = 'P05-PRIVATE-DO-NOT-LEAK';
69
+ const SECRET_PREV = 'p05-prev-sentinel';
70
+ const PUB_ORIGIN = 'https://p05-pub.example', PRIV_ORIGIN = 'https://p05-priv.example';
71
+ const CP_KEYS = ['totals', 'disciplines', 'last_active', 'projects', 'more_projects'];
72
+ const ACCOUNT_KEYS = ['handle', 'github_login', 'display_name', 'avatar_url', 'member_since'];
73
+
74
+ const q = (sql, params) => pool.query(sql, params);
75
+ async function cleanup() {
76
+ await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
77
+ await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
78
+ await q(`DELETE FROM platform_identity_projects WHERE origin = ANY($1::text[])`, [[PUB_ORIGIN, PRIV_ORIGIN]]).catch(() => {});
79
+ await q(`DELETE FROM platform_identity_sso_clients WHERE client_id = ANY($1::text[])`, [[SECRET_CLIENT_PUB, SECRET_CLIENT_PRIV]]).catch(() => {});
80
+ }
81
+
82
+ let passed = 0, failed = 0;
83
+ async function t(name, fn) {
84
+ try { await fn(); passed++; console.log(` ok ${name}`); }
85
+ catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
86
+ }
87
+
88
+ function get(port, path) {
89
+ return new Promise((resolve, reject) => {
90
+ const req = http.request({ host: '127.0.0.1', port, method: 'GET', path }, (res) => {
91
+ const chunks = [];
92
+ res.on('data', (c) => chunks.push(c));
93
+ res.on('end', () => {
94
+ const text = Buffer.concat(chunks).toString('utf8');
95
+ let json = null; try { json = JSON.parse(text); } catch (_) {}
96
+ resolve({ status: res.statusCode, json, text, headers: res.headers });
97
+ });
98
+ });
99
+ req.on('error', reject);
100
+ req.end();
101
+ });
102
+ }
103
+
104
+ const NEVER_IN_A_BODY = [
105
+ SECRET_CLIENT_PUB, SECRET_CLIENT_PRIV, SECRET_HASH, SECRET_PROJECT, SECRET_PREV,
106
+ 'profile_state', 'hide_stats', 'github_id',
107
+ ];
108
+ function assertNoSentinels(text, where) {
109
+ for (const s of NEVER_IN_A_BODY) {
110
+ assert.ok(!text.includes(s), `"${s}" leaked in ${where}`);
111
+ }
112
+ }
113
+
114
+ let server = null;
115
+ try {
116
+ await q(migrationSql('platform_identity_001_tables.sql'));
117
+ await q(migrationSql('platform_identity_004_projects.sql'));
118
+ await q(migrationSql('platform_identity_005_builder_activity.sql'));
119
+ await q(migrationSql('platform_identity_007_profile_consent.sql'));
120
+ await q(migrationSql('platform_identity_008_handle.sql'));
121
+ await cleanup();
122
+
123
+ await q(
124
+ `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)`,
129
+ [ACTIVE, HIDDEN, PROV, SECRET_PREV],
130
+ );
131
+ await q(
132
+ `INSERT INTO platform_identity_sso_clients (client_id, secret_hash, name, origin)
133
+ VALUES ($1, $3, 'P05 Pub Proj', $4), ($2, $3, $5, $6)`,
134
+ [SECRET_CLIENT_PUB, SECRET_CLIENT_PRIV, SECRET_HASH, PUB_ORIGIN, SECRET_PROJECT, PRIV_ORIGIN],
135
+ );
136
+ await q(
137
+ `INSERT INTO platform_identity_projects (origin, name, featured, status)
138
+ VALUES ($1, 'P05 Pub Proj', true, 'active'), ($2, $3, false, 'active')`,
139
+ [PUB_ORIGIN, PRIV_ORIGIN, SECRET_PROJECT],
140
+ );
141
+ await q(
142
+ `INSERT INTO platform_identity_builder_project_activity
143
+ (client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, last_active)
144
+ VALUES ($1, $3, 100, 5, 3, 'metic', '{engineer}', '2026-08-01T00:00:00Z'),
145
+ ($2, $3, 50, 2, 1, 'archon', '{ui}', '2026-08-02T00:00:00Z'),
146
+ ($1, $4, 10, 1, 0, 'metic', '{engineer}', '2026-08-03T00:00:00Z')`,
147
+ [SECRET_CLIENT_PUB, SECRET_CLIENT_PRIV, ACTIVE, HIDDEN],
148
+ );
149
+
150
+ const app = express();
151
+ app.use(attachFail);
152
+ app.use('/api/bongos', buildPublicProfileRoutes());
153
+ const port = await new Promise((resolve) => {
154
+ server = app.listen(0, '127.0.0.1', () => resolve(server.address().port));
155
+ });
156
+
157
+ await t('active handle → 200: identity-band whitelist, exact cross_project contract, no sentinels', async () => {
158
+ const r = await get(port, '/api/bongos/profiles/p05-active');
159
+ assert.equal(r.status, 200);
160
+ assert.equal(r.headers['cache-control'], 'no-store', 'hide must beat the cache (D5)');
161
+ assert.deepEqual(Object.keys(r.json.account).sort(), [...ACCOUNT_KEYS].sort());
162
+ assert.deepEqual(Object.keys(r.json.cross_project).sort(), [...CP_KEYS].sort());
163
+ assert.equal(r.json.cross_project.totals.projects, 2, 'both projects count');
164
+ assert.equal(r.json.cross_project.projects.length, 1, 'only the publicly listed project is named');
165
+ assert.equal(r.json.cross_project.projects[0].name, 'P05 Pub Proj');
166
+ assert.deepEqual(Object.keys(r.json.cross_project.projects[0]).sort(), ['name', 'origin', 'rank_held'].sort());
167
+ assert.equal(r.json.cross_project.more_projects, 1, 'the non-public project is counted, never named');
168
+ assertNoSentinels(r.text, 'the 200 body');
169
+ });
170
+
171
+ await t('lookup is case-insensitive', async () => {
172
+ const r = await get(port, '/api/bongos/profiles/P05-ACTIVE');
173
+ assert.equal(r.status, 200);
174
+ assert.equal(r.json.account.handle, 'p05-active');
175
+ });
176
+
177
+ await t('hidden builder → 200 with cross_project null — no partial numbers', async () => {
178
+ const r = await get(port, '/api/bongos/profiles/p05-hidden');
179
+ assert.equal(r.status, 200);
180
+ assert.equal(r.json.cross_project, null);
181
+ assert.ok(!r.text.includes('totals'), 'nothing derived from the rollup serializes');
182
+ assertNoSentinels(r.text, 'the hidden body');
183
+ });
184
+
185
+ await t('provisional → 404, indistinguishable from unknown (fail-closed D3)', async () => {
186
+ const prov = await get(port, '/api/bongos/profiles/p05-prov');
187
+ const ghost = await get(port, '/api/bongos/profiles/p05-no-such-handle');
188
+ assert.equal(prov.status, 404);
189
+ assert.equal(ghost.status, 404);
190
+ assert.equal(prov.json.error.code, ghost.json.error.code, 'same envelope for both');
191
+ assertNoSentinels(prov.text, 'the provisional 404 body');
192
+ });
193
+
194
+ await t('an invalid handle → 404, not a 500', async () => {
195
+ const r = await get(port, '/api/bongos/profiles/not%20a%20handle!');
196
+ assert.equal(r.status, 404);
197
+ });
198
+
199
+ await t('a previous handle → 301 to the current one; the old handle never echoes', async () => {
200
+ const r = await get(port, `/api/bongos/profiles/${SECRET_PREV}`);
201
+ assert.equal(r.status, 301);
202
+ assert.ok(r.headers.location.endsWith('/profiles/p05-active'), `Location points at the current handle (got ${r.headers.location})`);
203
+ assert.deepEqual(r.json, { moved_to: 'p05-active' });
204
+ assert.ok(!r.text.includes(SECRET_PREV), 'the requested old handle is not echoed');
205
+ });
206
+
207
+ await t('domain projection is clean even off-route (the port surface)', async () => {
208
+ const extras = await pi.getPublicProfileExtras(ACTIVE, { pool });
209
+ assertNoSentinels(JSON.stringify(extras), 'getPublicProfileExtras');
210
+ const page = await pi.getPublicProfileByHandle('p05-active', { pool });
211
+ assertNoSentinels(JSON.stringify(page), 'getPublicProfileByHandle');
212
+ });
213
+
214
+ console.log(`\nhub_profile_leak: ${passed} passed, ${failed} failed`);
215
+ if (failed > 0) process.exitCode = 1;
216
+ } finally {
217
+ if (server) server.close();
218
+ await cleanup();
219
+ await pool.end().catch(() => {});
220
+ }