@bongos/core 1.19.1077 → 1.19.1078

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.1077",
6
- "core_contract": "1.19.1077",
7
- "source_commit": "8213cd0b6b6ae2e91300c953bee2bed1d3558da3",
5
+ "core_version": "1.19.1078",
6
+ "core_contract": "1.19.1078",
7
+ "source_commit": "411c9ed509c000585d51b84c04a797ec9b15f67d",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-29T10:54:54.299Z",
9
+ "built_at": "2026-09-29T16:46:58.351Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 550,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2530,
14
+ "functional_verbatim": 2533,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3106,
20
- "tree_sha256": "90651971ad753afab30ed83277bfc27c910ad7279d6264170b9045d3726e1d5f",
19
+ "file_count": 3109,
20
+ "tree_sha256": "391c5cd61e61bb87908e73e23c99d7e86ea0f2467f47dfd2a63ebb2b475f435b",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -322,12 +322,12 @@
322
322
  {
323
323
  "path": "clients/bongos-client/README.md",
324
324
  "mode": "0000644",
325
- "sha256": "9d1e621dcf8b0a3b92cd67f700fd1d48f469d55833b494147e0a4c29be452d37"
325
+ "sha256": "aa2d78e23e985228caf64e4b9bdacc0caa55f07f1b0d883520b5a97e112427b2"
326
326
  },
327
327
  {
328
328
  "path": "clients/bongos-client/bongos-client.global.js",
329
329
  "mode": "0000644",
330
- "sha256": "9fcd3ca52df7d24f22ee6efcd5a0167af53835ec8bd3ffad139185a7aebb90b0"
330
+ "sha256": "1267fb539bb527d61edc8b3372eca5f4e9b478b8bf2f23c5aed3760ad4d1d09c"
331
331
  },
