@bongos/core 1.24.15 → 1.24.17

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.
@@ -331,12 +331,12 @@
331
331
  const files = readyFiles();
332
332
  const c = lib.payChoices(state.keys, state.balance);
333
333
  const thumbs = files.slice(0, 3).map((f) => thumb(f)).join('');
334
- const ownCard = c.own ? `
335
- <button type="button" class="st-action ais-card ais-card--own" data-pay="own">
336
- <span class="st-action__label">My own key</span>
337
- <span class="ais-card__line">Free here. ${esc(c.own.billedBy)} bills you for it directly.</span>
338
- <span class="ais-card__foot">${esc(c.own.name)} key ending ${esc(c.own.last4 || '')}</span>
339
- </button>` : '';
334
+ const ownCard = c.owns.map((k) => `
335
+ <button type="button" class="st-action ais-card ais-card--own" data-pay="${esc(k.provider)}">
336
+ <span class="st-action__label">${c.owns.length > 1 ? `My ${esc(k.name)} key` : 'My own key'}</span>
337
+ <span class="ais-card__line">Free here. ${esc(k.billedBy)} bills you for it directly.</span>
338
+ <span class="ais-card__foot">${c.owns.length > 1 ? '' : `${esc(k.name)} `}key ending ${esc(k.last4 || '')}</span>
339
+ </button>`).join('');
340
340
  const balanceCard = c.payments ? `
341
341
  <button type="button" class="st-action ais-card ais-card--ours" data-pay="ours" ${c.short ? 'disabled' : ''}>
342
342
  <span class="st-action__label">My balance</span>
@@ -346,8 +346,8 @@
346
346
  let foot = 'You only pay for what it really cost. If it fails, nothing is taken.';
347
347
  let extra = '';
348
348
  if (c.payments && c.short) extra = `<p class="ais-faint">Your balance is under ${esc(c.setAside)}. <a href="/builders/ai-studio?view=balance">Add money</a> to pay this way.</p>`;
349
- if (!c.own) extra += `<p class="ais-faint">Have your own OpenRouter or Gemini key? <a href="${SETTINGS_KEYS}">Add it in Settings</a> and make things for free here.</p>`;
350
- if (!c.own && !c.payments) foot = 'Paying from a balance is not set up here yet, so add your own key in Settings to make something.';
349
+ if (!c.owns.length) extra += `<p class="ais-faint">Have your own OpenRouter or Gemini key? <a href="${SETTINGS_KEYS}">Add it in Settings</a> and make things for free here.</p>`;
350
+ if (!c.owns.length && !c.payments) foot = 'Paying from a balance is not set up here yet, so add your own key in Settings to make something.';
351
351
  page.innerHTML = `${stepHead('make')}
352
352
  <h1 class="ais-ask" data-head tabindex="-1">How do you want to pay?</h1>
353
353
  <div class="ais-row ais-row--summary">${thumbs}<span class="ais-grow ais-soft">${esc(lib.quoteLine(state.draft.prompt))}</span><a class="ais-link" href="/builders/ai-studio?step=describe" data-nav="step=describe">change</a></div>
@@ -341,3 +341,8 @@ button.craft-save { display: inline-flex; align-items: center; justify-content:
341
341
  .prow--pick { flex-direction: row; align-items: flex-start; }
342
342
  .pinput, .display-name-input, .artkey-input, .join input, .join-note { max-width: none; }
343
343
  }
