@bongos/core 1.20.20 → 1.20.21
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 +32 -22
- 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 +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +44 -3
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +2 -0
- package/modules/platform-identity/guild-map.js +105 -0
- package/modules/platform-identity/routes/guilds-public.js +36 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/guild_map_db.mjs +157 -0
- package/tests/guilds_api.mjs +74 -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.20.
|
|
6
|
-
"core_contract": "1.20.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.20.21",
|
|
6
|
+
"core_contract": "1.20.21",
|
|
7
|
+
"source_commit": "21843bf1398d569536fc45f748fae3c195be4ba2",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-30T15:20:08.598Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 557,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2638,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3221,
|
|
20
|
+
"tree_sha256": "07ce496b1770bfc67bcd98bdf44e2bc6a47d1f869682f5fba2db1ded196d0b07",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -327,12 +327,12 @@
|
|
|
327
327
|
{
|
|
328
328
|
"path": "clients/bongos-client/README.md",
|
|
329
329
|
"mode": "0000644",
|
|
330
|
-
"sha256": "
|
|
330
|
+
"sha256": "680d37464fa929b11cd7a02af6aa4fb144bedcde5134c3d81edac167d7b95980"
|
|
331
331
|
},
|
|
332
332
|
{
|
|
333
333
|
"path": "clients/bongos-client/bongos-client.global.js",
|
|
334
334
|
"mode": "0000644",
|
|
335
|
-
"sha256": "
|
|
335
|
+
"sha256": "c6dac0dab2b2d5fb996568cff8ead84e7f6ac9e036c3fab6a47893c79a99f8f4"
|
|
336
336
|
},
|
|
337
337
|
{
|
|
338
338
|
"path": "clients/bongos-client/examples/hello-world.mjs",
|
|
@@ -342,17 +342,17 @@
|
|
|
342
342
|
{
|
|
343
343
|
"path": "clients/bongos-client/index.cjs",
|
|
344
344
|
"mode": "0000644",
|
|
345
|
-
"sha256": "
|
|
345
|
+
"sha256": "baa7c833c3d18bf8250590fd94c499b3032ba05c6b9aa0af36b4660da295c799"
|
|
346
346
|
},
|
|
347
347
|
{
|
|
348
348
|
"path": "clients/bongos-client/index.d.ts",
|
|
349
349
|
"mode": "0000644",
|
|
350
|
-
"sha256": "
|
|
350
|
+
"sha256": "e9b2837873e241f20472ebde570b550718365a663282d4a85c255d98a8c8e21c"
|
|
351
351
|
},
|
|
352
352
|
{
|
|
353
353
|
"path": "clients/bongos-client/index.mjs",
|
|
354
354
|
"mode": "0000644",
|
|
355
|
-
"sha256": "
|
|
355
|
+
"sha256": "79240ff449f616e50aca58fc310a3b0670af84dc30d7a93991d84c0d0f4b87d2"
|
|
356
356
|
},
|
|
357
357
|
{
|
|
358
358
|
"path": "clients/bongos-client/package.json",
|
|
@@ -2242,12 +2242,12 @@
|
|
|
2242
2242
|
{
|
|
2243
2243
|
"path": "docs/api-reference.md",
|
|
2244
2244
|
"mode": "0000644",
|
|
2245
|
-
"sha256": "
|
|
2245
|
+
"sha256": "36a01165649c2ca2f1e29c592332efe08f3cffcd926a547e8de4121ca05c0469"
|
|
2246
2246
|
},
|
|
2247
2247
|
{
|
|
2248
2248
|
"path": "docs/api/openapi.json",
|
|
2249
2249
|
"mode": "0000644",
|
|
2250
|
-
"sha256": "
|
|
2250
|
+
"sha256": "37b9cfbb4cce621c7ba60d358a66b618d60f6706b4b1c2dec7215c88451cff1c"
|
|
2251
2251
|
},
|
|
2252
2252
|
{
|
|
2253
2253
|
"path": "docs/architecture.md",
|
|
@@ -2777,7 +2777,7 @@
|
|
|
2777
2777
|
{
|
|
2778
2778
|
"path": "docs/module-api-changelog.md",
|
|
2779
2779
|
"mode": "0000644",
|
|
2780
|
-
"sha256": "
|
|
2780
|
+
"sha256": "5be7dcf0c4dbcd61706bbaf8fec0edc509d32b721b7be1abae250dcff896f95e"
|
|
2781
2781
|
},
|
|
2782
2782
|
{
|
|
2783
2783
|
"path": "docs/modules-contract.md",
|
|
@@ -6864,6 +6864,11 @@
|
|
|
6864
6864
|
"mode": "0000644",
|
|
6865
6865
|
"sha256": "793e96feacef39ec52166dd7372c894a25679c7137c9c3c5e9df38ec323f1c4a"
|
|
6866
6866
|
},
|
|
6867
|
+
{
|
|
6868
|
+
"path": "modules/platform-identity/guild-map.js",
|
|
6869
|
+
"mode": "0000644",
|
|
6870
|
+
"sha256": "f550b6f42ef3e37c4390a1629d4c5f109285a130d2b5efd4ccbefa57acfe2cf1"
|
|
6871
|
+
},
|
|
6867
6872
|
{
|
|
6868
6873
|
"path": "modules/platform-identity/guild-totals.js",
|
|
6869
6874
|
"mode": "0000644",
|
|
@@ -7107,7 +7112,7 @@
|
|
|
7107
7112
|
{
|
|
7108
7113
|
"path": "modules/platform-identity/routes/guilds-public.js",
|
|
7109
7114
|
"mode": "0000644",
|
|
7110
|
-
"sha256": "
|
|
7115
|
+
"sha256": "3217ae9aa5d952b72711a4e723bec3c93b4b5d12975f056bb74e8e9f4ff5e7e3"
|
|
7111
7116
|
},
|
|
7112
7117
|
{
|
|
7113
7118
|
"path": "modules/platform-identity/routes/guilds.js",
|
|
@@ -8887,12 +8892,12 @@
|
|
|
8887
8892
|
{
|
|
8888
8893
|
"path": "package-lock.json",
|
|
8889
8894
|
"mode": "0000644",
|
|
8890
|
-
"sha256": "
|
|
8895
|
+
"sha256": "7ca4dcc58b8138d0f20002590f565c03952db6a97c8a740c09307c91b24872bf"
|
|
8891
8896
|
},
|
|
8892
8897
|
{
|
|
8893
8898
|
"path": "package.json",
|
|
8894
8899
|
"mode": "0000644",
|
|
8895
|
-
"sha256": "
|
|
8900
|
+
"sha256": "fc858fcc7b0fef1d6850cca8b5d5825250568cb16754f3452dc5a5d8f42545d5"
|
|
8896
8901
|
},
|
|
8897
8902
|
{
|
|
8898
8903
|
"path": "public-docs/index.html",
|
|
@@ -8912,7 +8917,7 @@
|
|
|
8912
8917
|
{
|
|
8913
8918
|
"path": "release-notes.json",
|
|
8914
8919
|
"mode": "0000644",
|
|
8915
|
-
"sha256": "
|
|
8920
|
+
"sha256": "1739d308859639e1ff64b6716cd4e04f117c094868c1ad8ad1f1fa1272232221"
|
|
8916
8921
|
},
|
|
8917
8922
|
{
|
|
8918
8923
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -10072,7 +10077,7 @@
|
|
|
10072
10077
|
{
|
|
10073
10078
|
"path": "scripts/gds/run-unit-tests.js",
|
|
10074
10079
|
"mode": "0000644",
|
|
10075
|
-
"sha256": "
|
|
10080
|
+
"sha256": "8a7b0e6b5d899bc2706d952d2383ee5b45758810b7f7d5bd5b7ebc2fc547e43e"
|
|
10076
10081
|
},
|
|
10077
10082
|
{
|
|
10078
10083
|
"path": "scripts/gds/runner-drift.js",
|
|
@@ -11027,7 +11032,7 @@
|
|
|
11027
11032
|
{
|
|
11028
11033
|
"path": "src/module-api.js",
|
|
11029
11034
|
"mode": "0000644",
|
|
11030
|
-
"sha256": "
|
|
11035
|
+
"sha256": "7e01ad4572ca48ef4dba7c5776246f2e7ba1a251a1bc079f1ae0566b9dc67593"
|
|
11031
11036
|
},
|
|
11032
11037
|
{
|
|
11033
11038
|
"path": "src/module-loader/catalog.js",
|
|
@@ -13074,6 +13079,11 @@
|
|
|
13074
13079
|
"mode": "0000644",
|
|
13075
13080
|
"sha256": "6e834f3cf75dd34ba30795c46bc401098053a56c967da8661128f34db71ff8c3"
|
|
13076
13081
|
},
|
|
13082
|
+
{
|
|
13083
|
+
"path": "tests/guild_map_db.mjs",
|
|
13084
|
+
"mode": "0000644",
|
|
13085
|
+
"sha256": "cc230b6df32f9f7ab7c81d0cca88af6d0ace233c800a4d29d43cd474d0deefa1"
|
|
13086
|
+
},
|
|
13077
13087
|
{
|
|
13078
13088
|
"path": "tests/guild_namespace_reserved.mjs",
|
|
13079
13089
|
"mode": "0000644",
|
|
@@ -13092,7 +13102,7 @@
|
|
|
13092
13102
|
{
|
|
13093
13103
|
"path": "tests/guilds_api.mjs",
|
|
13094
13104
|
"mode": "0000644",
|
|
13095
|
-
"sha256": "
|
|
13105
|
+
"sha256": "ae92262fb5fa1a79b1540538e7b9191ae47d19c3b4ae3572d07d470159f4ef91"
|
|
13096
13106
|
},
|
|
13097
13107
|
{
|
|
13098
13108
|
"path": "tests/guilds_db.mjs",
|
|
@@ -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
|
+
- 462 operations across 70 resource groups
|
|
9
9
|
|
|
10
10
|
## Use it from your project
|
|
11
11
|
|
|
@@ -499,6 +499,8 @@ function createClient(opts = {}) {
|
|
|
499
499
|
postGuildRequestsIdDecline: (args) => request("POST", "/guild-requests/{id}/decline", { hasBody: true }, args),
|
|
500
500
|
},
|
|
501
501
|
"guilds": {
|
|
502
|
+
// GET /guilds — rank: public — GET /guilds
|
|
503
|
+
getGuilds: (args) => request("GET", "/guilds", { hasBody: false }, args),
|
|
502
504
|
// POST /guilds — rank: any-builder — POST /guilds
|
|
503
505
|
postGuilds: (args) => request("POST", "/guilds", { hasBody: true }, args),
|
|
504
506
|
// DELETE /guilds/{slug} — rank: any-builder — DELETE /guilds/:slug
|
|
@@ -498,6 +498,8 @@ function createClient(opts = {}) {
|
|
|
498
498
|
postGuildRequestsIdDecline: (args) => request("POST", "/guild-requests/{id}/decline", { hasBody: true }, args),
|
|
499
499
|
},
|
|
500
500
|
"guilds": {
|
|
501
|
+
// GET /guilds — rank: public — GET /guilds
|
|
502
|
+
getGuilds: (args) => request("GET", "/guilds", { hasBody: false }, args),
|
|
501
503
|
// POST /guilds — rank: any-builder — POST /guilds
|
|
502
504
|
postGuilds: (args) => request("POST", "/guilds", { hasBody: true }, args),
|
|
503
505
|
// DELETE /guilds/{slug} — rank: any-builder — DELETE /guilds/:slug
|
|
@@ -72,6 +72,7 @@ export interface GetGovernmentAssignmentsResponse { assignments: unknown }
|
|
|
72
72
|
export interface GetGovernmentPermissionsResponse { permissions: unknown; principals: unknown }
|
|
73
73
|
export interface GetGovernmentRanksResponse { ranks: unknown }
|
|
74
74
|
export interface GetGradesByBuilderResponse { window_days: unknown; builders: unknown }
|
|
75
|
+
export interface GetGuildsResponse { order: unknown; count: unknown; results: unknown; page: unknown }
|
|
75
76
|
export interface GetGuildsSlugMembersResponse { guild: unknown; you: unknown; members: unknown; count: unknown }
|
|
76
77
|
export interface GetGuildsSlugRequestsResponse { incoming: unknown; outgoing: unknown; incoming_count: unknown; outgoing_count: unknown }
|
|
77
78
|
export interface GetGuildsSlugResponse { guild: unknown; members: unknown; size: unknown; totals: unknown }
|
|
@@ -925,6 +926,8 @@ export interface BongosClient {
|
|
|
925
926
|
postGuildRequestsIdDecline(args?: RequestArgs): Promise<ApiResponse>;
|
|
926
927
|
};
|
|
927
928
|
"guilds": {
|
|
929
|
+
/** GET /guilds — rank: public */
|
|
930
|
+
getGuilds(args?: RequestArgs): Promise<GetGuildsResponse>;
|
|
928
931
|
/** POST /guilds — rank: any-builder */
|
|
929
932
|
postGuilds(args: RequestArgs & { body: PostGuildsRequest }): Promise<PostGuildsResponse>;
|
|
930
933
|
/** DELETE /guilds/{slug} — rank: any-builder */
|
|
@@ -495,6 +495,8 @@ export function createClient(opts = {}) {
|
|
|
495
495
|
postGuildRequestsIdDecline: (args) => request("POST", "/guild-requests/{id}/decline", { hasBody: true }, args),
|
|
496
496
|
},
|
|
497
497
|
"guilds": {
|
|
498
|
+
// GET /guilds — rank: public — GET /guilds
|
|
499
|
+
getGuilds: (args) => request("GET", "/guilds", { hasBody: false }, args),
|
|
498
500
|
// POST /guilds — rank: any-builder — POST /guilds
|
|
499
501
|
postGuilds: (args) => request("POST", "/guilds", { hasBody: true }, args),
|
|
500
502
|
// DELETE /guilds/{slug} — rank: any-builder — DELETE /guilds/:slug
|
package/docs/api/openapi.json
CHANGED
|
@@ -8590,6 +8590,32 @@
|
|
|
8590
8590
|
}
|
|
8591
8591
|
},
|
|
8592
8592
|
"/guilds": {
|
|
8593
|
+
"get": {
|
|
8594
|
+
"operationId": "get_guilds",
|
|
8595
|
+
"tags": [
|
|
8596
|
+
"guilds"
|
|
8597
|
+
],
|
|
8598
|
+
"summary": "GET /guilds",
|
|
8599
|
+
"description": "the guild map (?order=size|shipped, default size; ?limit=&offset=). Viewer-independent: it reads no session. size is the public roster's length; shipped is R26's stored band floor (ADR 0337 D4.4), never an exact sum, null before a guild's first refresh. no-store: a hide or a visibility flip moves size on the very next read (ADR 0171 D5).\n\n**Rank:** `public` — No authentication — any caller.",
|
|
8600
|
+
"x-rank": "public",
|
|
8601
|
+
"x-source": "modules/platform-identity/routes/guilds-public.js",
|
|
8602
|
+
"responses": {
|
|
8603
|
+
"200": {
|
|
8604
|
+
"description": "Success.",
|
|
8605
|
+
"content": {
|
|
8606
|
+
"application/json": {
|
|
8607
|
+
"schema": {
|
|
8608
|
+
"$ref": "#/components/schemas/GetGuildsResponse"
|
|
8609
|
+
}
|
|
8610
|
+
}
|
|
8611
|
+
}
|
|
8612
|
+
},
|
|
8613
|
+
"400": {
|
|
8614
|
+
"$ref": "#/components/responses/BadRequest"
|
|
8615
|
+
}
|
|
8616
|
+
},
|
|
8617
|
+
"security": []
|
|
8618
|
+
},
|
|
8593
8619
|
"post": {
|
|
8594
8620
|
"operationId": "post_guilds",
|
|
8595
8621
|
"tags": [
|
|
@@ -22551,6 +22577,21 @@
|
|
|
22551
22577
|
"builders"
|
|
22552
22578
|
]
|
|
22553
22579
|
},
|
|
22580
|
+
"GetGuildsResponse": {
|
|
22581
|
+
"type": "object",
|
|
22582
|
+
"properties": {
|
|
22583
|
+
"order": {},
|
|
22584
|
+
"count": {},
|
|
22585
|
+
"results": {},
|
|
22586
|
+
"page": {}
|
|
22587
|
+
},
|
|
22588
|
+
"required": [
|
|
22589
|
+
"order",
|
|
22590
|
+
"count",
|
|
22591
|
+
"results",
|
|
22592
|
+
"page"
|
|
22593
|
+
]
|
|
22594
|
+
},
|
|
22554
22595
|
"GetGuildsSlugMembersResponse": {
|
|
22555
22596
|
"type": "object",
|
|
22556
22597
|
"properties": {
|
|
@@ -29523,9 +29564,9 @@
|
|
|
29523
29564
|
"description": "A required dependency/feature is not configured or is temporarily down."
|
|
29524
29565
|
}
|
|
29525
29566
|
},
|
|
29526
|
-
"x-endpoint-count":
|
|
29527
|
-
"x-schema-count":
|
|
29567
|
+
"x-endpoint-count": 462,
|
|
29568
|
+
"x-schema-count": 494,
|
|
29528
29569
|
"x-undocumented-bodies": 12,
|
|
29529
|
-
"x-response-schemas":
|
|
29570
|
+
"x-response-schemas": 336,
|
|
29530
29571
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
29531
29572
|
}
|
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. 462 endpoints across 82 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/0016-trust-boundary-server-enforced-permissions.md)): `public` < `any-builder` < `metic+archon` < `archon`.
|
|
@@ -342,10 +342,11 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
|
|
|
342
342
|
| POST | `/api/bongos/guild-requests/:id/accept` | `any-builder` | `visibility`, `counted_in_totals` | accept an invite addressed to ME, or (as the guild's owner) a request to join it. |
|
|
343
343
|
| POST | `/api/bongos/guild-requests/:id/decline` | `any-builder` | — | decline an invite to ME, or (as owner) a request to my guild. |
|
|
344
344
|
|
|
345
|
-
## `guilds` (
|
|
345
|
+
## `guilds` (13)
|
|
346
346
|
|
|
347
347
|
| Method | Path | Rank | Body | Description |
|
|
348
348
|
|---|---|---|---|---|
|
|
349
|
+
| GET | `/api/bongos/guilds` | `public` | — | the guild map (?order=size\|shipped, default size; ?limit=&offset=). |
|
|
349
350
|
| POST | `/api/bongos/guilds` | `any-builder` | `slug`, `name`, `description`, `visibility` | create a guild. |
|
|
350
351
|
| GET | `/api/bongos/guilds/:slug` | `public` | — | a public guild's page data. |
|
|
351
352
|
| PATCH | `/api/bongos/guilds/:slug` | `any-builder` | `name`, `description`, `visibility` | owner: edit name, description or visibility. |
|
|
@@ -2697,5 +2697,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2697
2697
|
landed since 1.20.18 with no explicit bump. run 36727774141. (task 1002620)
|
|
2698
2698
|
1.20.20 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2699
2699
|
landed since 1.20.19 with no explicit bump. run 36732445320. (task 1002620)
|
|
2700
|
+
1.20.21 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2701
|
+
landed since 1.20.20 with no explicit bump. run 36735857856. (task 1002620)
|
|
2700
2702
|
---------------------------------------------------------------------------
|
|
2701
2703
|
```
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// modules/platform-identity/guild-map.js — the guild map's data: public guilds,
|
|
2
|
+
// ranked (R30, task 1002306; ADR 0336 D8, amended by ADR 0337 D4.4).
|
|
3
|
+
//
|
|
4
|
+
// WHO. `visibility = 'public'` AND a non-empty public roster. An unlisted guild
|
|
5
|
+
// has no public existence (ADR 0336 D4), so it is never listed. Neither is a
|
|
6
|
+
// public guild whose every member is private or unshown: it would be a name on
|
|
7
|
+
// the map with nobody behind it.
|
|
8
|
+
//
|
|
9
|
+
// TWO ORDERINGS, NO STORED SCORE (D8):
|
|
10
|
+
// * size — the public roster's length, computed exactly as getPublicGuild
|
|
11
|
+
// computes it (shown_publicly AND accountVisibleSql, capped at
|
|
12
|
+
// guilds.LIST_MAX), so the map and the guild page never disagree.
|
|
13
|
+
// hide_stats hides numbers, never membership (ADR 0336 D4 §2), so a
|
|
14
|
+
// member who hides their numbers is still in it; a private one never.
|
|
15
|
+
// * shipped — the guild's STORED works_shipped band floor (guild-totals.js),
|
|
16
|
+
// never an exact sum (D4.4). A guild not yet refreshed publishes
|
|
17
|
+
// null and sorts last.
|
|
18
|
+
// Ties break on slug. Every sort key is a field the row publishes (ADR 0255: a
|
|
19
|
+
// public list's order is a covert column otherwise).
|
|
20
|
+
//
|
|
21
|
+
// COST, AND WHY THERE IS NO CACHE. One aggregate per read, over PUBLIC guilds'
|
|
22
|
+
// memberships only (the roster CTE filters on visibility before it groups, so an
|
|
23
|
+
// unlisted guild's members are never counted, let alone scanned for the map) —
|
|
24
|
+
// at the hub's scale a few index scans, bounded per caller by guild-map-read.
|
|
25
|
+
// D8 decides "computed at read time" and names the revisit trigger: a measured
|
|
26
|
+
// p95. It also rules out the cheap cache: any cache must be INVALIDATED by a hide,
|
|
27
|
+
// a privacy flip, a shown_publicly change, a membership change and a visibility
|
|
28
|
+
// change, because a TTL alone keeps publishing a withdrawn member in a guild's
|
|
29
|
+
// size (the ADR 0202 D3 reason). community-leaderboard.js's 30s memo does not
|
|
30
|
+
// transfer: it caches only NUMBERS and re-reads WHO on every request, and here
|
|
31
|
+
// the expensive half IS who — the roster under the privacy predicates. A cache
|
|
32
|
+
// is therefore a write-path change across guilds.js, the privacy setters and
|
|
33
|
+
// consent, taken when the p95 says so, not a TTL bolted on here.
|
|
34
|
+
//
|
|
35
|
+
// NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). Descriptive only.
|
|
36
|
+
//
|
|
37
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const api = require('../../src/module-api');
|
|
41
|
+
const { accountVisibleSql } = require('./account-visibility');
|
|
42
|
+
const { LIST_MAX } = require('./guilds');
|
|
43
|
+
const { clampOffset, MAX_OFFSET } = require('./builder-search');
|
|
44
|
+
|
|
45
|
+
const { pool: defaultPool } = api;
|
|
46
|
+
|
|
47
|
+
const DEFAULT_LIMIT = 25;
|
|
48
|
+
const MAX_LIMIT = 100;
|
|
49
|
+
|
|
50
|
+
// The orderings, as fixed SQL. A request names a key; it never supplies SQL.
|
|
51
|
+
const ORDERS = {
|
|
52
|
+
size: 'size DESC, g.slug',
|
|
53
|
+
shipped: 'shipped DESC NULLS LAST, g.slug',
|
|
54
|
+
};
|
|
55
|
+
const ORDER_KEYS = Object.keys(ORDERS);
|
|
56
|
+
const DEFAULT_ORDER = 'size';
|
|
57
|
+
|
|
58
|
+
function isOrder(order) {
|
|
59
|
+
return typeof order === 'string' && Object.prototype.hasOwnProperty.call(ORDERS, order);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clampLimit(limit) {
|
|
63
|
+
const n = parseInt(limit, 10);
|
|
64
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT;
|
|
65
|
+
return Math.min(n, MAX_LIMIT);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// One page of the map. `order` must already be valid (the route 400s first; an
|
|
69
|
+
// invalid one here falls back to the default rather than reaching SQL). Returns
|
|
70
|
+
// { order, results: [{ slug, name, size, shipped }], limit, offset } with the
|
|
71
|
+
// CLAMPED paging, so the route echoes what was actually applied.
|
|
72
|
+
async function listGuildMap({ order = DEFAULT_ORDER, limit, offset } = {}, { pool = defaultPool } = {}) {
|
|
73
|
+
const key = isOrder(order) ? order : DEFAULT_ORDER;
|
|
74
|
+
const lim = clampLimit(limit);
|
|
75
|
+
const off = clampOffset(offset);
|
|
76
|
+
const { rows } = await pool.query(
|
|
77
|
+
`WITH roster AS (
|
|
78
|
+
SELECT m.guild_id, LEAST(count(*), $3)::int AS size
|
|
79
|
+
FROM platform_identity_guilds pg
|
|
80
|
+
JOIN platform_identity_guild_members m ON m.guild_id = pg.id
|
|
81
|
+
JOIN platform_identity_accounts pa ON pa.github_id = m.github_id
|
|
82
|
+
WHERE pg.visibility = 'public' AND m.shown_publicly AND ${accountVisibleSql('pa')}
|
|
83
|
+
GROUP BY m.guild_id
|
|
84
|
+
)
|
|
85
|
+
SELECT g.slug, g.name, r.size, t.works_shipped AS shipped
|
|
86
|
+
FROM platform_identity_guilds g
|
|
87
|
+
JOIN roster r ON r.guild_id = g.id
|
|
88
|
+
LEFT JOIN platform_identity_guild_totals t ON t.guild_id = g.id
|
|
89
|
+
WHERE g.visibility = 'public'
|
|
90
|
+
ORDER BY ${ORDERS[key]}
|
|
91
|
+
LIMIT $1 OFFSET $2`,
|
|
92
|
+
[lim, off, LIST_MAX],
|
|
93
|
+
);
|
|
94
|
+
return { order: key, results: rows, limit: lim, offset: off };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
ORDER_KEYS,
|
|
99
|
+
DEFAULT_ORDER,
|
|
100
|
+
DEFAULT_LIMIT,
|
|
101
|
+
MAX_LIMIT,
|
|
102
|
+
MAX_OFFSET,
|
|
103
|
+
isOrder,
|
|
104
|
+
listGuildMap,
|
|
105
|
+
};
|
|
@@ -11,12 +11,17 @@
|
|
|
11
11
|
// GET /guilds/:slug/members. The page at /g/<slug> is task 1002301's; this is
|
|
12
12
|
// its read.
|
|
13
13
|
//
|
|
14
|
+
// GET /guilds — the guild MAP (task 1002306, R30; ADR 0336 D8): public guilds
|
|
15
|
+
// with a non-empty public roster, ranked by size or by the stored shipped band
|
|
16
|
+
// floor, slug tiebreak, paged. Who and how live in ../guild-map.js.
|
|
17
|
+
//
|
|
14
18
|
// Reaches core only through the doorway (ADR 0083).
|
|
15
19
|
'use strict';
|
|
16
20
|
|
|
17
21
|
const express = require('express');
|
|
18
22
|
const api = require('../../../src/module-api');
|
|
19
23
|
const guilds = require('../guilds');
|
|
24
|
+
const guildMap = require('../guild-map');
|
|
20
25
|
const { createReadRateLimit } = require('../read-rate-limit');
|
|
21
26
|
|
|
22
27
|
const { pool } = api;
|
|
@@ -34,9 +39,40 @@ function logError(msg) {
|
|
|
34
39
|
// uncapped scraper could walk the slug space at line rate.
|
|
35
40
|
const guildReadRateLimit = createReadRateLimit({ scope: 'guild-public-read', limit: 120 });
|
|
36
41
|
|
|
42
|
+
// The map's own budget: one read aggregates every public guild's roster, so it
|
|
43
|
+
// gets the community reads' tighter cap rather than the single-guild one.
|
|
44
|
+
const guildMapRateLimit = createReadRateLimit({ scope: 'guild-map-read', limit: 60 });
|
|
45
|
+
|
|
37
46
|
module.exports = function guildPublicRoutes() {
|
|
38
47
|
const router = express.Router();
|
|
39
48
|
|
|
49
|
+
// rank: public — the guild map (?order=size|shipped, default size;
|
|
50
|
+
// ?limit=&offset=). Viewer-independent: it reads no session. size is the
|
|
51
|
+
// public roster's length; shipped is R26's stored band floor (ADR 0337 D4.4),
|
|
52
|
+
// never an exact sum, null before a guild's first refresh. no-store: a hide or
|
|
53
|
+
// a visibility flip moves size on the very next read (ADR 0171 D5).
|
|
54
|
+
router.get('/guilds', guildMapRateLimit, async (req, res) => {
|
|
55
|
+
const order = req.query.order === undefined || req.query.order === ''
|
|
56
|
+
? guildMap.DEFAULT_ORDER
|
|
57
|
+
: req.query.order;
|
|
58
|
+
if (!guildMap.isOrder(order)) {
|
|
59
|
+
return res.fail('guild_map_bad_order', { status: 400, message: `order must be one of: ${guildMap.ORDER_KEYS.join(', ')}` });
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const map = await guildMap.listGuildMap({ order, limit: req.query.limit, offset: req.query.offset }, { pool });
|
|
63
|
+
res.set('Cache-Control', 'no-store');
|
|
64
|
+
res.json({
|
|
65
|
+
order: map.order,
|
|
66
|
+
count: map.results.length,
|
|
67
|
+
results: map.results,
|
|
68
|
+
page: { limit: map.limit, offset: map.offset },
|
|
69
|
+
});
|
|
70
|
+
} catch (err) {
|
|
71
|
+
logError(`GET /guilds failed: ${err && err.message}`);
|
|
72
|
+
res.fail('guild_map_failed', { status: 500, message: 'internal error' });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
40
76
|
// rank: public — a public guild's page data. The roster is the members who
|
|
41
77
|
// agreed to be shown AND whose own accounts are publicly visible (ADR 0336 D4);
|
|
42
78
|
// size is that roster's length; totals are the weekly band floors (ADR 0337
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.21",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.21",
|
|
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.20.
|
|
3
|
+
"version": "1.20.21",
|
|
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/release-notes.json
CHANGED
|
@@ -8176,5 +8176,11 @@
|
|
|
8176
8176
|
"id": "1002302",
|
|
8177
8177
|
"text": "Guilds now show a combined track record: rounded totals of their members' credits, shipped work and projects, refreshed once a week. Members who keep their numbers private still count, but only when at least three such members"
|
|
8178
8178
|
}
|
|
8179
|
+
],
|
|
8180
|
+
"1.20.21": [
|
|
8181
|
+
{
|
|
8182
|
+
"id": "1002306",
|
|
8183
|
+
"text": "The hub can now list guilds ranked by size or by how much their members have shipped, using the rounded weekly totals. Only public guilds with visible members appear, and private members are never counted."
|
|
8184
|
+
}
|
|
8179
8185
|
]
|
|
8180
8186
|
}
|
|
@@ -183,6 +183,10 @@ const INTEGRATION = new Set([
|
|
|
183
183
|
// re-evaluated against a racing writer's committed row, are planner facts; the
|
|
184
184
|
// DB-free sibling (guild_totals) pins the band scale and statement shapes.
|
|
185
185
|
'guild_totals_db',
|
|
186
|
+
// task 1002306 (R30): the guild map against real SQL — the INNER join that drops
|
|
187
|
+
// an empty public roster, the capped roster count, and NULLS LAST on a guild
|
|
188
|
+
// with no weekly record yet. guilds_api §3b pins the route and statement shape.
|
|
189
|
+
'guild_map_db',
|
|
186
190
|
// task 1003935: 'linkify_refs' was swept in with the three above without the
|
|
187
191
|
// no-DB check they were being fixed for. It touches no Postgres at all — pure
|
|
188
192
|
// string work plus a filesystem walk — so the DB-free lane skipped it and the
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.21'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// tests/guild_map_db.mjs — the guild map against REAL SQL (R30, task 1002306;
|
|
2
|
+
// ADR 0336 D8, amended by ADR 0337 D4.4).
|
|
3
|
+
//
|
|
4
|
+
// Why a real database: who is listed is an INNER join to an aggregated roster,
|
|
5
|
+
// size is a capped count under the shared existence predicate, and the shipped
|
|
6
|
+
// ordering puts un-refreshed guilds (NULL) last — each a planner fact the
|
|
7
|
+
// DB-free route test (tests/guilds_api.mjs §3b) can only assert the shape of.
|
|
8
|
+
//
|
|
9
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
10
|
+
// INTEGRATION set so the default DB-free lane never runs it. Fixture ids are
|
|
11
|
+
// high-numbered and every guild slug starts r30-, so a shared DB is safe; all
|
|
12
|
+
// fixtures are deleted before and after.
|
|
13
|
+
//
|
|
14
|
+
// Run: DATABASE_URL=... node tests/guild_map_db.mjs
|
|
15
|
+
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { Pool } = require('pg');
|
|
24
|
+
|
|
25
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
27
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
28
|
+
|
|
29
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
30
|
+
try {
|
|
31
|
+
await pool.query('SELECT 1');
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.log(`guild_map_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
34
|
+
await pool.end().catch(() => {});
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const guildMap = require('../modules/platform-identity/guild-map.js');
|
|
39
|
+
const guilds = require('../modules/platform-identity/guilds.js');
|
|
40
|
+
|
|
41
|
+
const A = 9002306001; // public
|
|
42
|
+
const B = 9002306002; // public
|
|
43
|
+
const C = 9002306003; // public
|
|
44
|
+
const HID = 9002306004; // public account, hides their numbers
|
|
45
|
+
const PRIV = 9002306005; // private account
|
|
46
|
+
const PROV = 9002306006; // provisional
|
|
47
|
+
const ALL_IDS = [A, B, C, HID, PRIV, PROV];
|
|
48
|
+
|
|
49
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
50
|
+
async function cleanup() {
|
|
51
|
+
await q(`DELETE FROM platform_identity_guilds WHERE slug LIKE 'r30-%'`).catch(() => {});
|
|
52
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let pass = 0;
|
|
56
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
57
|
+
|
|
58
|
+
async function account(id, handle, { visibility = 'public', hide = false, state = 'active' } = {}) {
|
|
59
|
+
await q(
|
|
60
|
+
`INSERT INTO platform_identity_accounts
|
|
61
|
+
(github_id, github_login, handle, display_name, profile_state, terms_accepted_at, account_visibility, hide_stats)
|
|
62
|
+
VALUES ($1, $2, $2, $2, $3, CASE WHEN $3 = 'active' THEN now() END, $4, $5)`,
|
|
63
|
+
[id, handle, state, visibility, hide],
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
async function guild(slug, visibility = 'public') {
|
|
67
|
+
const { rows } = await q(
|
|
68
|
+
`INSERT INTO platform_identity_guilds (slug, name, visibility) VALUES ($1, $1, $2) RETURNING id`,
|
|
69
|
+
[slug, visibility],
|
|
70
|
+
);
|
|
71
|
+
return rows[0].id;
|
|
72
|
+
}
|
|
73
|
+
async function member(gid, id, { kind = 'member', shown = true } = {}) {
|
|
74
|
+
await q(
|
|
75
|
+
`INSERT INTO platform_identity_guild_members (guild_id, github_id, membership_kind, shown_publicly)
|
|
76
|
+
VALUES ($1, $2, $3, $4)`,
|
|
77
|
+
[gid, id, kind, shown],
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const totals = (gid, shipped) => q(
|
|
81
|
+
`INSERT INTO platform_identity_guild_totals (guild_id, credits, works_shipped, karma, projects)
|
|
82
|
+
VALUES ($1, 0, $2, 0, 0)`, [gid, shipped],
|
|
83
|
+
);
|
|
84
|
+
// Only this file's guilds: a shared DB may hold others.
|
|
85
|
+
const mine = async (order) => (await guildMap.listGuildMap({ order, limit: 100 }, { pool }))
|
|
86
|
+
.results.filter((r) => r.slug.startsWith('r30-'));
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
for (const f of [
|
|
90
|
+
'platform_identity_001_tables.sql', 'platform_identity_007_profile_consent.sql',
|
|
91
|
+
'platform_identity_008_handle.sql', 'platform_identity_012_account_privacy.sql',
|
|
92
|
+
'platform_identity_019_terms_acceptance.sql', 'platform_identity_025_guilds.sql',
|
|
93
|
+
'platform_identity_027_guild_totals.sql',
|
|
94
|
+
]) await q(migrationSql(f));
|
|
95
|
+
await cleanup();
|
|
96
|
+
|
|
97
|
+
await account(A, 'r30a');
|
|
98
|
+
await account(B, 'r30b');
|
|
99
|
+
await account(C, 'r30c');
|
|
100
|
+
await account(HID, 'r30hid', { hide: true });
|
|
101
|
+
await account(PRIV, 'r30priv', { visibility: 'private' });
|
|
102
|
+
await account(PROV, 'r30prov', { state: 'provisional' });
|
|
103
|
+
|
|
104
|
+
// r30-big: three public members, one who hides numbers (still shown), one private (never).
|
|
105
|
+
const big = await guild('r30-big');
|
|
106
|
+
await member(big, A, { kind: 'owner' });
|
|
107
|
+
await member(big, B);
|
|
108
|
+
await member(big, HID);
|
|
109
|
+
await member(big, PRIV);
|
|
110
|
+
await totals(big, 20);
|
|
111
|
+
// r30-alpha / r30-beta: two each, a size tie broken on slug; beta has no record yet.
|
|
112
|
+
const alpha = await guild('r30-alpha');
|
|
113
|
+
await member(alpha, A, { kind: 'owner' });
|
|
114
|
+
await member(alpha, C);
|
|
115
|
+
await totals(alpha, 50);
|
|
116
|
+
const beta = await guild('r30-beta');
|
|
117
|
+
await member(beta, B, { kind: 'owner' });
|
|
118
|
+
await member(beta, C);
|
|
119
|
+
// r30-gamma: same shipped band as big (tie on shipped → slug).
|
|
120
|
+
const gamma = await guild('r30-gamma');
|
|
121
|
+
await member(gamma, C, { kind: 'owner' });
|
|
122
|
+
await totals(gamma, 20);
|
|
123
|
+
// Never on the map: unlisted; a public guild whose only members are private,
|
|
124
|
+
// provisional or unshown.
|
|
125
|
+
const quiet = await guild('r30-quiet', 'unlisted');
|
|
126
|
+
await member(quiet, A, { kind: 'owner', shown: false });
|
|
127
|
+
await member(quiet, B, { shown: false });
|
|
128
|
+
const hollow = await guild('r30-hollow');
|
|
129
|
+
await member(hollow, PRIV, { kind: 'owner' });
|
|
130
|
+
await member(hollow, PROV);
|
|
131
|
+
await member(hollow, A, { shown: false });
|
|
132
|
+
|
|
133
|
+
same((await mine('size')).map((r) => [r.slug, r.size]),
|
|
134
|
+
[['r30-big', 3], ['r30-alpha', 2], ['r30-beta', 2], ['r30-gamma', 1]],
|
|
135
|
+
'size order: public roster length, slug tiebreak; the hidden member counts, the private one never; unlisted and empty-roster guilds absent');
|
|
136
|
+
same((await mine('shipped')).map((r) => [r.slug, r.shipped]),
|
|
137
|
+
[['r30-alpha', 50], ['r30-big', 20], ['r30-gamma', 20], ['r30-beta', null]],
|
|
138
|
+
'shipped order: the stored band floor, slug tiebreak, a guild with no record yet last');
|
|
139
|
+
|
|
140
|
+
const page = await guilds.getPublicGuild('r30-big', { pool });
|
|
141
|
+
same((await mine('size')).find((r) => r.slug === 'r30-big').size, page.size,
|
|
142
|
+
'the map\'s size is exactly the guild page\'s size');
|
|
143
|
+
|
|
144
|
+
// Hides beat the map on the very next read: B goes private.
|
|
145
|
+
await q(`UPDATE platform_identity_accounts SET account_visibility = 'private' WHERE github_id = $1`, [B]);
|
|
146
|
+
same((await mine('size')).map((r) => [r.slug, r.size]),
|
|
147
|
+
[['r30-alpha', 2], ['r30-big', 2], ['r30-beta', 1], ['r30-gamma', 1]],
|
|
148
|
+
'a member going private leaves every guild\'s size on the next read — nothing cached');
|
|
149
|
+
|
|
150
|
+
same(Object.keys((await mine('size'))[0]).sort(), ['name', 'shipped', 'size', 'slug'],
|
|
151
|
+
'a row carries only what it publishes');
|
|
152
|
+
|
|
153
|
+
console.log(`\nguild_map_db.mjs: ${pass} passed`);
|
|
154
|
+
} finally {
|
|
155
|
+
await cleanup();
|
|
156
|
+
await pool.end();
|
|
157
|
+
}
|
package/tests/guilds_api.mjs
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
// 3. THE PUBLIC READ — 404 for an unlisted guild is byte-identical to an
|
|
11
11
|
// unknown slug, needs no session, is no-store, and its roster statement
|
|
12
12
|
// carries shown_publicly AND the shared existence predicate.
|
|
13
|
+
// 3b. THE GUILD MAP (task 1002306) — public guilds with a non-empty public
|
|
14
|
+
// roster, sized by the guild page's own predicate and cap, ranked only by
|
|
15
|
+
// published fields (size, or the STORED shipped band floor), paging
|
|
16
|
+
// clamped, and an unknown order refused before any SQL.
|
|
13
17
|
// 4. SILENCE — a request written and one swallowed by a decline cooldown
|
|
14
18
|
// answer identically; a decline is an empty 204.
|
|
15
19
|
// 5. THE CARD — an invite accept must echo the visibility its card showed,
|
|
@@ -90,6 +94,7 @@ require.cache[moduleApiPath] = {
|
|
|
90
94
|
},
|
|
91
95
|
};
|
|
92
96
|
const guilds = require('../modules/platform-identity/guilds.js');
|
|
97
|
+
const guildMap = require('../modules/platform-identity/guild-map.js');
|
|
93
98
|
const buildGuildRoutes = require('../modules/platform-identity/routes/guilds.js');
|
|
94
99
|
const buildPublicRoutes = require('../modules/platform-identity/routes/guilds-public.js');
|
|
95
100
|
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
@@ -281,6 +286,75 @@ test('public read: the roster is shown_publicly AND the shared existence predica
|
|
|
281
286
|
assert.equal(calls[0].params[0], 'guild-a', 'the slug is looked up in canonical form');
|
|
282
287
|
});
|
|
283
288
|
|
|
289
|
+
// ===========================================================================
|
|
290
|
+
// 3b. The guild map (R30, task 1002306; ADR 0336 D8 + ADR 0337 D4.4)
|
|
291
|
+
// ===========================================================================
|
|
292
|
+
|
|
293
|
+
const IS_MAP = (t) => /WITH roster AS/.test(t) && /FROM platform_identity_guilds g/.test(t);
|
|
294
|
+
|
|
295
|
+
test('guild map: public guilds with a public roster, sized like the guild page, ranked by size then slug', async () => {
|
|
296
|
+
on((t) => (IS_MAP(t) ? result([
|
|
297
|
+
{ slug: 'big', name: 'Big', size: 9, shipped: 50 },
|
|
298
|
+
{ slug: 'new', name: 'New', size: 2, shipped: null },
|
|
299
|
+
]) : result()));
|
|
300
|
+
session = null; // the map must not need one
|
|
301
|
+
try {
|
|
302
|
+
const res = await call('GET', '/api/bongos/guilds');
|
|
303
|
+
assert.equal(res.status, 200);
|
|
304
|
+
assert.equal(res.headers['cache-control'], 'no-store');
|
|
305
|
+
assert.deepEqual(Object.keys(res.json).sort(), ['count', 'order', 'page', 'results']);
|
|
306
|
+
assert.equal(res.json.order, 'size', 'size is the default ordering');
|
|
307
|
+
assert.equal(res.json.count, res.json.results.length);
|
|
308
|
+
assert.deepEqual(res.json.page, { limit: guildMap.DEFAULT_LIMIT, offset: 0 });
|
|
309
|
+
assert.equal(calls.length, 1, 'one statement, and no read of the viewer');
|
|
310
|
+
const { text, params } = calls[0];
|
|
311
|
+
assert.match(text, /AND m\.shown_publicly AND \(\(pa\.profile_state = 'active' AND pa\.terms_accepted_at IS NOT NULL\) AND pa\.account_visibility = 'public'\)/,
|
|
312
|
+
'size is the public roster: shown_publicly AND accountVisibleSql — the getPublicGuild predicate');
|
|
313
|
+
assert.match(text, /JOIN roster r ON r\.guild_id = g\.id/, 'an INNER join: a guild with an empty public roster is not on the map');
|
|
314
|
+
assert.match(text, /WHERE g\.visibility = 'public'/, 'an unlisted guild is never listed');
|
|
315
|
+
assert.match(text, /LEAST\(count\(\*\), \$3\)/, 'size is capped like the guild page\'s roster');
|
|
316
|
+
assert.match(text.split('SELECT g.slug')[0], /WHERE pg\.visibility = 'public' AND m\.shown_publicly/,
|
|
317
|
+
'the aggregate groups PUBLIC guilds\' memberships only — an unlisted guild is never scanned for the map');
|
|
318
|
+
assert.deepEqual(params, [guildMap.DEFAULT_LIMIT, 0, guilds.LIST_MAX]);
|
|
319
|
+
assert.match(text, /ORDER BY size DESC, g\.slug\s+LIMIT/, 'ties break on slug');
|
|
320
|
+
} finally { session = { github_id: ME, github_login: 'octo' }; }
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('guild map: shipped ranks by the STORED band floor, never an exact sum; a guild not yet refreshed sorts last', async () => {
|
|
324
|
+
on((t) => (IS_MAP(t) ? result([]) : result()));
|
|
325
|
+
const res = await call('GET', '/api/bongos/guilds?order=shipped');
|
|
326
|
+
assert.equal(res.status, 200);
|
|
327
|
+
assert.equal(res.json.order, 'shipped');
|
|
328
|
+
const { text } = calls[0];
|
|
329
|
+
assert.match(text, /LEFT JOIN platform_identity_guild_totals t ON t\.guild_id = g\.id/);
|
|
330
|
+
assert.match(text, /t\.works_shipped AS shipped/, 'the published key IS the sort key (ADR 0255)');
|
|
331
|
+
assert.match(text, /ORDER BY shipped DESC NULLS LAST, g\.slug/);
|
|
332
|
+
assert.doesNotMatch(text, /platform_identity_builder_project_activity/, 'no exact sum is ever read here (ADR 0337 D4.4)');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('guild map: a row carries slug, name, size and shipped — nothing that names an account', async () => {
|
|
336
|
+
on((t) => (IS_MAP(t) ? result([]) : result()));
|
|
337
|
+
await call('GET', '/api/bongos/guilds');
|
|
338
|
+
const select = calls[0].text.split(/SELECT g\.slug/)[1].split('FROM')[0];
|
|
339
|
+
assert.equal(`g.slug${select}`.replace(/\s+/g, ' ').trim(), 'g.slug, g.name, r.size, t.works_shipped AS shipped');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('guild map: an unknown order is a 400 that reaches no SQL; paging is clamped and echoed', async () => {
|
|
343
|
+
on(() => result());
|
|
344
|
+
const bad = await call('GET', '/api/bongos/guilds?order=credits');
|
|
345
|
+
assert.equal(bad.status, 400);
|
|
346
|
+
assert.equal(bad.json.error.code, 'guild_map_bad_order');
|
|
347
|
+
assert.equal(calls.length, 0);
|
|
348
|
+
|
|
349
|
+
on((t) => (IS_MAP(t) ? result([]) : result()));
|
|
350
|
+
const big = await call('GET', '/api/bongos/guilds?limit=1000&offset=99999');
|
|
351
|
+
assert.deepEqual(big.json.page, { limit: guildMap.MAX_LIMIT, offset: guildMap.MAX_OFFSET });
|
|
352
|
+
assert.deepEqual(calls[0].params.slice(0, 2), [guildMap.MAX_LIMIT, guildMap.MAX_OFFSET]);
|
|
353
|
+
on((t) => (IS_MAP(t) ? result([]) : result()));
|
|
354
|
+
const junk = await call('GET', '/api/bongos/guilds?limit=-3&offset=abc');
|
|
355
|
+
assert.deepEqual(junk.json.page, { limit: guildMap.DEFAULT_LIMIT, offset: 0 });
|
|
356
|
+
});
|
|
357
|
+
|
|
284
358
|
// ===========================================================================
|
|
285
359
|
// 4. Join requests and silence (D3)
|
|
286
360
|
// ===========================================================================
|