@bongos/core 1.19.1076 → 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/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1076",
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.1076",
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.1076",
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",
@@ -7849,5 +7849,17 @@
7849
7849
  "id": "1003898",
7850
7850
  "text": "The dev box's removal is written up in one decision record (ADR 0346), and the 20 older records that described it now point there. The live guides, the design mockups and the README no longer tell anyone to use a dev box."
7851
7851
  }
7852
+ ],
7853
+ "1.19.1077": [
7854
+ {
7855
+ "id": "1004357",
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
+ }
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
+ }
7852
7864
  ]
7853
7865
  }
@@ -69,7 +69,14 @@ const MAX_AUTH_TOKEN_CANDIDATES = 6;
69
69
  // in the wild, so the list is three long, not two (task 1003704). The server
70
70
  // SETS only the head; routes/auth.js clears the tail on the next write. Part 4
71
71
  // (task 1003706) retires the tail behind a test of its absence.
72
- const SESSION_COOKIE_NAMES = Object.freeze(['bongos_session', 'gds_session', 'pms_session']);
72
+ //
73
+ // The head is `__Host-bongos_session` (task 1004357, ADR 0145 Finding 1): a
74
+ // browser only accepts a `__Host-` cookie that is Secure, Path=/ and carries NO
75
+ // Domain attribute, so the prefix itself guarantees the session never reaches a
76
+ // project hall on another subdomain. A browser refuses a `__Host-` cookie over
77
+ // plain http, so localhost dev keeps setting `bongos_session` (host-only there
78
+ // too) — routes/auth.js picks between the first two by the request's scheme.
79
+ const SESSION_COOKIE_NAMES = Object.freeze(['__Host-bongos_session', 'bongos_session', 'gds_session', 'pms_session']);
73
80
 
74
81
  function extractTokens(req) {
75
82
  const out = [];
@@ -157,7 +157,7 @@ function pruneAndCount(bucket, now, windowMs) {
157
157
  // Deliberately duplicated from SESSION_COOKIE_NAMES in src/bongos/auth.js rather
158
158
  // than imported: this middleware is dependency-free by design. The copy is pinned
159
159
  // against the real list by tests/session_rename_fallback.mjs (task 1003704).
160
- const SESSION_COOKIE_RES = ['bongos_session', 'gds_session', 'pms_session']
160
+ const SESSION_COOKIE_RES = ['__Host-bongos_session', 'bongos_session', 'gds_session', 'pms_session']
161
161
  .map((n) => new RegExp('(?:^|;\\s*)' + n + '=([^;]+)'));
162
162
 
163
163
  function extractSessionKey(req) {
@@ -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
+ };