344
+
345
+ /* ---- Your age (task 1005028): four radio choices, one per line ---------------- */
346
+ .age-choices { border: 0; padding: 0; margin: 0 0 var(--space-3, 14px); display: grid; gap: 2px; }
347
+ .age-choices label { display: flex; align-items: center; gap: 10px; min-height: 32px; cursor: pointer; }
348
+ .age-choices input { width: 18px; height: 18px; margin: 0; }
@@ -0,0 +1,65 @@
1
+ // modules/hall-ui/public/settings-age.js — the "Your age" panel on /builders/settings
2
+ // (task 1005028). Shows the age band on record and records a new one. AI Studio and
3
+ // payouts read the latest record; the server only accepts it from a person signed in
4
+ // on the website (POST /me/payout-age), so this page is the one way to write it.
5
+ (() => {
6
+ 'use strict';
7
+
8
+ const API = '/api/bongos';
9
+ const { $ } = window.OTB;
10
+ const toast = (msg) => window.OTB.toast(msg);
11
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
12
+
13
+ const LABELS = { adult: '18 or older', '16_17': '16 to 17', '13_15': '13 to 15', under_13: 'Under 13' };
14
+
15
+ // The server's error envelope is { error: { message } }.
16
+ const why = (res) => (res.data && res.data.error && res.data.error.message) || `HTTP ${res.status}`;
17
+
18
+ const currentText = (tier) => (LABELS[tier]
19
+ ? `On record: ${LABELS[tier]}.`
20
+ : 'Nothing on record yet, so AI Studio stays closed to you.');
21
+
22
+ async function load() {
23
+ const res = await api.request('GET', `${API}/me/payouts`);
24
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
25
+ if (!res.ok) throw new Error(why(res));
26
+ const tier = res.data && res.data.age_tier;
27
+ $('#age-current').textContent = currentText(tier);
28
+ // Only a known band is looked up, so nothing from the response reaches a selector.
29
+ const pick = LABELS[tier] ? document.querySelector(`input[name="age-tier"][value="${tier}"]`) : null;
30
+ if (pick) pick.checked = true;
31
+ }
32
+
33
+ async function save() {
34
+ const chosen = document.querySelector('input[name="age-tier"]:checked');
35
+ const status = $('#age-status');
36
+ if (!chosen) { status.textContent = 'Pick the group you are in first.'; return; }
37
+ const btn = $('#age-save');
38
+ btn.disabled = true;
39
+ status.textContent = 'Saving…';
40
+ try {
41
+ const res = await api.request('POST', `${API}/me/payout-age`, { body: { tier: chosen.value } });
42
+ if (!res.ok) throw new Error(why(res));
43
+ status.textContent = '';
44
+ $('#age-current').textContent = currentText(chosen.value);
45
+ toast('Age saved.');
46
+ } catch (err) {
47
+ status.textContent = '';
48
+ toast(`Could not save your age: ${err.message}`);
49
+ } finally {
50
+ btn.disabled = false;
51
+ }
52
+ }
53
+
54
+ async function setup() {
55
+ try {
56
+ await load();
57
+ $('#age-scroll').hidden = false;
58
+ $('#age-save').addEventListener('click', save);
59
+ } catch (err) {
60
+ toast(`Could not load your age: ${err.message}`);
61
+ }
62
+ }
63
+
64
+ setup();
65
+ })();
@@ -91,6 +91,31 @@
91
91
  <p class="section-note">Saves when you press Save; shows everywhere at once.</p>
92
92
  </section>
93
93
 
94
+ <!-- YOUR AGE — the band on record, and where you record it (task 1005028).
95
+ AI Studio and payouts both read the latest payout_age_records row, and
96
+ the only writer is POST /me/payout-age, which needs a person signed in
97
+ on the website. Rendered by settings-age.js; only the band is kept. -->
98
+ <section class="scroll" id="age-scroll" aria-labelledby="age-h" data-group="account" hidden>
99
+ <h2 id="age-h" class="scroll__h">Your age</h2>
100
+ <p class="scroll__lede">
101
+ AI Studio and getting paid both depend on your age. Pick the group you
102
+ are in. Only the group is kept, never your birthday.
103
+ </p>
104
+ <p class="settings-card__hint" id="age-current" aria-live="polite"></p>
105
+ <fieldset class="age-choices" id="age-choices">
106
+ <legend class="field-label">I am</legend>
107
+ <label><input type="radio" name="age-tier" value="adult"> 18 or older</label>
108
+ <label><input type="radio" name="age-tier" value="16_17"> 16 to 17</label>
109
+ <label><input type="radio" name="age-tier" value="13_15"> 13 to 15</label>
110
+ <label><input type="radio" name="age-tier" value="under_13"> Under 13</label>
111
+ </fieldset>
112
+ <div class="display-name-row">
113
+ <button type="button" class="craft-save" id="age-save">Save</button>
114
+ </div>
115
+ <p class="settings-status" id="age-status" role="status" aria-live="polite"></p>
116
+ <p class="section-note">AI Studio is for adults only, because the AI companies behind it only serve adults.</p>
117
+ </section>
118
+
94
119
  <!-- PROFILE AND PRIVACY — one link out to the hub's account page
