@bongos/core 1.20.18 → 1.20.20
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 +72 -27
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0352-a-rollup-report-carries-its-as-of-in-a-header.md +38 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +6 -4
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/guild-totals.js +217 -0
- package/modules/platform-identity/guilds.js +6 -2
- package/modules/platform-identity/migrations/platform_identity_026_activity_reported_as_of.sql +27 -0
- package/modules/platform-identity/migrations/platform_identity_027_guild_totals.sql +45 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +11 -6
- package/modules/platform-identity/pollers/guild-totals.js +76 -0
- package/modules/platform-identity/routes/guilds-public.js +11 -9
- package/modules/platform-identity/routes/sso.js +18 -0
- package/modules/platform-identity/tests/platform-identity.mjs +3 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/run-unit-tests.js +9 -0
- package/src/bongos/auth-admission.js +8 -1
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_as_of.mjs +172 -0
- package/tests/activity_rollup_order_db.mjs +131 -0
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/guild_totals.mjs +183 -0
- package/tests/guild_totals_db.mjs +292 -0
- package/tests/guilds_api.mjs +6 -1
- package/tests/profile_rollup_consent.mjs +2 -0
package/modules/platform-identity/migrations/platform_identity_026_activity_reported_as_of.sql
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- platform_identity_026_activity_reported_as_of.sql — WHEN the reporting instance
|
|
2
|
+
-- read the totals a rollup carries (task 1004268).
|
|
3
|
+
--
|
|
4
|
+
-- WHY. POST /sso/activity/rollup upserted the live activity row last-writer-wins,
|
|
5
|
+
-- and a report carried no timestamp. A sign-in report and a ship report racing to
|
|
6
|
+
-- the hub could commit in the wrong order, leaving the older totals live (and, since
|
|
7
|
+
-- task 1004244, as the newest snapshot) — a leaderboard regression that never
|
|
8
|
+
-- happened, until the builder's next report.
|
|
9
|
+
--
|
|
10
|
+
-- WHAT. One nullable column: the instance's own "as of" for the stored totals, sent
|
|
11
|
+
-- as the Bongos-Report-As-Of header and clamped to the hub's clock on write.
|
|
12
|
+
-- upsertActivity's conflict branch applies a report only when its stamp is at least
|
|
13
|
+
-- this one. NULL means "unstamped" — every row written before this column, and every
|
|
14
|
+
-- report from an instance too old to send the header — and an unstamped side always
|
|
15
|
+
-- applies, which is exactly the old behaviour.
|
|
16
|
+
--
|
|
17
|
+
-- No backfill, no default: inventing a stamp for an existing row would make it
|
|
18
|
+
-- refuse an honest report from an instance whose clock reads earlier than the hub's.
|
|
19
|
+
--
|
|
20
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
|
|
21
|
+
|
|
22
|
+
BEGIN;
|
|
23
|
+
|
|
24
|
+
ALTER TABLE platform_identity_builder_project_activity
|
|
25
|
+
ADD COLUMN IF NOT EXISTS reported_as_of timestamptz;
|
|
26
|
+
|
|
27
|
+
COMMIT;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
-- platform_identity_027_guild_totals.sql — a guild's collective record, as a
|
|
2
|
+
-- stored weekly snapshot (task 1002302, R26; ADR 0337 D4.4, superseding ADR
|
|
3
|
+
-- 0336 D5's read-time sum).
|
|
4
|
+
--
|
|
5
|
+
-- WHAT THIS IS. One row per guild: the totals its page publishes, and when they
|
|
6
|
+
-- were computed. guild-totals.js writes it from a poller and nothing else does;
|
|
7
|
+
-- the public read only SELECTs it. A guild with no row yet publishes no totals.
|
|
8
|
+
--
|
|
9
|
+
-- THE SHAPE CARRIES THE RULES, so no reader has to remember them:
|
|
10
|
+
-- * ONLY BAND FLOORS ARE STORED. Every column is already rounded down on the
|
|
11
|
+
-- 1-2-5 scale (guild-totals.js bandFloor). The exact sum exists only in
|
|
12
|
+
-- memory for the moment it takes to round it, so no read, dump or backup can
|
|
13
|
+
-- recover a number more precise than the page shows. The CHECKs hold the
|
|
14
|
+
-- floor at zero; the scale itself lives in the domain as a named constant.
|
|
15
|
+
-- * computed_at IS THE WEEKLY GATE. The writer's upsert refreshes a row only
|
|
16
|
+
-- when its computed_at is at least a week old, in the same statement, so two
|
|
17
|
+
-- racing sweeps cannot refresh a guild twice in a week.
|
|
18
|
+
-- * THE FK CASCADES, so a dissolved guild takes its record with it (the ADR
|
|
19
|
+
-- 0336 D2 no-tombstone reason).
|
|
20
|
+
--
|
|
21
|
+
-- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141). No rank, credit or admission
|
|
22
|
+
-- path reads this table; it is descriptive only.
|
|
23
|
+
--
|
|
24
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). CREATE only, so the
|
|
25
|
+
-- previous release still runs against this schema. Idempotent.
|
|
26
|
+
|
|
27
|
+
BEGIN;
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS platform_identity_guild_totals (
|
|
30
|
+
guild_id bigint PRIMARY KEY REFERENCES platform_identity_guilds(id) ON DELETE CASCADE,
|
|
31
|
+
credits integer NOT NULL CHECK (credits >= 0),
|
|
32
|
+
works_shipped integer NOT NULL CHECK (works_shipped >= 0),
|
|
33
|
+
karma integer NOT NULL CHECK (karma >= 0),
|
|
34
|
+
projects integer NOT NULL CHECK (projects >= 0),
|
|
35
|
+
computed_at timestamptz NOT NULL DEFAULT now()
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
-- The sweep's working set: the stalest rows first.
|
|
39
|
+
CREATE INDEX IF NOT EXISTS platform_identity_guild_totals_computed_at
|
|
40
|
+
ON platform_identity_guild_totals (computed_at);
|
|
41
|
+
|
|
42
|
+
COMMENT ON TABLE platform_identity_guild_totals IS
|
|
43
|
+
'A guild''s published collective record (ADR 0337 D4.4, task 1002302): band floors on the 1-2-5 scale, never exact sums, recomputed at most weekly by a poller and never on a read. Non-public members'' numbers enter only while the guild has at least 3 counted non-public members. Descriptive only, never authority (ADR 0016).';
|
|
44
|
+
|
|
45
|
+
COMMIT;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"contributes": {
|
|
14
14
|
"routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search", "community-leaderboard", "guilds", "guilds-public"],
|
|
15
15
|
"migrations": true,
|
|
16
|
-
"pollers": ["code-sweep", "visibility-pull"]
|
|
16
|
+
"pollers": ["code-sweep", "visibility-pull", "guild-totals"]
|
|
17
17
|
},
|
|
18
18
|
"provides": ["platform-identity.hub", "project.publishGate", "project.catalog"],
|
|
19
19
|
"consumes": []
|
|
@@ -664,8 +664,10 @@ function sanitizeRecentShips(raw, clientOrigin) {
|
|
|
664
664
|
// Descriptive analytics — never authority. Refreshes last_active on each report.
|
|
665
665
|
// `clientOrigin` is the AUTHENTICATED client's registered origin (never a
|
|
666
666
|
// caller-named one) and exists only to bound the ship links — see
|
|
667
|
-
// origin-containment.js:urlWithinOrigin.
|
|
668
|
-
|
|
667
|
+
// origin-containment.js:urlWithinOrigin. `asOf` is when the instance read these
|
|
668
|
+
// totals (task 1004268, migration 026): an older report never overwrites a newer
|
|
669
|
+
// one; unstamped (an older instance) keeps last-writer-wins; a future stamp is clamped.
|
|
670
|
+
async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], clientOrigin = null, asOf = null }, { pool = defaultPool } = {}) {
|
|
669
671
|
// api.LIVE_RANK_LADDER is a published doorway export (src/module-api.js, a
|
|
670
672
|
// lazy getter over db-kernel's canonical four-rank ladder).
|
|
671
673
|
const safeRank = api.LIVE_RANK_LADDER.includes(rank) ? rank : null;
|
|
@@ -677,17 +679,20 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
|
|
|
677
679
|
return api.withTx(async (client) => {
|
|
678
680
|
const { rows } = await client.query(
|
|
679
681
|
`INSERT INTO platform_identity_builder_project_activity
|
|
680
|
-
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at)
|
|
681
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now()
|
|
682
|
+
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at, reported_as_of)
|
|
683
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now(),
|
|
684
|
+
CASE WHEN $9::timestamptz IS NULL THEN NULL ELSE LEAST($9::timestamptz, now()) END)
|
|
682
685
|
ON CONFLICT (client_id, github_id) DO UPDATE SET
|
|
683
686
|
credits = EXCLUDED.credits, tasks_shipped = EXCLUDED.tasks_shipped, karma = EXCLUDED.karma,
|
|
684
687
|
rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
|
|
685
|
-
recent_ships = EXCLUDED.recent_ships,
|
|
688
|
+
recent_ships = EXCLUDED.recent_ships, reported_as_of = EXCLUDED.reported_as_of,
|
|
686
689
|
last_active = now(), updated_at = now()
|
|
690
|
+
WHERE EXCLUDED.reported_as_of IS NULL OR platform_identity_builder_project_activity.reported_as_of IS NULL
|
|
691
|
+
OR EXCLUDED.reported_as_of >= platform_identity_builder_project_activity.reported_as_of
|
|
687
692
|
RETURNING id, client_id, github_id`,
|
|
688
693
|
// jsonb is bound as a JSON STRING: node-postgres serializes a JS array as a
|
|
689
694
|
// Postgres ARRAY literal, which the jsonb column would reject.
|
|
690
|
-
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships)],
|
|
695
|
+
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships), asOf],
|
|
691
696
|
);
|
|
692
697
|
// Windowed data plane (task 1002977) — best-effort, see activity-snapshots.js.
|
|
693
698
|
await recordActivitySnapshot({ clientId, githubId }, { client });
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/platform-identity/pollers/guild-totals.js — the loader poller entry that
|
|
4
|
+
// refreshes guilds' collective records (R26, task 1002302; ADR 0337 D4.4). The
|
|
5
|
+
// loader's startModulePollers requires THIS file and calls start(deps) at boot /
|
|
6
|
+
// stop() at shutdown for the ENABLED module only (the hub) — non-blocking and
|
|
7
|
+
// failure-isolated, so a sweep hiccup never touches a read. Mirrors visibility-pull.js.
|
|
8
|
+
//
|
|
9
|
+
// WHY A POLLER. D4.4 says the totals are "recomputed at most once a week, never on a
|
|
10
|
+
// read": a read that recomputed would let an observer move the number they are
|
|
11
|
+
// watching by asking for it. So the only writer runs on its own clock, and the
|
|
12
|
+
// public read (getPublicGuild) only SELECTs what it last wrote.
|
|
13
|
+
//
|
|
14
|
+
// Proof: ../../../tests/guild_totals.mjs (DB-free), ../../../tests/guild_totals_db.mjs
|
|
15
|
+
|
|
16
|
+
const api = require('../../../src/module-api');
|
|
17
|
+
const guildTotals = require('../guild-totals');
|
|
18
|
+
|
|
19
|
+
const log = api.logger('platform-identity');
|
|
20
|
+
|
|
21
|
+
// Hourly. The weekly limit is enforced per row by refreshStaleGuildTotals, not by
|
|
22
|
+
// this cadence, so the interval only sets how late a due row may run — and how soon
|
|
23
|
+
// a new guild gets its first record.
|
|
24
|
+
const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
// Guilds per sweep. A ceiling rather than a page: refreshStaleGuildTotals takes the
|
|
27
|
+
// stalest first, so a backlog larger than this converges over a few sweeps.
|
|
28
|
+
const SWEEP_LIMIT = 200;
|
|
29
|
+
|
|
30
|
+
// Wait before the FIRST sweep so boot is never competing with it.
|
|
31
|
+
const FIRST_SWEEP_DELAY_MS = 60 * 1000;
|
|
32
|
+
|
|
33
|
+
let timer = null;
|
|
34
|
+
let firstTimer = null;
|
|
35
|
+
|
|
36
|
+
async function sweepOnce(deps = {}) {
|
|
37
|
+
try {
|
|
38
|
+
const out = await guildTotals.refreshStaleGuildTotals(
|
|
39
|
+
{ limit: SWEEP_LIMIT },
|
|
40
|
+
deps.pool ? { pool: deps.pool } : {},
|
|
41
|
+
);
|
|
42
|
+
// One line per sweep, and only when something was refreshed.
|
|
43
|
+
if (out.refreshed > 0) {
|
|
44
|
+
log.info({ refreshed: out.refreshed, scanned: out.scanned }, 'guild-totals: record(s) refreshed');
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// Best-effort: a sweep failure must never crash the process. The rows it did not
|
|
49
|
+
// refresh stay due, so the next sweep picks them up.
|
|
50
|
+
log.error({ err: err && err.message }, 'guild-totals sweep failed (non-fatal)');
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
start: (deps = {}) => {
|
|
57
|
+
if (timer || firstTimer) return; // idempotent
|
|
58
|
+
firstTimer = setTimeout(() => {
|
|
59
|
+
firstTimer = null;
|
|
60
|
+
sweepOnce(deps);
|
|
61
|
+
timer = setInterval(() => { sweepOnce(deps); }, SWEEP_INTERVAL_MS);
|
|
62
|
+
if (timer.unref) timer.unref();
|
|
63
|
+
}, FIRST_SWEEP_DELAY_MS);
|
|
64
|
+
// unref so neither timer keeps the process alive on its own.
|
|
65
|
+
if (firstTimer.unref) firstTimer.unref();
|
|
66
|
+
},
|
|
67
|
+
stop: () => {
|
|
68
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
69
|
+
if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
|
|
70
|
+
},
|
|
71
|
+
// exported for tests
|
|
72
|
+
sweepOnce,
|
|
73
|
+
SWEEP_INTERVAL_MS,
|
|
74
|
+
SWEEP_LIMIT,
|
|
75
|
+
FIRST_SWEEP_DELAY_MS,
|
|
76
|
+
};
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// routes/guilds.js is own-scoped and held there by tests/own_scope_guard.mjs;
|
|
4
4
|
// this one is deliberately not.
|
|
5
5
|
//
|
|
6
|
-
// GET /guilds/:slug — a PUBLIC guild's name, description, public roster
|
|
7
|
-
// size. 404 for an unknown slug AND
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// 1002301's; this is
|
|
6
|
+
// GET /guilds/:slug — a PUBLIC guild's name, description, public roster,
|
|
7
|
+
// size and rounded weekly totals (task 1002302). 404 for an unknown slug AND
|
|
8
|
+
// for an unlisted guild, to everyone, its own members included: the read
|
|
9
|
+
// takes no viewer, so it cannot vary by one (the private-profile rule, ADR
|
|
10
|
+
// 0171 D4). Members reach an unlisted guild through the own-scope
|
|
11
|
+
// GET /guilds/:slug/members. The page at /g/<slug> is task 1002301's; this is
|
|
12
|
+
// its read.
|
|
12
13
|
//
|
|
13
14
|
// Reaches core only through the doorway (ADR 0083).
|
|
14
15
|
'use strict';
|
|
@@ -38,15 +39,16 @@ module.exports = function guildPublicRoutes() {
|
|
|
38
39
|
|
|
39
40
|
// rank: public — a public guild's page data. The roster is the members who
|
|
40
41
|
// agreed to be shown AND whose own accounts are publicly visible (ADR 0336 D4);
|
|
41
|
-
// size is that roster's length
|
|
42
|
-
//
|
|
42
|
+
// size is that roster's length; totals are the weekly band floors (ADR 0337
|
|
43
|
+
// D4.4), null before the first refresh. no-store: a hide or a visibility flip
|
|
44
|
+
// must beat every cache (ADR 0171 D5).
|
|
43
45
|
router.get('/guilds/:slug', guildReadRateLimit, async (req, res) => {
|
|
44
46
|
res.set('Cache-Control', 'no-store');
|
|
45
47
|
try {
|
|
46
48
|
const slug = guilds.lookupSlug(req.params.slug);
|
|
47
49
|
const out = slug ? await guilds.getPublicGuild(slug, { pool }) : null;
|
|
48
50
|
if (!out) return res.fail('guild_not_found', 404);
|
|
49
|
-
res.json({ guild: out.guild, members: out.members, size: out.size });
|
|
51
|
+
res.json({ guild: out.guild, members: out.members, size: out.size, totals: out.totals });
|
|
50
52
|
} catch (err) {
|
|
51
53
|
logError(`GET /guilds/:slug failed: ${err && err.message}`);
|
|
52
54
|
res.fail('guild_read_failed', { status: 500, message: 'internal error' });
|
|
@@ -83,6 +83,14 @@ const log = api.logger('platform-identity');
|
|
|
83
83
|
|
|
84
84
|
const { pool, validateOrRespond, resolveSession } = api;
|
|
85
85
|
|
|
86
|
+
// The Bongos-Report-As-Of header of a rollup as an ISO timestamp, or null when it
|
|
87
|
+
// is absent or unparseable (task 1004268; see POST /sso/activity/rollup).
|
|
88
|
+
function reportAsOf(req) {
|
|
89
|
+
const raw = req.get ? req.get('Bongos-Report-As-Of') : null;
|
|
90
|
+
const ms = typeof raw === 'string' && raw.length <= 64 ? Date.parse(raw) : NaN;
|
|
91
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
86
94
|
// Minimal standalone error page for the browser leg (no template engine / deps).
|
|
87
95
|
// The /sso/authorize failures are pre-redirect (bad client / bad redirect_uri), so
|
|
88
96
|
// we must render our OWN page rather than bounce to an unvalidated URI.
|
|
@@ -538,6 +546,15 @@ module.exports = function ssoRoutes() {
|
|
|
538
546
|
// the AUTHENTICATED client_id. Descriptive analytics for the Archon-only scouting
|
|
539
547
|
// view — never authority.
|
|
540
548
|
//
|
|
549
|
+
// Optional `Bongos-Report-As-Of` header (task 1004268): when the instance read
|
|
550
|
+
// these totals, so an older report landing last cannot overwrite a newer one.
|
|
551
|
+
// A HEADER, not a body field, because the body is validated strictly and the hub
|
|
552
|
+
// and its instances upgrade independently: a new instance's body field would 400
|
|
553
|
+
// on an older hub and the report would be lost, while an unknown header is
|
|
554
|
+
// ignored. Absent or unparseable reads as unstamped (last-writer-wins, as before)
|
|
555
|
+
// rather than a 400 — the sender never reads the response, so a refusal would
|
|
556
|
+
// silently drop the report instead of only its ordering.
|
|
557
|
+
//
|
|
541
558
|
// rank: public — authenticated by client_id + client_secret, not a builder rank.
|
|
542
559
|
router.post('/sso/activity/rollup', async (req, res) => {
|
|
543
560
|
if (validateOrRespond(req, res, {
|
|
@@ -595,6 +612,7 @@ module.exports = function ssoRoutes() {
|
|
|
595
612
|
// ship link must fall inside (task 1002803). Taken from the client row,
|
|
596
613
|
// never from the body, for the same reason client_id is.
|
|
597
614
|
clientOrigin: client.origin,
|
|
615
|
+
asOf: reportAsOf(req),
|
|
598
616
|
}, { pool });
|
|
599
617
|
res.json({ ok: true });
|
|
600
618
|
} catch (err) {
|
|
@@ -595,8 +595,9 @@ await ta('upsertActivity upserts per (client_id, github_id) with the talent snap
|
|
|
595
595
|
assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
|
|
596
596
|
// recent_ships (task 1002803) rides the same row as a jsonb STRING param —
|
|
597
597
|
// an empty list when the sender reported none, never NULL. Its own cap,
|
|
598
|
-
// shape and link bound are covered by tests/hub_recent_ships.mjs.
|
|
599
|
-
|
|
598
|
+
// shape and link bound are covered by tests/hub_recent_ships.mjs. The last param
|
|
599
|
+
// is the report's as-of stamp (task 1004268): null when the sender gave none.
|
|
600
|
+
assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer'], '[]', null]);
|
|
600
601
|
});
|
|
601
602
|
|
|
602
603
|
await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-ladder rank → null, disciplines validated + capped', async () => {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.20",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.20",
|
|
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.20.
|
|
3
|
+
"version": "1.20.20",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -8164,5 +8164,17 @@
|
|
|
8164
8164
|
"id": "1004265",
|
|
8165
8165
|
"text": "The terms page now says your agreement is recorded when you sign in to a project in your browser, and explains why a command-line-only sign-in doesn't publish your profile."
|
|
8166
8166
|
}
|
|
8167
|
+
],
|
|
8168
|
+
"1.20.19": [
|
|
8169
|
+
{
|
|
8170
|
+
"id": "1004268",
|
|
8171
|
+
"text": "Builders' activity numbers on the platform can no longer briefly go backwards when two updates arrive at the same time."
|
|
8172
|
+
}
|
|
8173
|
+
],
|
|
8174
|
+
"1.20.20": [
|
|
8175
|
+
{
|
|
8176
|
+
"id": "1002302",
|
|
8177
|
+
"text": "Guilds now show a combined track record: rounded totals of their members' credits, shipped work and projects, refreshed once a week. Members who keep their numbers private still count, but only when at least three such members"
|
|
8178
|
+
}
|
|
8167
8179
|
]
|
|
8168
8180
|
}
|
|
@@ -87,6 +87,10 @@ const INTEGRATION = new Set([
|
|
|
87
87
|
// WHERE NOT EXISTS statement) and the upsertActivity wiring against real SQL —
|
|
88
88
|
// a fake pool can prove the query shape but not which reports it inserts.
|
|
89
89
|
'activity_snapshots_db',
|
|
90
|
+
// task 1004268: an older rollup report never overwrites a newer one — a WHERE on
|
|
91
|
+
// the upsert's conflict branch, whose NULL handling and racing-writer outcome only
|
|
92
|
+
// a real Postgres settles (the DB-free sibling is activity_rollup_as_of).
|
|
93
|
+
'activity_rollup_order_db',
|
|
90
94
|
// task 1002978: the windowed leaderboard against real SQL. Which snapshot
|
|
91
95
|
// becomes a window baseline (a LATERAL at-or-before probe), a missing baseline
|
|
92
96
|
// counting as zero, and the privacy matrix filtering are all planner facts the
|
|
@@ -174,6 +178,11 @@ const INTEGRATION = new Set([
|
|
|
174
178
|
// proves the one-owner partial index, the expiry CTE drained before its insert,
|
|
175
179
|
// the cascade on dissolve and the two-statement transfer.
|
|
176
180
|
'guilds_db',
|
|
181
|
+
// task 1002302 (R26): a guild's weekly rounded totals against real SQL. Who is
|
|
182
|
+
// counted (three predicates and a FILTERed k count) and the upsert's week guard,
|
|
183
|
+
// re-evaluated against a racing writer's committed row, are planner facts; the
|
|
184
|
+
// DB-free sibling (guild_totals) pins the band scale and statement shapes.
|
|
185
|
+
'guild_totals_db',
|
|
177
186
|
// task 1003935: 'linkify_refs' was swept in with the three above without the
|
|
178
187
|
// no-DB check they were being fixed for. It touches no Postgres at all — pure
|
|
179
188
|
// string work plus a filesystem walk — so the DB-free lane skipped it and the
|
|
@@ -148,6 +148,10 @@ async function reportActivityRollup({ builderId, githubId = null }) {
|
|
|
148
148
|
const idp = loadIdpConfig();
|
|
149
149
|
if (!idp) return;
|
|
150
150
|
try {
|
|
151
|
+
// Stamped BEFORE the reads: the hub keeps whichever report was read latest, so a
|
|
152
|
+
// sign-in push and a ship push racing to it cannot land the older totals last
|
|
153
|
+
// (task 1004268). A header, not a body field — see the hub's rollup route.
|
|
154
|
+
const asOf = new Date().toISOString();
|
|
151
155
|
// Snapshot the builder's talent signals directly from the DB (auth.js is a
|
|
152
156
|
// kernel file — it uses pool, never a non-kernel domain module like metrics.js).
|
|
153
157
|
const b = await pool.query(
|
|
@@ -162,7 +166,10 @@ async function reportActivityRollup({ builderId, githubId = null }) {
|
|
|
162
166
|
const recentShips = await recentShipsForRollup(builderId);
|
|
163
167
|
await fetch(`${idp.origin}/api/bongos/sso/activity/rollup`, {
|
|
164
168
|
method: 'POST',
|
|
165
|
-
headers: {
|
|
169
|
+
headers: {
|
|
170
|
+
'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent(),
|
|
171
|
+
'Bongos-Report-As-Of': asOf,
|
|
172
|
+
},
|
|
166
173
|
body: JSON.stringify({
|
|
167
174
|
client_id: idp.clientId,
|
|
168
175
|
client_secret: idp.clientSecret,
|
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.20.
|
|
78
|
+
const CORE_VERSION = '1.20.20'; // 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,172 @@
|
|
|
1
|
+
// tests/activity_rollup_as_of.mjs — task 1004268, the two ends of the wire.
|
|
2
|
+
//
|
|
3
|
+
// A rollup report now says WHEN the instance read its totals, so the hub can keep
|
|
4
|
+
// the newest one when a sign-in push and a ship push race (the ordering itself is
|
|
5
|
+
// proven against real SQL in tests/activity_rollup_order_db.mjs). This file holds
|
|
6
|
+
// the two halves a fake can settle:
|
|
7
|
+
//
|
|
8
|
+
// 1. THE SENDER stamps every report with a `Bongos-Report-As-Of` header, taken
|
|
9
|
+
// BEFORE it reads the totals, and never puts the stamp in the body — the hub
|
|
10
|
+
// validates the body strictly, so a body field from a newer instance would
|
|
11
|
+
// 400 on an older hub and lose the report.
|
|
12
|
+
// 2. THE HUB ROUTE binds that header as the upsert's stamp, and reads a missing
|
|
13
|
+
// or unparseable one as "unstamped" (last-writer-wins, the old behaviour)
|
|
14
|
+
// rather than refusing — the sender never reads the response, so a 400 would
|
|
15
|
+
// silently drop the report, not just its ordering.
|
|
16
|
+
//
|
|
17
|
+
// DB-free: the core half pins require.cache and stubs fetch (the
|
|
18
|
+
// tests/hub_recent_ships.mjs idiom); the hub half drives the real route handler
|
|
19
|
+
// over a recording fake pool.
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/activity_rollup_as_of.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
|
|
28
|
+
let passed = 0, failed = 0;
|
|
29
|
+
async function t(name, fn) {
|
|
30
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
31
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mockPool(answer = () => ({ rows: [] })) {
|
|
35
|
+
const calls = [];
|
|
36
|
+
const query = async (text, params = []) => {
|
|
37
|
+
calls.push({ text: String(text), params, at: Date.now() });
|
|
38
|
+
return answer(String(text), params) || { rows: [] };
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
calls,
|
|
42
|
+
query,
|
|
43
|
+
connect: async () => ({ query, release: () => {} }),
|
|
44
|
+
find: (re) => calls.find((c) => re.test(c.text)),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- 1. the sender ----------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
console.log('\n1. the instance stamps each report, in a header:');
|
|
51
|
+
|
|
52
|
+
const pin = (rel, exports) => {
|
|
53
|
+
const p = require.resolve(rel);
|
|
54
|
+
require.cache[p] = { id: p, filename: p, loaded: true, exports };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
await t('reportActivityRollup sends Bongos-Report-As-Of, stamped before the totals were read', async () => {
|
|
58
|
+
const senderPool = mockPool((sql) => {
|
|
59
|
+
if (/FROM builders WHERE id/.test(sql)) {
|
|
60
|
+
return { rows: [{ github_id: 42, total_credits: 7, karma: 1, rank: 'metic', preferred_disciplines: ['engineer'] }] };
|
|
61
|
+
}
|
|
62
|
+
if (/count\(\*\)/.test(sql)) return { rows: [{ n: 3 }] };
|
|
63
|
+
return { rows: [] };
|
|
64
|
+
});
|
|
65
|
+
pin('../src/bongos/pool.js', { pool: senderPool });
|
|
66
|
+
pin('../src/bongos/auth-config.js', {
|
|
67
|
+
loadIdpConfig: () => ({ origin: 'https://hub.example', clientId: 'c1', clientSecret: 's' }),
|
|
68
|
+
});
|
|
69
|
+
delete require.cache[require.resolve('../src/bongos/auth-admission.js')];
|
|
70
|
+
const { reportActivityRollup } = require('../src/bongos/auth-admission.js');
|
|
71
|
+
|
|
72
|
+
const sent = [];
|
|
73
|
+
const realFetch = globalThis.fetch;
|
|
74
|
+
globalThis.fetch = async (url, opts) => { sent.push({ url, opts }); return { ok: true, json: async () => ({ ok: true }) }; };
|
|
75
|
+
const before = Date.now();
|
|
76
|
+
try {
|
|
77
|
+
await reportActivityRollup({ builderId: 7 });
|
|
78
|
+
} finally {
|
|
79
|
+
globalThis.fetch = realFetch;
|
|
80
|
+
}
|
|
81
|
+
assert.equal(sent.length, 1, 'exactly one rollup was sent');
|
|
82
|
+
assert.match(sent[0].url, /\/sso\/activity\/rollup$/);
|
|
83
|
+
const stamp = sent[0].opts.headers['Bongos-Report-As-Of'];
|
|
84
|
+
assert.equal(typeof stamp, 'string', 'the report carries its as-of header');
|
|
85
|
+
const ms = Date.parse(stamp);
|
|
86
|
+
assert.ok(Number.isFinite(ms) && new Date(ms).toISOString() === stamp, `an ISO timestamp, got ${stamp}`);
|
|
87
|
+
assert.ok(ms >= before && ms <= senderPool.calls[0].at,
|
|
88
|
+
'stamped BEFORE the first read — a stamp taken after could order a report ahead of totals read later');
|
|
89
|
+
const body = JSON.parse(sent[0].opts.body);
|
|
90
|
+
assert.equal(body.credits, 7, 'CONTROL: the body still carries the totals');
|
|
91
|
+
assert.ok(!Object.keys(body).some((k) => /as_?of/i.test(k)),
|
|
92
|
+
'the stamp must never ride the body: an older hub validates it strictly and would 400 the whole report');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---- 2. the hub route --------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
console.log('\n2. the hub binds the stamp, and an unusable one degrades to unstamped:');
|
|
98
|
+
|
|
99
|
+
const SECRET = 'as-of-secret';
|
|
100
|
+
function hubRoute() {
|
|
101
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
102
|
+
const rollupPool = mockPool((sql) => {
|
|
103
|
+
if (/FROM platform_identity_sso_clients/.test(sql)) {
|
|
104
|
+
return { rows: [{ client_id: 'c1', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: 'https://p.example' }] };
|
|
105
|
+
}
|
|
106
|
+
if (/FROM platform_identity_project_memberships/.test(sql)) {
|
|
107
|
+
return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member', signin_verified_at: '2026-08-29T00:00:00.000Z' }] };
|
|
108
|
+
}
|
|
109
|
+
return { rows: [{ id: 1 }] };
|
|
110
|
+
});
|
|
111
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
112
|
+
const realApi = require('../src/module-api.js');
|
|
113
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
114
|
+
require.cache[moduleApiPath] = {
|
|
115
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
116
|
+
exports: {
|
|
117
|
+
pool: rollupPool,
|
|
118
|
+
validateOrRespond: helpers.validateOrRespond,
|
|
119
|
+
resolveSession: async () => null,
|
|
120
|
+
logger: () => ({ info() {}, warn() {}, error() {} }),
|
|
121
|
+
LIVE_RANK_LADDER: realApi.LIVE_RANK_LADDER,
|
|
122
|
+
withTx: realApi.withTx,
|
|
123
|
+
corsPublicGet: () => (_q, _s, next) => next(),
|
|
124
|
+
on: () => () => {},
|
|
125
|
+
hasProvider: () => false,
|
|
126
|
+
registerProvider: () => {},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
delete require.cache[require.resolve('../modules/platform-identity/routes/sso.js')];
|
|
130
|
+
const router = require('../modules/platform-identity/routes/sso.js')();
|
|
131
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/sso/activity/rollup');
|
|
132
|
+
return { handle: layer.route.stack[0].handle, rollupPool };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function postRollup(headers) {
|
|
136
|
+
const { handle, rollupPool } = hubRoute();
|
|
137
|
+
const res = { body: null, failure: null };
|
|
138
|
+
res.json = (b) => { res.body = b; return res; };
|
|
139
|
+
res.fail = (code, ...rest) => { res.failure = { code, rest }; return res; };
|
|
140
|
+
const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
141
|
+
const req = {
|
|
142
|
+
get: (name) => lower[name.toLowerCase()],
|
|
143
|
+
body: { client_id: 'c1', client_secret: SECRET, github_id: 42, credits: 10, tasks_shipped: 2, karma: 1 },
|
|
144
|
+
};
|
|
145
|
+
await handle(req, res, () => {});
|
|
146
|
+
const insert = rollupPool.find(/INSERT INTO platform_identity_builder_project_activity/);
|
|
147
|
+
return { res, stamp: insert ? insert.params[8] : undefined };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await t('a stamped report binds its as-of as the upsert stamp', async () => {
|
|
151
|
+
const { res, stamp } = await postRollup({ 'Bongos-Report-As-Of': '2026-09-30T12:00:10.123Z' });
|
|
152
|
+
assert.equal(res.failure, null, JSON.stringify(res.failure));
|
|
153
|
+
assert.deepEqual(res.body, { ok: true });
|
|
154
|
+
assert.equal(stamp, '2026-09-30T12:00:10.123Z');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await t('no header (an older instance) is unstamped — the report still applies', async () => {
|
|
158
|
+
const { res, stamp } = await postRollup({});
|
|
159
|
+
assert.deepEqual(res.body, { ok: true });
|
|
160
|
+
assert.equal(stamp, null);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await t('an unparseable or oversized header is unstamped, never a 400 that would drop the report', async () => {
|
|
164
|
+
for (const bad of ['yesterday-ish', '', 'x'.repeat(200)]) {
|
|
165
|
+
const { res, stamp } = await postRollup({ 'Bongos-Report-As-Of': bad });
|
|
166
|
+
assert.equal(res.failure, null, `a bad stamp (${bad.slice(0, 20)}) refused the whole report`);
|
|
167
|
+
assert.equal(stamp, null, `a bad stamp (${bad.slice(0, 20)}) was bound`);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
172
|
+
if (failed) process.exit(1);
|