@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.
@@ -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
- async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], clientOrigin = null }, { pool = defaultPool } = {}) {
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 and
7
- // size. 404 for an unknown slug AND for an unlisted guild, to everyone, its own
8
- // members included: the read takes no viewer, so it cannot vary by one (the
9
- // private-profile rule, ADR 0171 D4). Members reach an unlisted guild through
10
- // the own-scope GET /guilds/:slug/members. The page at /g/<slug> is task
11
- // 1002301's; this is its read.
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. no-store: a hide or a visibility flip must beat
42
- // every cache (ADR 0171 D5).
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
- assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer'], '[]']);
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.18",
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.18",
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.18",
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",
@@ -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: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
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.18'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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);