95
120
  (task 1004259, ADR 0337 D4.3). Handle, bio, links, public/private,
96
121
  recruiters and the hide-my-numbers switch belong to the platform
@@ -501,6 +526,7 @@
501
526
  settings.js, which sits close to the 1500-line size ratchet. -->
502
527
  <script src="/builders/settings-sessions.js?v=2026-08-29-sessions"></script>
503
528
  <script src="/builders/settings-passkeys.js?v=2026-10-15-passkeys"></script>
529
+ <script src="/builders/settings-age.js?v=2026-10-30-age"></script>
504
530
  <script src="/builders/settings-ai-keys.js?v=2026-10-28-ai-keys"></script>
505
531
  <!-- task 1002514: the interaction-profile panel ("How Claude talks to you"), on
506
532
  the same precedent and for the same reason — settings.js is against the
@@ -1107,6 +1107,7 @@
1107
1107
  const PANEL_ANCHORS = {
1108
1108
  'connections': 'connections-scroll',
1109
1109
  'display-name': 'display-name-scroll',
1110
+ 'age': 'age-scroll',
1110
1111
  // task 1004259: the two privacy panels became one link-out row; their old
1111
1112
  // anchors still land on it (the hall profile links /settings#cross-project).
1112
1113
  'profile-privacy': 'account-hub-scroll',
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.15",
3
+ "version": "1.24.17",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.15",
9
+ "version": "1.24.17",
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.24.15",
3
+ "version": "1.24.17",
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",
@@ -10975,5 +10975,17 @@
10975
10975
  "id": "1004906",
10976
10976
  "text": "The payment processor is chosen: Stripe Connect. Each project's money will sit in its own Stripe account, so we never hold anyone's money. J.P. Morgan Payments is recorded as the backup. Nothing is bought or opened yet, and th"
10977
10977
  }
10978
+ ],
10979
+ "1.24.16": [
10980
+ {
10981
+ "id": "1005028",
10982
+ "text": "Settings now has a Your age section where you record your age group, which switches on AI Studio for adults."
10983
+ }
10984
+ ],
10985
+ "1.24.17": [
10986
+ {
10987
+ "id": "1005029",
10988
+ "text": "Artists with both an OpenRouter and a Gemini key saved can now choose which one runs their AI request."
10989
+ }
10978
10990
  ]
10979
10991
  }
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.15'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.17'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -246,6 +246,8 @@ test('which key runs: theirs whenever they have one, unless they ask for ours',
246
246
  assert.deepEqual(K.chooseKey(['openrouter'], 'ours'), { source: 'ours' });
247
247
  assert.deepEqual(K.chooseKey([], 'own'), { error: 'no_own_key' });
248
248
  assert.deepEqual(K.chooseKey(['openrouter'], 'free'), { error: 'bad_pay_with' });
249
+ assert.deepEqual(K.chooseKey(['gemini', 'openrouter'], 'gemini'), { source: 'own', provider: 'gemini' }, 'a named key wins over the default');
250
+ assert.deepEqual(K.chooseKey(['openrouter'], 'gemini'), { error: 'no_own_key' });
249
251
  });
250
252
 