332
332
  {
333
333
  "path": "clients/bongos-client/examples/hello-world.mjs",
@@ -337,17 +337,17 @@
337
337
  {
338
338
  "path": "clients/bongos-client/index.cjs",
339
339
  "mode": "0000644",
340
- "sha256": "0bbe77783e3bf8a1dd8bc594dc28372a8c982be8e10a468f97d17ec8a5ff8cfc"
340
+ "sha256": "4a5ca3b3f81ecc0d068f4456662342c2be6c609668c730bf8b90eb255ac6590f"
341
341
  },
342
342
  {
343
343
  "path": "clients/bongos-client/index.d.ts",
344
344
  "mode": "0000644",
345
- "sha256": "7aa307fe6518433b5d85216d098c1d4cfedb25995aceb5717113e2233de62f48"
345
+ "sha256": "3f262f4b629221e5ac75344d23d2b07d51a8003b91567f1a7e96f6b354695e46"
346
346
  },
347
347
  {
348
348
  "path": "clients/bongos-client/index.mjs",
349
349
  "mode": "0000644",
350
- "sha256": "a9ca2bf984f00e1b6506904f789fccf8acfedd7ca7418322f50713d82680f783"
350
+ "sha256": "aa9e5ed9bf436f0e0eb28eb6a14a394668f614c5d30bdb31eb7bcd92335ff456"
351
351
  },
352
352
  {
353
353
  "path": "clients/bongos-client/package.json",
@@ -2207,17 +2207,17 @@
2207
2207
  {
2208
2208
  "path": "docs/api-reference.md",
2209
2209
  "mode": "0000644",
2210
- "sha256": "1b53174253962c6d9d45d3fc107770fa0af26899c7a638fa2ed8be55712a001c"
2210
+ "sha256": "4cbfdf33670b1e7cfa9e361ef7209af719da5440fee486f2634a00ab2d8ded64"
2211
2211
  },
2212
2212
  {
2213
2213
  "path": "docs/api/openapi.json",
2214
2214
  "mode": "0000644",
2215
- "sha256": "efbdeaf4b09c7113ece416afdc79b4a46a4088504d5398ce02afd4d9aa5f917e"
2215
+ "sha256": "ee67c19d724bde053b4dbcb711c05cf8d1d14cb3b6ff15c198739fd368c8356d"
2216
2216
  },
2217
2217
  {
2218
2218
  "path": "docs/architecture.md",
2219
2219
  "mode": "0000644",
2220
- "sha256": "c7e6113f1819b535619c163c097253314cefaf24b665e727f1e16c65aff9c19e"
2220
+ "sha256": "c806ee6970ef09f4ee4da68b11b38e9e80bf6c37a71e63833ef3a491b9d1afde"
2221
2221
  },
2222
2222
  {
2223
2223
  "path": "docs/branding-contract.md",
@@ -2742,7 +2742,7 @@
2742
2742
  {
2743
2743
  "path": "docs/module-api-changelog.md",
2744
2744
  "mode": "0000644",
2745
- "sha256": "3c225690f0579fca6dbada87f49ee760d816a1135a3ae8ae30dcea4983ccccc8"
2745
+ "sha256": "e366a77119b50c3c36bfb3219a3cd873b1e36f735b7a150a199cab20ac40fc65"
2746
2746
  },
2747
2747
  {
2748
2748
  "path": "docs/modules-contract.md",
@@ -3889,6 +3889,11 @@
3889
3889
  "mode": "0000644",
3890
3890
  "sha256": "6591b63dc385fd1d3a69b2729ef16141bdd6017bf3442cac1642403332edc601"
3891
3891
  },
3892
+ {
3893
+ "path": "migrations/core_259_module_entitlements.sql",
3894
+ "mode": "0000644",
3895
+ "sha256": "496e1605d0bcac75631217e55ff87d707971332c25fab0730a592eed5d7da8b9"
3896
+ },
3892
3897
  {
3893
3898
  "path": "modules/agents/CLAUDE.md",
3894
3899
  "mode": "0000644",
@@ -8597,12 +8602,12 @@
8597
8602
  {
8598
8603
  "path": "package-lock.json",
8599
8604
  "mode": "0000644",
8600
- "sha256": "f474f9d00b0d9bdcbb709d52ebb4f1e61e24b06ebe698ea4a423a3e67bfd3abe"
8605
+ "sha256": "3e6637987df0fac5b562427c291eb8b9ee03af1b4d84bdc96845fdc3f0616198"
8601
8606
  },
8602
8607
  {
8603
8608
  "path": "package.json",
8604
8609
  "mode": "0000644",
8605
- "sha256": "9fcd91f348d585a802db0d0c881b29f64c915c1af4bebe34f3c25d76f322efb8"
8610
+ "sha256": "6cfe5fc0e9b764a9e597bb45806a6f5876dd340c3dbedf3101ec20609546bd26"
8606
8611
  },
8607
8612
  {
8608
8613
  "path": "public-docs/index.html",
@@ -8622,7 +8627,7 @@
8622
8627
  {
8623
8628
  "path": "release-notes.json",
8624
8629
  "mode": "0000644",
8625
- "sha256": "797b5505242d566628e1fef62b4afc9e66cc77708ac3115eb48791ff15b763fe"
8630
+ "sha256": "6c995cf2f60c9bca1bf378e3687ada8fe7963c5b18bf45b78bd92e0ee414c88b"
8626
8631
  },
8627
8632
  {
8628
8633
  "path": "scripts/bongos-mcp.js",
@@ -10474,6 +10479,11 @@
10474
10479
  "mode": "0000644",
10475
10480
  "sha256": "efe34f92930419444ec31d84c7787488db4984ea761d1bb77d5375388758e68c"
10476
10481
  },
10482
+ {
10483
+ "path": "src/bongos/module-entitlements.js",
10484
+ "mode": "0000644",
10485
+ "sha256": "917f364b804f25437dea7fe507abe50e9b64cac79b6cc29ac786de0e24350d33"
10486
+ },
10477
10487
  {
10478
10488
  "path": "src/bongos/module-overrides.js",
10479
10489
  "mode": "0000644",
@@ -10622,7 +10632,7 @@
10622
10632
  {
10623
10633
  "path": "src/bongos/routes/modules.js",
10624
10634
  "mode": "0000644",
10625
- "sha256": "1178db97cfa28a54a41c90bde0fe4a5f47192278c3a51ab01b68f334f8e4374a"
10635
+ "sha256": "e349115a6adea7e97eaf91d2a5fde6d17c90ac80d8f4be579c890ce4a593ef66"
10626
10636
  },
10627
10637
  {
10628
10638
  "path": "src/bongos/routes/my-sessions.js",
@@ -10692,7 +10702,7 @@
10692
10702
  {
10693
10703
  "path": "src/module-api.js",
10694
10704
  "mode": "0000644",
10695
- "sha256": "30a3d4c45217ac814326057165b0ff3f6627a03fb15e80586e6b602bfa9e8514"
10705
+ "sha256": "28d2a831a69f59cc426c1e318991e4ab0755abd3303cc5786e88b72988e97a5c"
10696
10706
  },
10697
10707
  {
10698
10708
  "path": "src/module-loader/catalog.js",
@@ -13539,6 +13549,11 @@
13539
13549
  "mode": "0000644",
13540
13550
  "sha256": "02862e4df8c3df0f6e255c8ad8108b518de7a665881f744eb3e59537193f4587"
13541
13551
  },
13552
+ {
13553
+ "path": "tests/module_entitlements.mjs",
13554
+ "mode": "0000644",
13555
+ "sha256": "50e4d45bf544efe755dda538a93eafc40dcd9caa071e987d8833d97ecf1b7e0f"
13556
+ },
13542
13557
  {
13543
13558
  "path": "tests/module_exports_unique_keys.mjs",
13544
13559
  "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
- - 434 operations across 67 resource groups
8
+ - 436 operations across 67 resource groups
9
9
 
10
10
  ## Use it from your project
11
11
 
@@ -971,6 +971,10 @@ function createClient(opts = {}) {
971
971
  postSsoToken: (args) => request("POST", "/sso/token", { hasBody: true }, args),
972
972
  },
973
973
  "store": {
974
+ // GET /store/entitlements — rank: any-builder — GET /store/entitlements
975
+ getStoreEntitlements: (args) => request("GET", "/store/entitlements", { hasBody: false }, args),
976
+ // GET /store/modules/{key}/entitlement — rank: any-builder — GET /store/modules/:key/entitlement
977
+ getStoreModulesKeyEntitlement: (args) => request("GET", "/store/modules/{key}/entitlement", { hasBody: false }, args),
974
978
  // POST /store/modules/{key}/versions — rank: metic+archon — POST /store/modules/:key/versions
975
979
  postStoreModulesKeyVersions: (args) => request("POST", "/store/modules/{key}/versions", { hasBody: true }, args),
976
980
  },
@@ -970,6 +970,10 @@ function createClient(opts = {}) {
970
970
  postSsoToken: (args) => request("POST", "/sso/token", { hasBody: true }, args),
971
971
  },
972
972
  "store": {
973
+ // GET /store/entitlements — rank: any-builder — GET /store/entitlements
974
+ getStoreEntitlements: (args) => request("GET", "/store/entitlements", { hasBody: false }, args),
975
+ // GET /store/modules/{key}/entitlement — rank: any-builder — GET /store/modules/:key/entitlement
976
+ getStoreModulesKeyEntitlement: (args) => request("GET", "/store/modules/{key}/entitlement", { hasBody: false }, args),
973
977
  // POST /store/modules/{key}/versions — rank: metic+archon — POST /store/modules/:key/versions
974
978
  postStoreModulesKeyVersions: (args) => request("POST", "/store/modules/{key}/versions", { hasBody: true }, args),
975
979
  },
@@ -157,6 +157,7 @@ export interface GetSpecialitiesIdResponse { speciality: unknown }
157
157
  export interface GetSpecialitiesOfferedResponse { suggestions: unknown }
158
158
  export interface GetSpecialitiesResponse { specialities: unknown }
159
159
  export interface GetSsoPubkeyResponse { alg: unknown; format: unknown; public_key_b64: unknown }
160
+ export interface GetStoreEntitlementsResponse { entitlements: unknown }
160
161
  export interface GetTaskRecommendationsForMeResponse { recommendations: unknown; count: unknown }
161
162
  export interface GetTaskRecommendationsMineResponse { recommendations: unknown; count: unknown }
162
163
  export interface GetTasksClaimableResponse { claimable: unknown; claimable_total: unknown; active_claims: unknown }
@@ -1370,6 +1371,10 @@ export interface BongosClient {
1370
1371
  postSsoToken(args: RequestArgs & { body: PostSsoTokenRequest }): Promise<PostSsoTokenResponse>;
1371
1372
  };
1372
1373
  "store": {
1374
+ /** GET /store/entitlements — rank: any-builder */
1375
+ getStoreEntitlements(args?: RequestArgs): Promise<GetStoreEntitlementsResponse>;
1376
+ /** GET /store/modules/{key}/entitlement — rank: any-builder */
1377
+ getStoreModulesKeyEntitlement(args?: RequestArgs): Promise<ApiResponse>;
1373
1378
  /** POST /store/modules/{key}/versions — rank: metic+archon */
1374
1379
  postStoreModulesKeyVersions(args?: RequestArgs): Promise<PostStoreModulesKeyVersionsResponse>;
1375
1380
  };
@@ -967,6 +967,10 @@ export function createClient(opts = {}) {
967
967
  postSsoToken: (args) => request("POST", "/sso/token", { hasBody: true }, args),
968
968
  },
969
969
  "store": {
970
+ // GET /store/entitlements — rank: any-builder — GET /store/entitlements
971
+ getStoreEntitlements: (args) => request("GET", "/store/entitlements", { hasBody: false }, args),
972
+ // GET /store/modules/{key}/entitlement — rank: any-builder — GET /store/modules/:key/entitlement
973
+ getStoreModulesKeyEntitlement: (args) => request("GET", "/store/modules/{key}/entitlement", { hasBody: false }, args),
970
974
  // POST /store/modules/{key}/versions — rank: metic+archon — POST /store/modules/:key/versions
971
975
  postStoreModulesKeyVersions: (args) => request("POST", "/store/modules/{key}/versions", { hasBody: true }, args),
972
976
  },
@@ -17712,6 +17712,89 @@
17712
17712
  "security": []
17713
17713
  }
17714
17714
  },
17715
+ "/store/entitlements": {
17716
+ "get": {
17717
+ "operationId": "get_store_entitlements",
17718
+ "tags": [
17719
+ "store"
17720
+ ],
17721
+ "summary": "GET /store/entitlements",
17722
+ "description": "GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder', holder_ref = req.builder.id), never named in the query or path, so requireBuilder alone is the gate and one builder cannot read another's holdings. Granting has no route yet: install (task 1003785) grants a free module, area 8's buy action grants a paid one — both through src/bongos/module-entitlements.js.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
17723
+ "x-rank": "any-builder",
17724
+ "x-source": "src/bongos/routes/modules.js",
17725
+ "responses": {
17726
+ "200": {
17727
+ "description": "Success.",
17728
+ "content": {
17729
+ "application/json": {
17730
+ "schema": {
17731
+ "$ref": "#/components/schemas/GetStoreEntitlementsResponse"
17732
+ }
17733
+ }
17734
+ }
17735
+ },
17736
+ "400": {
17737
+ "$ref": "#/components/responses/BadRequest"
17738
+ },
17739
+ "401": {
17740
+ "$ref": "#/components/responses/Unauthorized"
17741
+ },
17742
+ "403": {
17743
+ "$ref": "#/components/responses/Forbidden"
17744
+ }
17745
+ },
17746
+ "security": [
17747
+ {
17748
+ "builderSession": []
17749
+ }
17750
+ ]
17751
+ }
17752
+ },
17753
+ "/store/modules/{key}/entitlement": {
17754
+ "get": {
17755
+ "operationId": "get_store_modules_key_entitlement",
17756
+ "tags": [
17757
+ "store"
17758
+ ],
17759
+ "summary": "GET /store/modules/:key/entitlement",
17760
+ "description": "**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
17761
+ "x-rank": "any-builder",
17762
+ "x-source": "src/bongos/routes/modules.js",
17763
+ "parameters": [
17764
+ {
17765
+ "name": "key",
17766
+ "in": "path",
17767
+ "required": true,
17768
+ "schema": {
17769
+ "type": "string"
17770
+ },
17771
+ "description": "Path parameter `key`."
17772
+ }
17773
+ ],
17774
+ "responses": {
17775
+ "200": {
17776
+ "description": "Success."
17777
+ },
17778
+ "400": {
17779
+ "$ref": "#/components/responses/BadRequest"
17780
+ },
17781
+ "401": {
17782
+ "$ref": "#/components/responses/Unauthorized"
17783
+ },
17784
+ "403": {
17785
+ "$ref": "#/components/responses/Forbidden"
17786
+ },
17787
+ "404": {
17788
+ "$ref": "#/components/responses/NotFound"
17789
+ }
17790
+ },
17791
+ "security": [
17792
+ {
17793
+ "builderSession": []
17794
+ }
17795
+ ]
17796
+ }
17797
+ },
17715
17798
  "/store/modules/{key}/versions": {
17716
17799
  "post": {
17717
17800
  "operationId": "post_store_modules_key_versions",
@@ -22341,6 +22424,15 @@
22341
22424
  "public_key_b64"
22342
22425
  ]
22343
22426
  },
22427
+ "GetStoreEntitlementsResponse": {
22428
+ "type": "object",
22429
+ "properties": {
22430
+ "entitlements": {}
22431
+ },
22432
+ "required": [
22433
+ "entitlements"
22434
+ ]
22435
+ },
22344
22436
  "GetTaskRecommendationsForMeResponse": {
22345
22437
  "type": "object",
22346
22438
  "properties": {
@@ -27737,9 +27829,9 @@
27737
27829
  "description": "A required dependency/feature is not configured or is temporarily down."
27738
27830
  }
27739
27831
  },
27740
- "x-endpoint-count": 434,
27741
- "x-schema-count": 466,
27832
+ "x-endpoint-count": 436,
27833
+ "x-schema-count": 467,
27742
27834
  "x-undocumented-bodies": 11,
27743
- "x-response-schemas": 319,
27835
+ "x-response-schemas": 320,
27744
27836
  "x-generated-by": "scripts/gds/gen-api-docs.js"
27745
27837
  }
@@ -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. 434 endpoints across 75 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 436 endpoints across 75 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`.
@@ -694,10 +694,12 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
694
694
  | GET | `/api/bongos/sso/pubkey` | `public` | — | GET /sso/pubkey — the hub's Ed25519 assertion-verification key (public). |
695
695
  | POST | `/api/bongos/sso/token` | `public` | `client_id`, `client_secret`, `code`, `redirect_uri` | POST /sso/token — server-to-server code redemption. |
696
696
 
697
- ## `store` (1)
697
+ ## `store` (3)
698
698
 
699
699
  | Method | Path | Rank | Body | Description |
700
700
  |---|---|---|---|---|
701
+ | GET | `/api/bongos/store/entitlements` | `any-builder` | — | GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the read side of the entitlement record (task 1003813, ADR 0338 … |
702
+ | GET | `/api/bongos/store/modules/:key/entitlement` | `any-builder` | — | |
701
703
  | POST | `/api/bongos/store/modules/:key/versions` | `metic+archon` | _undocumented_ | POST /api/bongos/store/modules/:key/versions — publish one module version to the store (task 1004271, ADR 0338 D1). |
702
704
 
703
705
  ## `task-recommendations` (4)
@@ -233,6 +233,22 @@ store_module_prices(id, module_key, price_cents, price_credits, set_by, set_at)
233
233
  -- in that currency, at least one must be set.
234
234
  ```
235
235
 
236
+ Module entitlements (migration `core_259`, task 1003813 — who holds which module, at which version; the record install, update and the telemetry signals consult; it records ownership, never payment — area 8 owns money):
237
+
238
+ ```
239
+ module_entitlements(id, module_key, holder_kind IN (builder|instance), holder_ref,
240
+ source IN (free|core|grant|purchase), payment_ref (opaque, area 8's),
241
+ version exact X.Y.Z | NULL, acquired_at, granted_by, granted_at,
242
+ revoked_at, revoked_reason, UNIQUE(module_key, holder_kind, holder_ref))
243
+ -- module_key is NOT an FK: a core-bundled module has no store row and
244
+ -- holds an entitlement like any other. Ownership is per module; an update
245
+ -- moves `version` forward, never asks for a second purchase. A delist never
246
+ -- touches it (ADR 0338 D2); only revoke (refund, task 1003789) ends a hold,
247
+ -- keeping the row.
248
+ ```
249
+
250
+ Code: `src/bongos/module-entitlements.js` (`grantEntitlement`, `recordAcquired`, `revokeEntitlement`, `checkEntitlement`, `listEntitlements`). Read routes (own-scoped, `requireBuilder`): `GET /store/entitlements`, `GET /store/modules/:key/entitlement`. No grant route yet: install (task 1003785) grants a free module, the buy action (area 8) a paid one.
251
+
236
252
  Bongos views: `claimable_tasks` (rebuilt in 006 to use SELECT t.*; rebuilt again in 014 so the new `discipline` column flows through), `version_progress` (rebuilt in 006 to count only leaf tasks for forward-compat with task hierarchy), `tasks_session_fit` (new in 006: derives `fits_session_modes[]` and `credits_per_minute`; rebuilt in 014 for the same SELECT t.* refresh), `task_grades_with_context` (new in 016: joins task_grades × tasks × builders × cost_log so the V4 optimizer has quality-per-dollar in one read).
237
253
 
238
254
  **Bongos API (`/api/bongos/*`, mounted in the same Node process):**
@@ -2645,5 +2645,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2645
2645
  landed since 1.19.1075 with no explicit bump. run 36523068301. (task 1002620)
2646
2646
  1.19.1077 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2647
2647
  landed since 1.19.1076 with no explicit bump. run 36558431394. (task 1002620)
2648
+ 1.19.1078 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2649
+ landed since 1.19.1077 with no explicit bump. run 36600137276. (task 1002620)
2648
2650
  ---------------------------------------------------------------------------
2649
2651
  ```
@@ -0,0 +1,58 @@
1
+ -- core_259_module_entitlements.sql — who holds which module, and at which version.
2
+ --
3
+ -- task 1003813 (goal 1000091, working area 5). ADR 0338 D1: the store checks an
4
+ -- entitlement before it serves a tarball, and install (1003785), update (1003786)
5
+ -- and the install/reliability signals (1003793) all read it. This is the durable
6
+ -- meaning of "bought" — and deliberately NOT the act of paying. Granting an
7
+ -- entitlement is area 5's; taking money, balances and payout are area 8's (goal
8
+ -- 1000092) and are not modelled here. The one seam is `payment_ref`: an opaque
9
+ -- text pointer area 8 can fill when it wires the buy action, never read by us.
10
+ --
11
+ -- module_entitlements one row per (module, holder). The holder is a builder or
12
+ -- an instance, named by (holder_kind, holder_ref).
13
+ -- `source` says how it came to be held: 'free' (a zero-price
14
+ -- store module), 'core' (bundled with core), 'grant' (a
15
+ -- person gave it, no payment), 'purchase' (area 8 will
16
+ -- write this). Every source is the same row, so free and
17
+ -- core modules go through exactly the record a paid one does.
18
+ -- `version` is the exact X.Y.Z the holder last acquired —
19
+ -- NULL until the first acquisition. Ownership is per MODULE:
20
+ -- an update moves `version` forward and never asks for a
21
+ -- second purchase (task 1003786).
22
+ --
23
+ -- module_key is deliberately NOT a foreign key to store_modules: a core-bundled
24
+ -- module has no store row, and it must hold an entitlement like any other.
25
+ -- A delist never touches this table (ADR 0338 D2: an entitlement is never revoked
26
+ -- by a delist); the only thing that ends one is `revoked_at`, set by the refund
27
+ -- path (task 1003789). A revoked row is kept, not deleted, so history stays
28
+ -- answerable, and a re-grant clears `revoked_at` on the same row.
29
+ --
30
+ -- Additive only (a new table), so forward-safe. Idempotent: safe to re-run.
31
+
32
+ CREATE TABLE IF NOT EXISTS module_entitlements (
33
+ id bigserial PRIMARY KEY,
34
+ module_key text NOT NULL
35
+ CHECK (module_key ~ '^[a-z][a-z0-9-]*$'),
36
+ holder_kind text NOT NULL
37
+ CHECK (holder_kind IN ('builder', 'instance')),
38
+ holder_ref text NOT NULL CHECK (length(holder_ref) > 0),
39
+ source text NOT NULL
40
+ CHECK (source IN ('free', 'core', 'grant', 'purchase')),
41
+ payment_ref text,
42
+ version text
43
+ CHECK (version IS NULL OR version ~ '^[0-9]+\.[0-9]+\.[0-9]+$'),
44
+ acquired_at timestamptz,
45
+ granted_by bigint REFERENCES builders(id),
46
+ granted_at timestamptz NOT NULL DEFAULT now(),
47
+ revoked_at timestamptz,
48
+ revoked_reason text,
49
+ UNIQUE (module_key, holder_kind, holder_ref),
50
+ CHECK ((version IS NULL) = (acquired_at IS NULL)),
51
+ CHECK ((revoked_at IS NULL) = (revoked_reason IS NULL))
52
+ );
53
+
54
+ CREATE INDEX IF NOT EXISTS idx_module_entitlements_holder
55
+ ON module_entitlements (holder_kind, holder_ref);
56
+
57
+ INSERT INTO schema_migrations (version) VALUES ('core_259_module_entitlements')
58
+ ON CONFLICT (version) DO NOTHING;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1077",
3
+ "version": "1.19.1078",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1077",
9
+ "version": "1.19.1078",
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.1077",
3
+ "version": "1.19.1078",
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",
@@ -7855,5 +7855,11 @@
7855
7855
  "id": "1004357",
7856
7856
  "text": "A project's hall on one of our addresses can no longer read the sign-in of a hub builder who visits it. Hub sign-in cookies now stay on the exact address that set them. builders. and status. still sign you in automatically thr"
7857
7857
  }
7858
+ ],
7859
+ "1.19.1078": [
7860
+ {
7861
+ "id": "1003813",
7862
+ "text": "The store now has a durable record of who owns which module and at which version, so install and update can check it. Free and bundled modules use the same record; nothing charges money yet."
7863
+ }
7858
7864
  ]
7859
7865
  }
@@ -0,0 +1,127 @@
1
+ // src/bongos/module-entitlements.js — the entitlement record: who holds which
2
+ // module, at which version (task 1003813, ADR 0338 D1; table core_259).
3
+ //
4
+ // This is the ONE place install, update and the telemetry signals ask "does this
5
+ // holder own this module?" — the store checks it before it serves a tarball. It is
6
+ // the record of ownership, not of payment: a grant needs no money to have moved,
7
+ // so free and core-bundled modules hold an entitlement through the same rows a
8
+ // paid one will (area 8 writes source 'purchase' + payment_ref when it lands).
9
+ //
10
+ // Rules the functions enforce, from ADR 0338 D2:
11
+ // - ownership is per MODULE, the acquired version moves forward under it, so an
12
+ // update never asks for a second purchase;
13
+ // - a delist never reaches this table — only revokeEntitlement ends a hold (the
14
+ // refund path, task 1003789), and it keeps the row rather than deleting it.
15
+ //
16
+ // Every function takes { db } (a pg Pool or anything with query()) so tests run
17
+ // against a fake client, the way module-store.js does.
18
+
19
+ const { parseVersion, cmp } = require('../module-loader/semver');
20
+ const { KEY_RE } = require('../module-loader/manifest-schema');
21
+
22
+ const HOLDER_KINDS = ['builder', 'instance'];
23
+ const SOURCES = ['free', 'core', 'grant', 'purchase'];
24
+
25
+ const COLS = `id, module_key, holder_kind, holder_ref, source, payment_ref, version,
26
+ acquired_at, granted_by, granted_at, revoked_at, revoked_reason`;
27
+
28
+ function poolOf(db) { return db || require('./pool').pool; }
29
+
30
+ // A bad argument is a programming error in the caller, not a user error: throw.
31
+ function assertHolder(moduleKey, holderKind, holderRef) {
32
+ if (!KEY_RE.test(String(moduleKey))) throw new Error('entitlement: a bad module key');
33
+ if (!HOLDER_KINDS.includes(holderKind)) throw new Error(`entitlement: holder_kind must be one of ${HOLDER_KINDS.join(', ')}`);
34
+ if (typeof holderRef !== 'string' || !holderRef.length) throw new Error('entitlement: holder_ref is required');
35
+ }
36
+
37
+ // Give a holder a module. Idempotent: granting a holder that already holds it is a
38
+ // no-op that returns the existing row (an earlier source is never rewritten), and
39
+ // granting one whose hold was revoked lifts the revocation on the same row.
40
+ // `source: 'purchase'` is area 8's to use; nothing here checks a payment.
41
+ async function grantEntitlement({ moduleKey, holderKind, holderRef, source, grantedBy = null, paymentRef = null }, { db } = {}) {
42
+ assertHolder(moduleKey, holderKind, holderRef);
43
+ if (!SOURCES.includes(source)) throw new Error(`entitlement: source must be one of ${SOURCES.join(', ')}`);
44
+ const pool = poolOf(db);
45
+ const { rows: [fresh] } = await pool.query(
46
+ `INSERT INTO module_entitlements (module_key, holder_kind, holder_ref, source, payment_ref, granted_by)
47
+ VALUES ($1, $2, $3, $4, $5, $6)
48
+ ON CONFLICT (module_key, holder_kind, holder_ref) DO NOTHING
49
+ RETURNING ${COLS}`,
50
+ [moduleKey, holderKind, holderRef, source, paymentRef, grantedBy]);
51
+ if (fresh) return { entitlement: fresh, created: true };
52
+ const { rows: [held] } = await pool.query(
53
+ `UPDATE module_entitlements
54
+ SET revoked_at = NULL, revoked_reason = NULL, source = $4, payment_ref = $5,
55
+ granted_by = $6, granted_at = now()
56
+ WHERE module_key = $1 AND holder_kind = $2 AND holder_ref = $3 AND revoked_at IS NOT NULL
57
+ RETURNING ${COLS}`,
58
+ [moduleKey, holderKind, holderRef, source, paymentRef, grantedBy]);
59
+ if (held) return { entitlement: held, created: false, reinstated: true };
60
+ const { rows: [same] } = await pool.query(
61
+ `SELECT ${COLS} FROM module_entitlements WHERE module_key = $1 AND holder_kind = $2 AND holder_ref = $3`,
62
+ [moduleKey, holderKind, holderRef]);
63
+ return { entitlement: same, created: false };
64
+ }
65
+
66
+ // Record the exact version a holder acquired. Refuses a holder with no live
67
+ // entitlement (nothing is handed over without one) and a version older than the
68
+ // one already held (an update moves forward; a rollback is a separate act, so it
69
+ // must be asked for with allowDowngrade).
70
+ async function recordAcquired({ moduleKey, holderKind, holderRef, version, allowDowngrade = false }, { db } = {}) {
71
+ assertHolder(moduleKey, holderKind, holderRef);
72
+ const wanted = parseVersion(String(version));
73
+ if (!wanted) throw new Error('entitlement: version must be exact X.Y.Z');
74
+ const pool = poolOf(db);
75
+ const { rows: [cur] } = await pool.query(
76
+ `SELECT ${COLS} FROM module_entitlements
77
+ WHERE module_key = $1 AND holder_kind = $2 AND holder_ref = $3`,
78
+ [moduleKey, holderKind, holderRef]);
79
+ if (!cur || cur.revoked_at) return { ok: false, code: 'not_entitled' };
80
+ const held = cur.version && parseVersion(cur.version);
81
+ if (held && !allowDowngrade && cmp(wanted, held) < 0) return { ok: false, code: 'version_older_than_held' };
82
+ const { rows: [row] } = await pool.query(
83
+ `UPDATE module_entitlements SET version = $2, acquired_at = now()
84
+ WHERE id = $1 AND revoked_at IS NULL
85
+ RETURNING ${COLS}`,
86
+ [cur.id, version]);
87
+ return row ? { ok: true, entitlement: row } : { ok: false, code: 'not_entitled' };
88
+ }
89
+
90
+ // End a hold. Only the refund path calls this (task 1003789); a delist must not.
91
+ // The row stays, stamped with when and why.
92
+ async function revokeEntitlement({ moduleKey, holderKind, holderRef, reason }, { db } = {}) {
93
+ assertHolder(moduleKey, holderKind, holderRef);
94
+ if (typeof reason !== 'string' || !reason.trim()) throw new Error('entitlement: a revocation needs a reason');
95
+ const { rows: [row] } = await poolOf(db).query(
96
+ `UPDATE module_entitlements SET revoked_at = now(), revoked_reason = $4
97
+ WHERE module_key = $1 AND holder_kind = $2 AND holder_ref = $3 AND revoked_at IS NULL
98
+ RETURNING ${COLS}`,
99
+ [moduleKey, holderKind, holderRef, reason.trim()]);
100
+ return row ? { ok: true, entitlement: row } : { ok: false, code: 'not_entitled' };
101
+ }
102
+
103
+ // The question the store asks before it serves: does this holder hold this module
104
+ // right now? `entitled` is false for no row and for a revoked row; the row itself
105
+ // comes back either way so the caller can say WHY.
106
+ async function checkEntitlement({ moduleKey, holderKind, holderRef }, { db } = {}) {
107
+ assertHolder(moduleKey, holderKind, holderRef);
108
+ const { rows: [row] } = await poolOf(db).query(
109
+ `SELECT ${COLS} FROM module_entitlements
110
+ WHERE module_key = $1 AND holder_kind = $2 AND holder_ref = $3`,
111
+ [moduleKey, holderKind, holderRef]);
112
+ return { entitled: !!row && !row.revoked_at, entitlement: row || null };
113
+ }
114
+
115
+ // Everything one holder holds (live and revoked), newest grant first.
116
+ async function listEntitlements({ holderKind, holderRef }, { db } = {}) {
117
+ assertHolder('x', holderKind, holderRef);
118
+ const { rows } = await poolOf(db).query(
119
+ `SELECT ${COLS} FROM module_entitlements
120
+ WHERE holder_kind = $1 AND holder_ref = $2 ORDER BY granted_at DESC, id DESC`,
121
+ [holderKind, holderRef]);
122
+ return rows;
123
+ }
124
+
125
+ module.exports = {
126
+ grantEntitlement, recordAcquired, revokeEntitlement, checkEntitlement, listEntitlements,
127
+ };
@@ -23,6 +23,9 @@
23
23
  // server end of `bongos module publish`. Not the
24
24
  // upstreaming door above: submit proposes a module
25
25
  // INTO core; publish puts a version in the store.
26
+ // GET /store/entitlements the modules the caller holds, and at which version
27
+ // GET /store/modules/:key/entitlement
28
+ // does the caller hold this module (task 1003813).
26
29
  //
27
30
  // Read is open to any signed-in builder (same gate as the atlas/primer pages);
28
31
  // every write (enable/disable, submit) is metic+archon, same rank as
@@ -65,6 +68,7 @@ const modulesLib = require('../../modules');
65
68
  const moduleCli = require('../../../scripts/gds/module');
66
69
  const moduleSubmissions = require('../module-submissions');
67
70
  const moduleArtifact = require('../../../scripts/gds/module-artifact');
71
+ const { checkEntitlement, listEntitlements } = require('../module-entitlements');
68
72
  const { stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion } = require('../module-store');
69
73
  const { KEY_RE } = require('../../module-loader/manifest-schema');
70
74
  // Named imports, not a namespace object (task 1004052 strand walk): knip cannot follow
@@ -347,6 +351,31 @@ module.exports = function buildModulesRouter() {
347
351
  res.status(201).json({ ok: true, created_module: result.created, version: result.version });
348
352
  }));
349
353
 
354
+ // GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
355
+ // read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped
356
+ // like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder',
357
+ // holder_ref = req.builder.id), never named in the query or path, so requireBuilder
358
+ // alone is the gate and one builder cannot read another's holdings. Granting has no
359
+ // route yet: install (task 1003785) grants a free module, area 8's buy action
360
+ // grants a paid one — both through src/bongos/module-entitlements.js.
361
+ router.get('/store/entitlements', auth.requireBuilder,
362
+ asyncHandler('GET /store/entitlements', async (req, res) => {
363
+ const entitlements = await listEntitlements({ holderKind: 'builder', holderRef: String(req.builder.id) });
364
+ res.set('Cache-Control', 'no-store');
365
+ res.json({ entitlements });
366
+ }));
367
+
368
+ router.get('/store/modules/:key/entitlement', auth.requireBuilder,
369
+ asyncHandler('GET /store/modules/:key/entitlement', async (req, res) => {
370
+ const { key } = req.params;
371
+ if (!KEY_RE.test(key)) {
372
+ return res.fail('bad_module_key', { status: 400, message: 'A module key is lowercase kebab-case.' });
373
+ }
374
+ const held = await checkEntitlement({ moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) });
375
+ res.set('Cache-Control', 'no-store');
376
+ res.json(held);
377
+ }));
378
+
350
379
  return router;
351
380
  };
352
381
 
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.19.1077'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.19.1078'; // 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,204 @@
1
+ // tests/module_entitlements.mjs — the entitlement record (task 1003813, ADR 0338 D1).
2
+ // DB-free: the migration text is pinned directly, and src/bongos/module-entitlements.js
3
+ // runs against a small in-memory stand-in for the one table. The live apply runs in
4
+ // the PG lane and at deploy.
5
+ //
6
+ // Run: node tests/module_entitlements.mjs
7
+
8
+ import { test } from 'node:test';
9
+ import { strict as assert } from 'node:assert';
10
+ import { readFileSync } from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const SQL = readFileSync(path.join(ROOT, 'migrations', 'core_259_module_entitlements.sql'), 'utf8');
18
+ const DDL = SQL.replace(/--.*$/gm, '');
19
+ const {
20
+ grantEntitlement, recordAcquired, revokeEntitlement, checkEntitlement, listEntitlements,
21
+ } = require('../src/bongos/module-entitlements.js');
22
+
23
+ // ---- an in-memory stand-in for module_entitlements, keyed on the SQL's shape ----
24
+ function fakeDb() {
25
+ const rows = [];
26
+ let nextId = 1;
27
+ const find = (k, kind, ref) => rows.find((r) => r.module_key === k && r.holder_kind === kind && r.holder_ref === ref);
28
+ return {
29
+ rows,
30
+ async query(sql, p) {
31
+ const s = sql.replace(/\s+/g, ' ');
32
+ if (s.startsWith('INSERT INTO module_entitlements')) {
33
+ if (find(p[0], p[1], p[2])) return { rows: [] };
34
+ const r = { id: nextId++, module_key: p[0], holder_kind: p[1], holder_ref: p[2], source: p[3],
35
+ payment_ref: p[4], version: null, acquired_at: null, granted_by: p[5], granted_at: new Date(),
36
+ revoked_at: null, revoked_reason: null };
37
+ rows.push(r);
38
+ return { rows: [{ ...r }] };
39
+ }
40
+ if (s.startsWith('UPDATE module_entitlements SET revoked_at = NULL')) {
41
+ const r = find(p[0], p[1], p[2]);
42
+ if (!r || !r.revoked_at) return { rows: [] };
43
+ Object.assign(r, { revoked_at: null, revoked_reason: null, source: p[3], payment_ref: p[4], granted_by: p[5] });
44
+ return { rows: [{ ...r }] };
45
+ }
46
+ if (s.startsWith('UPDATE module_entitlements SET version')) {
47
+ const r = rows.find((x) => x.id === p[0]);
48
+ if (!r || r.revoked_at) return { rows: [] };
49
+ Object.assign(r, { version: p[1], acquired_at: new Date() });
50
+ return { rows: [{ ...r }] };
51
+ }
52
+ if (s.startsWith('UPDATE module_entitlements SET revoked_at = now()')) {
53
+ const r = find(p[0], p[1], p[2]);
54
+ if (!r || r.revoked_at) return { rows: [] };
55
+ Object.assign(r, { revoked_at: new Date(), revoked_reason: p[3] });
56
+ return { rows: [{ ...r }] };
57
+ }
58
+ if (s.includes('WHERE holder_kind = $1 AND holder_ref = $2')) {
59
+ return { rows: rows.filter((r) => r.holder_kind === p[0] && r.holder_ref === p[1]).map((r) => ({ ...r })).reverse() };
60
+ }
61
+ if (s.includes('WHERE module_key = $1 AND holder_kind = $2 AND holder_ref = $3')) {
62
+ const r = find(p[0], p[1], p[2]);
63
+ return { rows: r ? [{ ...r }] : [] };
64
+ }
65
+ throw new Error(`fake db: unhandled SQL: ${s}`);
66
+ },
67
+ };
68
+ }
69
+
70
+ const B = { moduleKey: 'weather', holderKind: 'builder', holderRef: '42' };
71
+
72
+ // ---- the migration ----
73
+
74
+ test('one idempotent table, forward-safe under the lint gate', () => {
75
+ assert.match(DDL, /CREATE TABLE IF NOT EXISTS module_entitlements \(/);
76
+ assert.doesNotMatch(DDL, /CREATE TABLE (?!IF NOT EXISTS)/);
77
+ assert.doesNotMatch(DDL, /CREATE INDEX (?!IF NOT EXISTS)/);
78
+ assert.match(DDL, /INSERT INTO schema_migrations \(version\) VALUES \('core_259_module_entitlements'\)\s+ON CONFLICT \(version\) DO NOTHING;/);
79
+ const lint = require('../scripts/gds/lint-migrations.js');
80
+ assert.deepEqual(lint.scanMigrationSql(SQL).unsafe || [], []);
81
+ });
82
+
83
+ test('a core-bundled module can hold an entitlement: module_key is not a foreign key', () => {
84
+ assert.doesNotMatch(DDL, /module_key[^,\n]*REFERENCES/);
85
+ assert.match(DDL, /source\s+text NOT NULL\s+CHECK \(source IN \('free', 'core', 'grant', 'purchase'\)\)/);
86
+ });
87
+
88
+ test('one row per module and holder; nothing cascades it away', () => {
89
+ assert.match(DDL, /UNIQUE \(module_key, holder_kind, holder_ref\)/);
90
+ assert.doesNotMatch(DDL, /ON DELETE CASCADE/);
91
+ });
92
+
93
+ test('the version is exact semver and moves with its timestamp; a revocation carries its reason', () => {
94
+ assert.match(DDL, /version IS NULL OR version ~ '\^\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$'/);
95
+ assert.match(DDL, /CHECK \(\(version IS NULL\) = \(acquired_at IS NULL\)\)/);
96
+ assert.match(DDL, /CHECK \(\(revoked_at IS NULL\) = \(revoked_reason IS NULL\)\)/);
97
+ });
98
+
99
+ test('the record models no balance, fee or payout — only an opaque payment_ref (area 8 owns money)', () => {
100
+ assert.doesNotMatch(DDL, /balance|payout|\bfee\b|price/i);
101
+ assert.match(DDL, /payment_ref\s+text,/);
102
+ });
103
+
104
+ // ---- the functions ----
105
+
106
+ test('every source is granted through the same record, with no payment involved', async () => {
107
+ const db = fakeDb();
108
+ for (const [i, source] of ['free', 'core', 'grant'].entries()) {
109
+ const r = await grantEntitlement({ ...B, moduleKey: `mod-${i}`, source }, { db });
110
+ assert.equal(r.created, true);
111
+ assert.equal(r.entitlement.payment_ref, null);
112
+ assert.equal((await checkEntitlement({ ...B, moduleKey: `mod-${i}` }, { db })).entitled, true);
113
+ }
114
+ });
115
+
116
+ test('granting twice is a no-op that keeps the first source', async () => {
117
+ const db = fakeDb();
118
+ await grantEntitlement({ ...B, source: 'grant', grantedBy: 7 }, { db });
119
+ const again = await grantEntitlement({ ...B, source: 'free' }, { db });
120
+ assert.equal(again.created, false);
121
+ assert.equal(again.entitlement.source, 'grant');
122
+ assert.equal(db.rows.length, 1);
123
+ });
124
+
125
+ test('a holder who was never granted is not entitled, and cannot record an acquisition', async () => {
126
+ const db = fakeDb();
127
+ assert.deepEqual(await checkEntitlement(B, { db }), { entitled: false, entitlement: null });
128
+ assert.deepEqual(await recordAcquired({ ...B, version: '1.0.0' }, { db }), { ok: false, code: 'not_entitled' });
129
+ });
130
+
131
+ test('an update moves the acquired version forward under the same entitlement', async () => {
132
+ const db = fakeDb();
133
+ await grantEntitlement({ ...B, source: 'purchase', paymentRef: 'pay_1' }, { db });
134
+ assert.equal((await recordAcquired({ ...B, version: '1.9.0' }, { db })).ok, true);
135
+ const up = await recordAcquired({ ...B, version: '1.10.0' }, { db });
136
+ assert.equal(up.ok, true, '1.10.0 is newer than 1.9.0 (numeric, not text, order)');
137
+ assert.equal(up.entitlement.version, '1.10.0');
138
+ assert.equal(up.entitlement.source, 'purchase', 'an update never rewrites how it was acquired');
139
+ assert.equal(db.rows.length, 1, 'no second purchase, no second row');
140
+ });
141
+
142
+ test('an older version is refused unless a rollback is asked for by name', async () => {
143
+ const db = fakeDb();
144
+ await grantEntitlement({ ...B, source: 'free' }, { db });
145
+ await recordAcquired({ ...B, version: '2.0.0' }, { db });
146
+ assert.deepEqual(await recordAcquired({ ...B, version: '1.5.0' }, { db }), { ok: false, code: 'version_older_than_held' });
147
+ assert.equal((await recordAcquired({ ...B, version: '1.5.0', allowDowngrade: true }, { db })).entitlement.version, '1.5.0');
148
+ });
149
+
150
+ test('a version that is not exact X.Y.Z is a caller error', async () => {
151
+ const db = fakeDb();
152
+ await grantEntitlement({ ...B, source: 'free' }, { db });
153
+ await assert.rejects(recordAcquired({ ...B, version: '1.0' }, { db }), /exact X\.Y\.Z/);
154
+ await assert.rejects(recordAcquired({ ...B, version: '^1.0.0' }, { db }), /exact X\.Y\.Z/);
155
+ });
156
+
157
+ test('revoking keeps the row, stamps why, and stops the entitlement; a re-grant lifts it', async () => {
158
+ const db = fakeDb();
159
+ await grantEntitlement({ ...B, source: 'purchase' }, { db });
160
+ await recordAcquired({ ...B, version: '1.0.0' }, { db });
161
+ await assert.rejects(revokeEntitlement({ ...B, reason: ' ' }, { db }), /needs a reason/);
162
+ const r = await revokeEntitlement({ ...B, reason: 'refunded' }, { db });
163
+ assert.equal(r.ok, true);
164
+ assert.equal(db.rows.length, 1, 'kept, never deleted');
165
+ const held = await checkEntitlement(B, { db });
166
+ assert.equal(held.entitled, false);
167
+ assert.equal(held.entitlement.revoked_reason, 'refunded');
168
+ assert.deepEqual(await recordAcquired({ ...B, version: '1.1.0' }, { db }), { ok: false, code: 'not_entitled' });
169
+ assert.deepEqual(await revokeEntitlement({ ...B, reason: 'again' }, { db }), { ok: false, code: 'not_entitled' });
170
+ const back = await grantEntitlement({ ...B, source: 'grant' }, { db });
171
+ assert.equal(back.reinstated, true);
172
+ assert.equal((await checkEntitlement(B, { db })).entitled, true);
173
+ assert.equal(back.entitlement.version, '1.0.0', 'what was acquired is remembered');
174
+ });
175
+
176
+ test('holdings are per holder: one builder never sees another\'s', async () => {
177
+ const db = fakeDb();
178
+ await grantEntitlement({ ...B, source: 'free' }, { db });
179
+ await grantEntitlement({ ...B, holderRef: '43', source: 'free' }, { db });
180
+ await grantEntitlement({ ...B, holderKind: 'instance', holderRef: '42', source: 'free' }, { db });
181
+ const mine = await listEntitlements({ holderKind: 'builder', holderRef: '42' }, { db });
182
+ assert.equal(mine.length, 1);
183
+ assert.equal(mine[0].holder_ref, '42');
184
+ });
185
+
186
+ test('bad arguments throw rather than write', async () => {
187
+ const db = fakeDb();
188
+ await assert.rejects(grantEntitlement({ ...B, moduleKey: '../x', source: 'free' }, { db }), /bad module key/);
189
+ await assert.rejects(grantEntitlement({ ...B, holderKind: 'team', source: 'free' }, { db }), /holder_kind/);
190
+ await assert.rejects(grantEntitlement({ ...B, source: 'gift' }, { db }), /source must be/);
191
+ assert.equal(db.rows.length, 0);
192
+ });
193
+
194
+ // ---- the read routes ----
195
+
196
+ test('the read routes are own-scoped: gated on requireBuilder, holder taken from the session', () => {
197
+ const src = readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
198
+ for (const p of ['/store/entitlements', '/store/modules/:key/entitlement']) {
199
+ assert.match(src, new RegExp(`router\\.get\\('${p.replace(/[/:.]/g, '\\$&')}', auth\\.requireBuilder,`));
200
+ }
201
+ const block = src.slice(src.indexOf("router.get('/store/entitlements'"));
202
+ assert.match(block, /holderRef: String\(req\.builder\.id\)/);
203
+ assert.doesNotMatch(block.slice(0, 1600), /req\.(query|body)\./, 'the holder is never named by the caller');
204
+ });