@bongos/core 1.19.427 → 1.19.429
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 +75 -35
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +71 -0
- package/docs/adr/0214-canonical-profile-url-one-resolver-dark-until-the-page-exists.md +90 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +3 -2
- package/docs/copy-inventory.md +45 -36
- package/docs/copy-registry.json +127 -46
- package/docs/file-map.md +3 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/profile-canonical.js +137 -0
- package/modules/hall-ui/public/profile-connections.js +20 -9
- package/modules/hall-ui/public/profile.html +9 -0
- package/modules/hall-ui/public/profile.js +5 -0
- package/modules/platform-identity/account-visibility.js +31 -12
- package/modules/platform-identity/canonical-profile.js +143 -0
- package/modules/platform-identity/connections.js +4 -3
- package/modules/platform-identity/migrations/platform_identity_019_terms_acceptance.sql +47 -0
- package/modules/platform-identity/platform-identity.js +12 -10
- package/modules/platform-identity/routes/connections.js +4 -3
- package/modules/platform-identity/routes/sso.js +7 -0
- package/modules/platform-identity/terms-acceptance.js +97 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/terms.html +8 -0
- package/src/bongos/routes/builders.js +24 -2
- package/src/bongos/serve-internal.js +28 -2
- package/src/module-api.js +1 -1
- package/tests/canonical_profile_url.mjs +628 -0
- package/tests/connections_api.mjs +13 -8
- package/tests/connections_ownership.mjs +6 -1
- package/tests/effective_visibility_predicate.mjs +89 -14
- package/tests/profile_connections_ui.mjs +60 -7
- package/tests/profile_privacy_owner_view.mjs +31 -4
- package/tests/profile_route.mjs +45 -13
- package/tests/terms_acceptance.mjs +573 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// modules/platform-identity/terms-acceptance.js — THE platform T&C acceptance
|
|
2
|
+
// record: which text is current, and the SQL that stamps a builder's acceptance
|
|
3
|
+
// of it (ADR 0213, task 1002913).
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS ANSWERS: "did the hub itself witness this human accept the platform
|
|
6
|
+
// terms, and which text did they accept?" — the artifact ADR 0141 §4 deferred
|
|
7
|
+
// consent for cross-project aggregation to, and ADR 0171 D3 reserved a place for
|
|
8
|
+
// ("the one path where a human is demonstrably at the hub, and where T&C
|
|
9
|
+
// acceptance can later be captured, task 1002913").
|
|
10
|
+
//
|
|
11
|
+
// WHY ITS OWN FILE. platform-identity.js sits at the 1500-line fitness ratchet
|
|
12
|
+
// with `oversized_file_count: 0` as the baseline — there is no headroom, and
|
|
13
|
+
// four sessions have already set the precedent that new logic lands beside it
|
|
14
|
+
// (account-visibility.js, application-echo.js, routes/connections.js,
|
|
15
|
+
// src/bongos/routes/my-sessions.js). This file is that, for consent.
|
|
16
|
+
//
|
|
17
|
+
// THE WRITE SIDE ONLY — read the record through account-visibility.js. That file
|
|
18
|
+
// is the SOLE owner of every effective-visibility answer, and acceptance is now
|
|
19
|
+
// part of the consent axis it publishes (accountActiveSql / isAccountActive).
|
|
20
|
+
// Nothing here compares terms_accepted_at to anything; these are the statements
|
|
21
|
+
// that SET it. tests/effective_visibility_predicate.mjs enforces that split —
|
|
22
|
+
// its SPELLINGS list fails any file but the owner that writes the acceptance
|
|
23
|
+
// predicate, and deliberately does not match a write.
|
|
24
|
+
//
|
|
25
|
+
// WHY THE CALLER CANNOT FORGE ONE. These fragments have exactly one call site:
|
|
26
|
+
// upsertAccount's `activate` branch, whose only caller is /sso/authorize
|
|
27
|
+
// (routes/sso.js). There `activate: true` is a SOURCE LITERAL and every other
|
|
28
|
+
// field comes from the hub's own resolved session — the ADR 0205 D3 property
|
|
29
|
+
// established for recordFederatedSignin by task 1003340. No request-body field
|
|
30
|
+
// reaches these statements, which is the whole point: an acceptance record a
|
|
31
|
+
// caller could assert would be evidence of nothing.
|
|
32
|
+
//
|
|
33
|
+
// IDEMPOTENT BY CONSTRUCTION: the conflict branch is COALESCE(existing, new), so
|
|
34
|
+
// the FIRST acceptance stands and a second sign-in never rewrites or duplicates
|
|
35
|
+
// it. The two columns are only ever written together, so they cannot diverge.
|
|
36
|
+
//
|
|
37
|
+
// A LATER REVISION IS A KNOWN GAP, NOT A SILENT ONE. Because the version is
|
|
38
|
+
// recorded and never overwritten, publishing a new TERMS_VERSION leaves every
|
|
39
|
+
// existing row naming the text it actually agreed to — so the platform can see
|
|
40
|
+
// who has not accepted the new one. Re-acceptance on revision is deliberately
|
|
41
|
+
// NOT built here (ADR 0213 D4): it is a product flow, not a storage shape.
|
|
42
|
+
'use strict';
|
|
43
|
+
|
|
44
|
+
// The version of the platform terms currently served at /terms. It is stamped
|
|
45
|
+
// onto every acceptance, so it must match the version the page states — the
|
|
46
|
+
// pairing is asserted by tests/terms_acceptance.mjs, which reads both.
|
|
47
|
+
//
|
|
48
|
+
// Date-stamped rather than numbered: the page says "Version <this>", and a
|
|
49
|
+
// reader comparing page to record should not have to translate.
|
|
50
|
+
const TERMS_VERSION = '2026-08-29';
|
|
51
|
+
|
|
52
|
+
// A Postgres placeholder, e.g. '$5'. Interpolated into SQL, so it is validated
|
|
53
|
+
// rather than trusted — the account-visibility.js alias-guard pattern.
|
|
54
|
+
function assertPlaceholder(fn, value) {
|
|
55
|
+
if (!/^\$[1-9][0-9]*$/.test(String(value))) {
|
|
56
|
+
throw new Error(`${fn}: expected a Postgres placeholder like '$5', got ${JSON.stringify(value)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The acceptance columns, for an INSERT column list. Leading comma: it appends
|
|
61
|
+
// to an existing list rather than standing alone.
|
|
62
|
+
function acceptanceInsertColumns() {
|
|
63
|
+
return ', terms_accepted_at, terms_version';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The matching VALUES fragment. A row created by a NON-activating caller
|
|
67
|
+
// (ensureAccount's check-in / device-poll path) gets NULL — fail-closed: no hub
|
|
68
|
+
// session witnessed that human, so there is no acceptance to record.
|
|
69
|
+
function acceptanceInsertValues(activateParam, versionParam) {
|
|
70
|
+
assertPlaceholder('acceptanceInsertValues', activateParam);
|
|
71
|
+
assertPlaceholder('acceptanceInsertValues', versionParam);
|
|
72
|
+
return `, CASE WHEN ${activateParam}::boolean THEN now() ELSE NULL END`
|
|
73
|
+
+ `, CASE WHEN ${activateParam}::boolean THEN ${versionParam}::text ELSE NULL END`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The ON CONFLICT DO UPDATE SET fragment — the idempotent half. COALESCE keeps
|
|
77
|
+
// the FIRST acceptance, so signing in again re-reads the terms but does not
|
|
78
|
+
// restamp them; a non-activating upsert leaves both columns exactly as they are,
|
|
79
|
+
// mirroring the one-way rule profile_state already follows.
|
|
80
|
+
//
|
|
81
|
+
// Trailing comma: it sits between two other assignments in the SET list.
|
|
82
|
+
function acceptanceConflictSet(activateParam, versionParam) {
|
|
83
|
+
assertPlaceholder('acceptanceConflictSet', activateParam);
|
|
84
|
+
assertPlaceholder('acceptanceConflictSet', versionParam);
|
|
85
|
+
const self = 'platform_identity_accounts';
|
|
86
|
+
return `terms_accepted_at = CASE WHEN ${activateParam}::boolean`
|
|
87
|
+
+ ` THEN COALESCE(${self}.terms_accepted_at, now()) ELSE ${self}.terms_accepted_at END,`
|
|
88
|
+
+ ` terms_version = CASE WHEN ${activateParam}::boolean`
|
|
89
|
+
+ ` THEN COALESCE(${self}.terms_version, ${versionParam}::text) ELSE ${self}.terms_version END,`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
TERMS_VERSION,
|
|
94
|
+
acceptanceInsertColumns,
|
|
95
|
+
acceptanceInsertValues,
|
|
96
|
+
acceptanceConflictSet,
|
|
97
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.429",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.429",
|
|
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.
|
|
3
|
+
"version": "1.19.429",
|
|
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",
|
|
@@ -61,6 +61,14 @@
|
|
|
61
61
|
<p>A project stood up from the hub runs on the platform's servers at your request. Any cost is shown before you confirm. Tearing it down stops its server; it keeps its database and backups so it can come back, and its web address stays with it while it can.</p>
|
|
62
62
|
<!--apex-only:end-->
|
|
63
63
|
|
|
64
|
+
<!--apex-only:start-->
|
|
65
|
+
<h2>Your work across projects</h2>
|
|
66
|
+
<p>Each {{productName}} project you build in reports a summary of your work there to the platform: the {{currency}} you earned, the works you shipped, your karma, the rank that project gave you, the disciplines you worked in, and when you were last active. The platform adds these up across every project into one cross-project record. Projects report this themselves — the platform does not read their databases.</p>
|
|
67
|
+
<p><b>These numbers describe; they do not govern.</b> Your cross-project record is descriptive analytics and never authority. What you may do inside any project is decided by that project alone, from its own records, at the moment you ask. A large record grants nothing and a hidden one takes nothing away; no project is required to read it, and none may treat it as permission. A rank shown here is that project’s own standing for you, as that project reported it — the platform holds no rank of its own.</p>
|
|
68
|
+
<p><b>What is public.</b> Once you have signed in here and accepted these terms, your profile and that record are public by default: at your handle’s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in here stays provisional and is published nowhere.</p>
|
|
69
|
+
<p><b>What you control.</b> From your settings you can hide your statistics, drop out of the recruiting directory, or make your account private. Each takes effect immediately, everywhere, including on a page someone already has open. Hiding removes the platform’s combined view; it does not retract the per-project numbers each project publishes itself, so a determined reader could still add them up the hard way.</p>
|
|
70
|
+
<p><b>Your acceptance is recorded.</b> The platform stores the date you accepted these terms, and which version, on your account. It is written only by signing in here, and only once — signing in again does not change the original date. This is version <b>2026-08-29</b>.</p>
|
|
71
|
+
<!--apex-only:end-->
|
|
64
72
|
<h2>Fair use</h2>
|
|
65
73
|
<p>Don't use {{productName}} to break the law, to attack this site or other people's projects, to mine, spam or otherwise abuse hosted resources, or to harass other builders. The owner may deactivate a builder or take a project offline for breaking these, and may set further house rules inside the hall.</p>
|
|
66
74
|
|
|
@@ -32,6 +32,7 @@ const { validateOrRespond, LIMITS, parseId } = require('./_helpers');
|
|
|
32
32
|
// 0093 §1): GET /builders/:id/onboarding resolves the `onboarding` kernel port
|
|
33
33
|
// for the roster-visible checklist (read/shape) — never a direct require.
|
|
34
34
|
const { pool } = require('../pool');
|
|
35
|
+
const { publicReadRateLimitMiddleware } = require('../middleware/rate-limit');
|
|
35
36
|
const seams = require('../../module-seams');
|
|
36
37
|
const { branding } = require('../../branding');
|
|
37
38
|
// task 1003208: structured logging — was console.*. A core file logs through
|
|
@@ -480,7 +481,17 @@ module.exports = function buildBuildersRouter() {
|
|
|
480
481
|
// monthly_budget_usd / system_role / box_blocked*) are NEVER spread into the
|
|
481
482
|
// JSON. A leak-prevention unit test (tests/profile_route.mjs) asserts their
|
|
482
483
|
// absence + the 404-on-unknown-id behaviour.
|
|
483
|
-
|
|
484
|
+
// THROTTLED since task 1002804. The global limiter meters WRITES only
|
|
485
|
+
// (rateLimitMiddleware returns early for GET) and the public read limiter is
|
|
486
|
+
// mounted under /public/*, which this is not — so this route answered at line
|
|
487
|
+
// rate. That was tolerable while it re-presented leaderboard data; it is not
|
|
488
|
+
// now that it also carries `handle`, because an id walk then yields the
|
|
489
|
+
// builder → canonical-profile-handle map in one call per builder.
|
|
490
|
+
// modules/platform-identity/routes/public-profile.js caps /profiles/:handle
|
|
491
|
+
// for exactly that mapping, so the two routes that publish it get the same
|
|
492
|
+
// ceiling. The EXISTING shared /public/* budget (120/60s per IP), not a new
|
|
493
|
+
// one — ADR 0209's rule that routes answering one question spend one budget.
|
|
494
|
+
router.get('/builders/:id/profile', publicReadRateLimitMiddleware, async (req, res) => {
|
|
484
495
|
const targetId = parseId(req, res, { code: 'bad_builder_id', positiveInt: true });
|
|
485
496
|
if (targetId === null) return;
|
|
486
497
|
try {
|
|
@@ -493,11 +504,21 @@ module.exports = function buildBuildersRouter() {
|
|
|
493
504
|
// shelf degrades to empty and the cross_project section is absent, not a 500.
|
|
494
505
|
const reward = seams.resolveOptional('reward');
|
|
495
506
|
const hub = seams.resolveOptional('platform-identity.hub');
|
|
496
|
-
|
|
507
|
+
// handle: the account's PUBLIC canonical-profile address (P08, task
|
|
508
|
+
// 1002804 / ADR 0214 D3). It rides a DIFFERENT predicate from
|
|
509
|
+
// cross_project on purpose — existence, not the rollup — because
|
|
510
|
+
// hide_stats suppresses numbers and never addressability, so a builder
|
|
511
|
+
// who hides their stats still has a canonical URL and a connect button.
|
|
512
|
+
// Absent (null) for no account, provisional and private alike, which is
|
|
513
|
+
// the same indistinguishable answer cross_project already gives.
|
|
514
|
+
// Optional-chained so an older hub provider without the function degrades
|
|
515
|
+
// to null rather than throwing across a version window.
|
|
516
|
+
const [shippedCount, recentShips, achievements, crossProject, handle] = await Promise.all([
|
|
497
517
|
lc().countShippedTasksForBuilder(targetId),
|
|
498
518
|
lc().recentShippedTasksByBuilder(targetId, 5),
|
|
499
519
|
reward ? reward.getBuilderAchievements(targetId) : [],
|
|
500
520
|
hub ? hub.getPublicProfileExtras(b.github_id) : null,
|
|
521
|
+
hub && hub.getPublicHandle ? hub.getPublicHandle(b.github_id) : null,
|
|
501
522
|
]);
|
|
502
523
|
// WHITELIST — only the leaderboard-equivalent public field set. Never spread
|
|
503
524
|
// the raw row: it carries anthropic_email / anthropic_user_id /
|
|
@@ -531,6 +552,7 @@ module.exports = function buildBuildersRouter() {
|
|
|
531
552
|
recent_ships: recentShips,
|
|
532
553
|
achievements,
|
|
533
554
|
cross_project: crossProject,
|
|
555
|
+
handle: handle || null,
|
|
534
556
|
});
|
|
535
557
|
}
|
|
536
558
|
res.json({
|
|
@@ -41,6 +41,7 @@ const { attachFail } = require('./middleware/error-envelope');
|
|
|
41
41
|
const metrics = require('./metrics');
|
|
42
42
|
const { logger } = require('./logger');
|
|
43
43
|
const gdsAuth = require('./auth');
|
|
44
|
+
const { loadIdpConfig } = require('./auth-config');
|
|
44
45
|
const { buildStampedHtml } = require('../asset-version');
|
|
45
46
|
const { buildInfo } = require('../build-info');
|
|
46
47
|
const { buildDevboxDownloads } = require('../devbox-downloads');
|
|
@@ -164,7 +165,7 @@ function injectBrandingGlobal(html, brand = clientBranding(), mods = clientModul
|
|
|
164
165
|
// fill cannot reach them, and a baked one would make every instance advertise
|
|
165
166
|
// whichever instance happened to author the file (task 1002748, direction C5/C6).
|
|
166
167
|
// Pure + exported for the test; both origins are injectable so it drives with zero I/O.
|
|
167
|
-
function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOrigin(), hallOrigin = safeBuildersOrigin(), sourceRepo = safeSourceRepoUrl(), instanceBase = safeInstanceBase()) {
|
|
168
|
+
function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOrigin(), hallOrigin = safeBuildersOrigin(), sourceRepo = safeSourceRepoUrl(), instanceBase = safeInstanceBase(), hubOrigin = safeHubOrigin()) {
|
|
168
169
|
const world = (brand.identity && brand.identity.worldName) || '';
|
|
169
170
|
const product = (brand.identity && brand.identity.productName) || '';
|
|
170
171
|
const tagline = (brand.copy && brand.copy.tagline) || '';
|
|
@@ -182,7 +183,13 @@ function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOri
|
|
|
182
183
|
// whichever instance authored the file
|
|
183
184
|
.split('{{buildersOrigin}}').join(esc(hall))
|
|
184
185
|
.split('{{sourceRepoUrl}}').join(esc(sourceRepo || ''))
|
|
185
|
-
.split('{{instanceBase}}').join(esc(instanceBase || ''))
|
|
186
|
+
.split('{{instanceBase}}').join(esc(instanceBase || ''))
|
|
187
|
+
// The PLATFORM HUB's apex — where a builder's canonical cross-project
|
|
188
|
+
// profile lives (P08, task 1002804 / ADR 0214 D2). Empty on a vanilla
|
|
189
|
+
// instance, and a page that reads it MUST render nothing rather than fall
|
|
190
|
+
// back: never write this token straight into an href, because an empty
|
|
191
|
+
// value would leave a same-host relative URL claiming to be the hub.
|
|
192
|
+
.split('{{hubOrigin}}').join(esc(hubOrigin || ''));
|
|
186
193
|
}
|
|
187
194
|
// A link whose href is a BARE origin token that resolves empty is dropped whole,
|
|
188
195
|
// server-side (task 1003242; the branding contract): a pack that names no domains
|
|
@@ -231,6 +238,25 @@ function safeBuildersOrigin() {
|
|
|
231
238
|
function safeInstanceBase() {
|
|
232
239
|
try { return branding().domains.instanceBase || ''; } catch { return ''; }
|
|
233
240
|
}
|
|
241
|
+
// The PLATFORM HUB's apex — the origin a builder's canonical cross-project
|
|
242
|
+
// profile is addressed on (P08, task 1002804; ADR 0171 D1 named this gap, ADR
|
|
243
|
+
// 0214 D2 fills it). Three cases, in the order they are asked:
|
|
244
|
+
// * THIS instance IS the hub — `platform-identity` enabled is the hub flag
|
|
245
|
+
// itself (src/platform-server.js), not a proxy for it — so the hub apex is
|
|
246
|
+
// its own publicOrigin;
|
|
247
|
+
// * a FEDERATED instance — the hub it signs in through, from the same
|
|
248
|
+
// `<PREFIX>_HUB_ORIGIN` env that auth-config's loadIdpConfig resolves; there
|
|
249
|
+
// is no second source of truth for "which hub am I on";
|
|
250
|
+
// * a VANILLA instance — no hub, so '' and every consumer renders nothing.
|
|
251
|
+
// Best-effort like its four siblings above: a branding or env hiccup must resolve
|
|
252
|
+
// to "no hub", never break every served page.
|
|
253
|
+
function safeHubOrigin() {
|
|
254
|
+
try {
|
|
255
|
+
if (isModuleEnabled('platform-identity')) return safePublicOrigin();
|
|
256
|
+
const idp = loadIdpConfig();
|
|
257
|
+
return (idp && idp.origin) || '';
|
|
258
|
+
} catch { return ''; }
|
|
259
|
+
}
|
|
234
260
|
// The instance's own source repo, from the pack's `repo: {owner, name}` binding —
|
|
235
261
|
// another server-only field (the branding contract keeps repo off clientBranding
|
|
236
262
|
// alongside domains). A page that wants to link "read the source" must take it
|
package/src/module-api.js
CHANGED
|
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
33
33
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
34
34
|
// the entry to that file. Look for a version's history there, not here.
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const CORE_VERSION = '1.19.
|
|
36
|
+
const CORE_VERSION = '1.19.429'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
37
37
|
|
|
38
38
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
39
39
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|