251
253
  test('on their own key no money moves: one own_key row, price 0, cost noted', async () => {
@@ -469,3 +469,18 @@ test('walls: the studio home shows the AI door only where the module is on', ()
469
469
  assert.match(js, /mods\.enabled\['ai-studio'\] !== true\) return;/, 'off by default: a missing flag hides the door');
470
470
  assert.match(js, /loadAi\(\)\]/);
471
471
  });
472
+
473
+ test('page: with both own keys saved, step 3 offers one card each and sends the one pressed', async () => {
474
+ const both = { keys: [{ provider: 'openrouter', set: true, last4: '4f2a' }, { provider: 'gemini', set: true, last4: '9c1d' }] };
475
+ assert.deepEqual(lib.ownKeys(both).map((k) => k.provider), ['openrouter', 'gemini']);
476
+ const p = bootPage('?step=make&from=1001', { routes: { 'GET /api/bongos/ai-studio/keys': both } });
477
+ await settle();
478
+ const pays = p.page.querySelectorAll('[data-pay]').map((b) => b.getAttribute('data-pay'));
479
+ assert.deepEqual(pays, ['openrouter', 'gemini', 'ours']);
480
+ assert.match(p.page.innerHTML, /My Gemini key/);
481
+ const gem = p.page.querySelectorAll('[data-pay]').find((b) => b.getAttribute('data-pay') === 'gemini');
482
+ await gem.fire('click');
483
+ await settle();
484
+ const sent = p.calls.find((c) => c.method === 'POST' && c.url.endsWith('/ai-studio/requests'));
485
+ assert.equal(sent.body.pay_with, 'gemini');
486
+ });
@@ -0,0 +1,110 @@
1
+ // tests/settings_age.mjs — the "Your age" panel on /builders/settings (task 1005028).
2
+ // WHY. AI Studio and payouts read the latest age record, and the only writer is a
3
+ // route that needs a person signed in on the website. This page is the one way to
4
+ // write it, so the panel must read the band, offer exactly the server's four bands,
5
+ // and save through that route — a panel that drifted from the server's tiers would
6
+ // offer a choice the server refuses.
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { readFileSync } from 'node:fs';
10
+ import { createRequire } from 'node:module';
11
+ import vm from 'node:vm';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const read = (f) => readFileSync(new URL(`../modules/hall-ui/public/${f}`, import.meta.url), 'utf8');
15
+ const gates = require('../src/bongos/payout-gates.js');
16
+
17
+ const html = read('settings.html');
18
+ const js = read('settings-age.js');
19
+
20
+ test('the panel offers exactly the age bands the server accepts', () => {
21
+ const offered = [...html.matchAll(/name="age-tier" value="([^"]+)"/g)].map((m) => m[1]).sort();
22
+ assert.deepEqual(offered, [...gates.AGE_TIERS].sort());
23
+ });
24
+
25
+ test('the section joins a rail group, starts hidden, and its script is loaded and self-boots', () => {
26
+ const section = html.match(/<section class="scroll" id="age-scroll"[^>]*>/);
27
+ assert.ok(section, 'the panel has a section');
28
+ assert.match(section[0], /data-group="account"/);
29
+ assert.match(section[0], /\bhidden\b/);
30
+ assert.match(html, /<script src="\/builders\/settings-age\.js/);
31
+ assert.match(js, /\$\('#age-scroll'\)\.hidden = false/);
32
+ assert.match(read('settings.js'), /'age': 'age-scroll'/, 'the deep link /settings#age resolves');
33
+ });
34
+
35
+ // Run the real script against a fake page and a fake client.
36
+ async function run({ tier, saveStatus = 201, loadStatus = 200 }) {
37
+ const calls = [];
38
+ const el = () => ({ hidden: true, textContent: '', disabled: false, listeners: {}, addEventListener(e, f) { this.listeners[e] = f; } });
39
+ const nodes = { '#age-current': el(), '#age-status': el(), '#age-save': el(), '#age-scroll': el() };
40
+ let checked = null;
41
+ const radios = {};
42
+ const sandbox = {
43
+ window: {
44
+ OTB: { $: (q) => nodes[q], toast: (m) => calls.push(['toast', m]) },
45
+ BongosClient: {
46
+ createClient: () => ({
47
+ request: async (method, path, opts) => {
48
+ calls.push([method, path, opts && opts.body]);
49
+ if (method === 'GET') return loadStatus < 400 ? { ok: true, status: 200, data: { age_tier: tier } } : { ok: false, status: loadStatus, data: { error: { message: 'The server said so.' } } };
50
+ return saveStatus < 400 ? { ok: true, status: saveStatus, data: {} } : { ok: false, status: saveStatus, data: { error: { message: 'no' } } };
51
+ },
52
+ }),
53
+ },
54
+ location: { assign() {} },
55
+ },
56
+ document: {
57
+ querySelector: (q) => {
58
+ if (q === 'input[name="age-tier"]:checked') return checked;
59
+ const m = q.match(/value="([^"]+)"/);
60
+ return m ? (radios[m[1]] = radios[m[1]] || { value: m[1], checked: false }) : null;
61
+ },
62
+ },
63
+ };
64
+ vm.runInNewContext(js, sandbox);
65
+ await new Promise((r) => setTimeout(r, 0));
66
+ return { calls, nodes, radios, choose: (v) => { checked = { value: v }; } };
67
+ }
68
+
69
+ test('nothing on record says so, and shows the panel', async () => {
70
+ const r = await run({ tier: null });
71
+ assert.match(r.nodes['#age-current'].textContent, /Nothing on record/);
72
+ assert.equal(r.nodes['#age-scroll'].hidden, false);
73
+ });
74
+
75
+ test('the band on record is shown and preselected', async () => {
76
+ const r = await run({ tier: 'adult' });
77
+ assert.match(r.nodes['#age-current'].textContent, /18 or older/);
78
+ assert.equal(r.radios.adult.checked, true);
79
+ });
80
+
81
+ test('saving posts the chosen band to the server route, and refuses to post with nothing chosen', async () => {
82
+ const r = await run({ tier: null });
83
+ await r.nodes['#age-save'].listeners.click();
84
+ assert.equal(r.calls.filter((c) => c[0] === 'POST').length, 0, 'no choice, no request');
85
+ r.choose('adult');
86
+ await r.nodes['#age-save'].listeners.click();
87
+ // JSON, because an object born inside the vm fails strict deepEqual on its prototype.
88
+ assert.equal(JSON.stringify(r.calls.find((c) => c[0] === 'POST')), JSON.stringify(['POST', '/api/bongos/me/payout-age', { tier: 'adult' }]));
89
+ assert.match(r.nodes['#age-current'].textContent, /18 or older/);
90
+ });
91
+
92
+ test('a refused save keeps what was on record and tells the person', async () => {
93
+ const r = await run({ tier: null, saveStatus: 403 });
94
+ r.choose('adult');
95
+ await r.nodes['#age-save'].listeners.click();
96
+ assert.match(r.nodes['#age-current'].textContent, /Nothing on record/);
97
+ assert.ok(r.calls.some((c) => c[0] === 'toast' && /Could not save/.test(c[1])));
98
+ });
99
+
100
+ test('a failed read says what the server said and leaves the panel hidden', async () => {
101
+ const r = await run({ tier: null, loadStatus: 500 });
102
+ assert.equal(r.nodes['#age-scroll'].hidden, true);
103
+ assert.ok(r.calls.some((c) => c[0] === 'toast' && /The server said so./.test(c[1])), 'the envelope message, not a bare HTTP code');
104
+ });
105
+
106
+ test('a band the page does not know is never put into a selector', async () => {
107
+ const r = await run({ tier: 'x"],body[x="' });
108
+ assert.match(r.nodes['#age-current'].textContent, /Nothing on record/);
109
+ assert.equal(Object.keys(r.radios).length, 0);
110
+ });