@bongos/core 1.21.172 → 1.21.173

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.
@@ -841,6 +841,9 @@ module.exports = function buildModulesRouter() {
841
841
  // is not listed at all. The floor is fixed in module-listing.js and returned here.
842
842
  // `?sort=<part>` orders the listed modules by the part a buyer cares about
843
843
  // (task 1003800, sortByPart); the parts it accepts come back as sort_parts.
844
+ // Without it the listed modules are ranked quality first, price breaking ties
845
+ // (task 1003808, rankListed); `?max_cents=` / `?max_credits=` leave out modules
846
+ // that cost more.
844
847
  //
845
848
  // rank: any-builder — a read of public store metadata, like /store/modules/latest:
846
849
  // no entitlement taken, nothing changed.
@@ -852,7 +855,18 @@ module.exports = function buildModulesRouter() {
852
855
  const names = SORT_PARTS.map((p) => `${p.title} (${p.part})`).join(', ');
853
856
  return res.fail('bad_request', { status: 400, message: `The store can be sorted by one part of the score: ${names}.` });
854
857
  }
855
- const listing = await listCatalog({ sort });
858
+ // An optional maximum price (task 1003808): ?max_cents= and/or ?max_credits=,
859
+ // each a whole number of at least 0. Free modules always pass.
860
+ const maxPrice = {};
861
+ for (const [param, field] of [['max_cents', 'cents'], ['max_credits', 'credits']]) {
862
+ const v = req.query && req.query[param];
863
+ if (v == null || v === '') continue;
864
+ if (typeof v !== 'string' || !/^\d{1,9}$/.test(v)) {
865
+ return res.fail('bad_request', { status: 400, message: `${param} is a maximum price: a whole number, 0 or more.` });
866
+ }
867
+ maxPrice[field] = Number(v);
868
+ }
869
+ const listing = await listCatalog({ sort, maxPrice });
856
870
  res.set('Cache-Control', 'no-store');
857
871
  res.json(listing);
858
872
  }));
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.21.172'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.173'; // 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,38 @@
1
+ // tests/auth_switch_account.mjs — the home page's "New account" door (task 1004789).
2
+ //
3
+ // WHY. GitHub silently re-uses whoever is logged in to github.com in the browser,
4
+ // so a second Cloud Bongos account was unreachable. ?switch=1 on /auth/web/start
5
+ // must add prompt=select_account (GitHub's account picker); a plain sign-in must not.
6
+ import { strict as assert } from 'node:assert';
7
+ import { test } from 'node:test';
8
+ import { createRequire } from 'node:module';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
+ process.env.BONGOS_GITHUB_CLIENT_ID = process.env.BONGOS_GITHUB_CLIENT_ID || 'test-client';
16
+ process.env.BONGOS_GITHUB_CLIENT_SECRET = process.env.BONGOS_GITHUB_CLIENT_SECRET || 'test-secret';
17
+ const auth = require(path.join(ROOT, 'src/bongos/auth.js'));
18
+
19
+ test('switchAccount adds prompt=select_account; a plain sign-in does not', () => {
20
+ const plain = new URL(auth.buildAuthorizeUrl('s', 'https://x/cb'));
21
+ assert.equal(plain.searchParams.get('prompt'), null);
22
+ const sw = new URL(auth.buildAuthorizeUrl('s', 'https://x/cb', { switchAccount: true }));
23
+ assert.equal(sw.searchParams.get('prompt'), 'select_account');
24
+ assert.equal(sw.searchParams.get('scope'), plain.searchParams.get('scope'));
25
+ });
26
+
27
+ test('the start route reads ?switch=1 and passes it to the GitHub leg', () => {
28
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/auth.js'), 'utf8');
29
+ assert.match(src, /const wantsSwitch = req\.query\.switch === '1'/);
30
+ assert.match(src, /switchAccount: wantsSwitch/);
31
+ // a switch must reach GitHub, never short-circuit through the live-session handoff
32
+ assert.match(src, /if \(!wantsRepos && !wantsSwitch\) \{/);
33
+ });
34
+
35
+ test('the landing carries the New account door beside Sign in', () => {
36
+ const html = fs.readFileSync(path.join(ROOT, 'modules/public-landing/public/index.html'), 'utf8');
37
+ assert.match(html, /<a class="pill newacct" href="\/api\/bongos\/auth\/web\/start\?return=\/&amp;repos=1&amp;switch=1"[^>]*>New account<\/a>/);
38
+ });
@@ -15,9 +15,18 @@ import { createRequire } from 'node:module';
15
15
  const require = createRequire(import.meta.url);
16
16
 
17
17
  // ---- the fake registry the route reads ----
18
- const state = { modules: {}, versions: [], scores: [], signals: [] };
18
+ const state = { modules: {}, versions: [], scores: [], signals: [], prices: [] };
19
19
  const pool = {
20
20
  async query(sql, params = []) {
21
+ if (/FROM store_module_prices/.test(sql)) {
22
+ const newest = new Map();
23
+ for (const p of state.prices) {
24
+ if (!params[0].includes(p.module_key)) continue;
25
+ const have = newest.get(p.module_key);
26
+ if (!have || p.set_at > have.set_at) newest.set(p.module_key, p);
27
+ }
28
+ return { rows: [...newest.values()] };
29
+ }
21
30
  if (/FROM store_modules m JOIN store_module_versions v/.test(sql)) {
22
31
  return {
23
32
  rows: state.versions
@@ -53,7 +62,7 @@ const poolPath = require.resolve('../src/bongos/pool.js');
53
62
  require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
54
63
 
55
64
  const buildModulesRouter = require('../src/bongos/routes/modules.js');
56
- const { QUALITY_FLOOR, SORT_PARTS, placeVersion, sortByPart } = require('../src/bongos/module-listing.js');
65
+ const { QUALITY_FLOOR, SORT_PARTS, RANKING, placeVersion, sortByPart, priceOf, withinMaxPrice, rankListed } = require('../src/bongos/module-listing.js');
57
66
 
58
67
  function handlerFor(router, method, p) {
59
68
  const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
@@ -153,7 +162,7 @@ test('GET /store/modules: lists by the newest version\'s current score, drops wh
153
162
  assert.equal(res.statusCode, 200);
154
163
  assert.equal(res.headers['Cache-Control'], 'no-store');
155
164
  assert.equal(res.body.floor, 50);
156
- assert.deepEqual(res.body.listed.map((m) => [m.key, m.version, m.overall]), [['fixed', '2.0.0', 60], ['good', '1.10.0', 80]]);
165
+ assert.deepEqual(res.body.listed.map((m) => [m.key, m.version, m.overall]), [['good', '1.10.0', 80], ['fixed', '2.0.0', 60]], 'highest score first');
157
166
  assert.deepEqual(res.body.new.map((m) => [m.key, m.is_new, m.overall]), [['fresh', true, null]]);
158
167
  const all = [...res.body.listed, ...res.body.new].map((m) => m.key);
159
168
  for (const k of ['low', 'unsafe', 'unscored', 'gone']) assert.ok(!all.includes(k), `${k} must not be listed`);
@@ -163,7 +172,7 @@ test('GET /store/modules: an empty store answers empty lists and the floor', asy
163
172
  state.modules = {}; state.versions = []; state.scores = [];
164
173
  const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
165
174
  const res = await invoke(handle, {});
166
- assert.deepEqual(res.body, { floor: 50, sort: null, sort_parts: SORT_PARTS, listed: [], new: [] });
175
+ assert.deepEqual(res.body, { floor: 50, ranking: RANKING, sort: null, sort_parts: SORT_PARTS, max_price: null, listed: [], new: [] });
167
176
  });
168
177
 
169
178
  // ---------------------------------------------------------------------------
@@ -229,7 +238,7 @@ test('GET /store/modules?sort=: orders the listed modules by the part, leaves Ne
229
238
  assert.deepEqual(byTests.body.listed.map((m) => m.key), ['tested', 'steady']);
230
239
  const plain = await invoke(handle, { query: {} });
231
240
  assert.equal(plain.body.sort, null);
232
- assert.deepEqual(plain.body.listed.map((m) => m.key), ['steady', 'tested'], 'no sort is key order, as before');
241
+ assert.deepEqual(plain.body.listed.map((m) => m.key), ['steady', 'tested'], 'no sort is the ranking: equal scores, both free, so key order');
233
242
  state.signals = [];
234
243
  });
235
244
 
@@ -307,3 +316,94 @@ test('the criterion: two modules with the same overall score read differently',
307
316
  assert.equal(by.steady.parts.reliability.score, 90);
308
317
  state.signals = [];
309
318
  });
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // the ranking: quality first, price breaks ties, optional max price (task 1003808)
322
+ // ---------------------------------------------------------------------------
323
+
324
+ test('priceOf: no price row is free; 0 in either currency is free; null is not offered', () => {
325
+ assert.deepEqual(priceOf(null), { cents: null, credits: null, free: true });
326
+ assert.deepEqual(priceOf({ price_cents: 0, price_credits: null }), { cents: 0, credits: null, free: true });
327
+ assert.deepEqual(priceOf({ price_cents: 500, price_credits: 0 }), { cents: 500, credits: 0, free: true });
328
+ assert.deepEqual(priceOf({ price_cents: 500, price_credits: null }), { cents: 500, credits: null, free: false });
329
+ });
330
+
331
+ test('rankListed: a higher score always wins, whatever it costs — price never buys a place', () => {
332
+ const ranked = rankListed([
333
+ { key: 'cheap', overall: 70, price: priceOf(null) },
334
+ { key: 'dear', overall: 90, price: priceOf({ price_cents: 99900 }) },
335
+ ]);
336
+ assert.deepEqual(ranked.map((e) => e.key), ['dear', 'cheap']);
337
+ });
338
+
339
+ test('rankListed: between equal scores, free first, then lowest cash, then lowest credits, then key', () => {
340
+ const input = [
341
+ { key: 'z-credits', overall: 80, price: priceOf({ price_credits: 5 }) },
342
+ { key: 'cents-900', overall: 80, price: priceOf({ price_cents: 900 }) },
343
+ { key: 'cents-100', overall: 80, price: priceOf({ price_cents: 100 }) },
344
+ { key: 'b-free', overall: 80, price: priceOf(null) },
345
+ { key: 'a-free', overall: 80, price: priceOf({ price_cents: 0 }) },
346
+ ];
347
+ assert.deepEqual(rankListed(input).map((e) => e.key), ['a-free', 'b-free', 'cents-100', 'cents-900', 'z-credits']);
348
+ assert.equal(input[0].key, 'z-credits', 'the input is untouched');
349
+ });
350
+
351
+ test('withinMaxPrice: free always passes; a priced module must be offered at or under a cap the buyer set', () => {
352
+ const p = priceOf({ price_cents: 500, price_credits: 7 });
353
+ assert.equal(withinMaxPrice(p, null), true, 'no cap, no filter');
354
+ assert.equal(withinMaxPrice(p, { cents: 500, credits: null }), true, 'at the cap is in');
355
+ assert.equal(withinMaxPrice(p, { cents: 499, credits: null }), false);
356
+ assert.equal(withinMaxPrice(p, { cents: 1, credits: 7 }), true, 'either currency under its cap is enough');
357
+ assert.equal(withinMaxPrice(priceOf({ price_cents: 500 }), { cents: null, credits: 100 }), false, 'not offered in credits, so a credit cap cannot let it in');
358
+ assert.equal(withinMaxPrice(priceOf(null), { cents: 0, credits: null }), true);
359
+ });
360
+
361
+ test('GET /store/modules: ranks by score then price, and ?max_cents= leaves out what costs more', async () => {
362
+ state.modules = {
363
+ top: { status: 'listed', title: 'Top' }, 'tie-paid': { status: 'listed', title: 'Tie paid' },
364
+ 'tie-free': { status: 'listed', title: 'Tie free' }, fresh: { status: 'listed', title: 'Fresh' },
365
+ };
366
+ state.versions = [
367
+ { id: 31, module_key: 'top', version: '1.0.0', published_at: 't' },
368
+ { id: 32, module_key: 'tie-paid', version: '1.0.0', published_at: 't' },
369
+ { id: 33, module_key: 'tie-free', version: '1.0.0', published_at: 't' },
370
+ { id: 34, module_key: 'fresh', version: '1.0.0', published_at: 't' },
371
+ ];
372
+ state.scores = [
373
+ { id: 31, version_id: 31, computed_at: 1, ...score({ overall: 92 }) },
374
+ { id: 32, version_id: 32, computed_at: 1, ...score({ overall: 75 }) },
375
+ { id: 33, version_id: 33, computed_at: 1, ...score({ overall: 75 }) },
376
+ { id: 34, version_id: 34, computed_at: 1, ...score({ is_new: true }) },
377
+ ];
378
+ state.signals = [];
379
+ state.prices = [
380
+ { module_key: 'top', price_cents: 2000, price_credits: null, set_at: 1 },
381
+ { module_key: 'tie-paid', price_cents: 900, price_credits: null, set_at: 1 },
382
+ { module_key: 'tie-paid', price_cents: 500, price_credits: null, set_at: 2 },
383
+ { module_key: 'fresh', price_cents: 3000, price_credits: null, set_at: 1 },
384
+ ];
385
+ const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
386
+ const all = await invoke(handle, { query: {} });
387
+ assert.equal(all.body.ranking, RANKING);
388
+ assert.deepEqual(all.body.listed.map((m) => m.key), ['top', 'tie-free', 'tie-paid']);
389
+ assert.deepEqual(all.body.listed.map((m) => m.price.cents), [2000, null, 500], 'the CURRENT price is shown');
390
+ assert.equal(all.body.max_price, null);
391
+
392
+ const capped = await invoke(handle, { query: { max_cents: '1000' } });
393
+ assert.deepEqual(capped.body.max_price, { cents: 1000, credits: null });
394
+ assert.deepEqual(capped.body.listed.map((m) => m.key), ['tie-free', 'tie-paid']);
395
+ assert.deepEqual(capped.body.new, [], 'the cap applies to New modules too');
396
+
397
+ const free = await invoke(handle, { query: { max_cents: '0' } });
398
+ assert.deepEqual(free.body.listed.map((m) => m.key), ['tie-free'], 'a cap of 0 is free only');
399
+ state.prices = [];
400
+ });
401
+
402
+ test('GET /store/modules: a maximum price that is not a whole number of 0 or more is refused', async () => {
403
+ const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
404
+ for (const query of [{ max_cents: '-1' }, { max_cents: '5.5' }, { max_credits: 'ten' }, { max_cents: ['1', '2'] }]) {
405
+ const res = await invoke(handle, { query });
406
+ assert.equal(res.statusCode, 400, JSON.stringify(query));
407
+ assert.match(res.body.message, /maximum price/);
408
+ }
409
+ });
@@ -105,3 +105,23 @@ test('the adoption read selects the contract itself', () => {
105
105
  // while still looking well-formed.
106
106
  assert.match(src('modules/specialities/db.js'), /SELECT s\.id, s\.slug, s\.name, s\.discipline, s\.summary, s\.contract_md/);
107
107
  });
108
+
109
+ // task 1004817: two sessions, two worktrees, two claims in two crafts. Each
110
+ // session gets ONLY the contract for the claim bound to its own worktree; the
111
+ // account-wide pool is only the fallback when no claim is bound here.
112
+ test('two claims in two worktrees: each session gets only its own contract', () => {
113
+ const claims = [
114
+ { ...claim('artist'), task_id: '1', worktree_name: 'task-1' },
115
+ { ...claim('engineer'), task_id: '2', worktree_name: 'task-2' },
116
+ ];
117
+ const specs = [
118
+ sp({ discipline: 'artist', contract: 'Speciality: Pixel art (under artist).' }),
119
+ sp({ discipline: 'engineer', contract: 'Speciality: Compilers (under engineer).' }),
120
+ ];
121
+ const got = (worktree) => ids(conductor.evaluateTriggers({ activeClaims: claims, specialities: specs, worktree }))
122
+ .filter((i) => String(i).startsWith('speciality:'));
123
+ assert.deepEqual(got('task-1'), ['speciality:artist']);
124
+ assert.deepEqual(got('task-2'), ['speciality:engineer']);
125
+ // No claim bound to this tree (main checkout): today's behaviour, every craft in play.
126
+ assert.deepEqual(got('main-checkout').sort(), ['speciality:artist', 'speciality:engineer']);
127
+ });