@bongos/core 1.20.18 → 1.20.19
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 +42 -22
- 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 +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/platform-identity/migrations/platform_identity_026_activity_reported_as_of.sql +27 -0
- package/modules/platform-identity/platform-identity.js +11 -6
- 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 +6 -0
- package/scripts/gds/run-unit-tests.js +4 -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/profile_rollup_consent.mjs +2 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.20.
|
|
6
|
-
"core_contract": "1.20.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.20.19",
|
|
6
|
+
"core_contract": "1.20.19",
|
|
7
|
+
"source_commit": "634240626e5878bfa71f84a126f2a5066315c631",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-30T14:16:31.957Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 557,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2631,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3214,
|
|
20
|
+
"tree_sha256": "1941a2dc44fb870a71c3227166d032c854f48237c2fef4cc958e355f9658f585",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2229,10 +2229,15 @@
|
|
|
2229
2229
|
"mode": "0000644",
|
|
2230
2230
|
"sha256": "4ca4eae961c994cae0dd9d306ea0a5a6f9ce660de161a62c5ccd125d910f70ec"
|
|
2231
2231
|
},
|
|
2232
|
+
{
|
|
2233
|
+
"path": "docs/adr/0352-a-rollup-report-carries-its-as-of-in-a-header.md",
|
|
2234
|
+
"mode": "0000644",
|
|
2235
|
+
"sha256": "d74b4f6262d4f23fd67f73771bbe8187e9cb92fa321b83ffb33ef03cf96d1236"
|
|
2236
|
+
},
|
|
2232
2237
|
{
|
|
2233
2238
|
"path": "docs/adr/README.md",
|
|
2234
2239
|
"mode": "0000644",
|
|
2235
|
-
"sha256": "
|
|
2240
|
+
"sha256": "943ac0f8c9c0b2dc5ef2a05241b0bac329c5fa986b09425841e12e62f4f9b936"
|
|
2236
2241
|
},
|
|
2237
2242
|
{
|
|
2238
2243
|
"path": "docs/api-reference.md",
|
|
@@ -2242,7 +2247,7 @@
|
|
|
2242
2247
|
{
|
|
2243
2248
|
"path": "docs/api/openapi.json",
|
|
2244
2249
|
"mode": "0000644",
|
|
2245
|
-
"sha256": "
|
|
2250
|
+
"sha256": "796e47e332b7788981b5118aabfbacdc9b728e22796da301cedce44ac1ab8dc6"
|
|
2246
2251
|
},
|
|
2247
2252
|
{
|
|
2248
2253
|
"path": "docs/architecture.md",
|
|
@@ -2772,7 +2777,7 @@
|
|
|
2772
2777
|
{
|
|
2773
2778
|
"path": "docs/module-api-changelog.md",
|
|
2774
2779
|
"mode": "0000644",
|
|
2775
|
-
"sha256": "
|
|
2780
|
+
"sha256": "f7fe6c9e2a06b6dfb71087791b38b22ff4ce57f897320ade88e65a131fdb4160"
|
|
2776
2781
|
},
|
|
2777
2782
|
{
|
|
2778
2783
|
"path": "docs/modules-contract.md",
|
|
@@ -7009,6 +7014,11 @@
|
|
|
7009
7014
|
"mode": "0000644",
|
|
7010
7015
|
"sha256": "72be4f7e3aa3afa47dd78de7a57dcdf57b877675b44fbdf02f6607a8632e1787"
|
|
7011
7016
|
},
|
|
7017
|
+
{
|
|
7018
|
+
"path": "modules/platform-identity/migrations/platform_identity_026_activity_reported_as_of.sql",
|
|
7019
|
+
"mode": "0000644",
|
|
7020
|
+
"sha256": "9f8d582d626dec82b55b96272f9d6d7f6c1488ac28bd9a98ab673404afde0c8e"
|
|
7021
|
+
},
|
|
7012
7022
|
{
|
|
7013
7023
|
"path": "modules/platform-identity/module.json",
|
|
7014
7024
|
"mode": "0000644",
|
|
@@ -7022,7 +7032,7 @@
|
|
|
7022
7032
|
{
|
|
7023
7033
|
"path": "modules/platform-identity/platform-identity.js",
|
|
7024
7034
|
"mode": "0000644",
|
|
7025
|
-
"sha256": "
|
|
7035
|
+
"sha256": "a823bd5e79795f93aa011470088b5840bf2ada6fb696fbf6b9b1c6c2ea85e5ef"
|
|
7026
7036
|
},
|
|
7027
7037
|
{
|
|
7028
7038
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -7117,7 +7127,7 @@
|
|
|
7117
7127
|
{
|
|
7118
7128
|
"path": "modules/platform-identity/routes/sso.js",
|
|
7119
7129
|
"mode": "0000644",
|
|
7120
|
-
"sha256": "
|
|
7130
|
+
"sha256": "2906d873fa46a2745fbbecefdeb81194f6f433e97deed65ee5fbee3e9a7fa140"
|
|
7121
7131
|
},
|
|
7122
7132
|
{
|
|
7123
7133
|
"path": "modules/platform-identity/scouting-authz.js",
|
|
@@ -7142,7 +7152,7 @@
|
|
|
7142
7152
|
{
|
|
7143
7153
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
7144
7154
|
"mode": "0000644",
|
|
7145
|
-
"sha256": "
|
|
7155
|
+
"sha256": "93d74c10d601eab840da83b5d33d7bbf51907830626401cc27fadb138441fcc3"
|
|
7146
7156
|
},
|
|
7147
7157
|
{
|
|
7148
7158
|
"path": "modules/platform-identity/ui/.gitkeep",
|
|
@@ -8862,12 +8872,12 @@
|
|
|
8862
8872
|
{
|
|
8863
8873
|
"path": "package-lock.json",
|
|
8864
8874
|
"mode": "0000644",
|
|
8865
|
-
"sha256": "
|
|
8875
|
+
"sha256": "01b48ab609eb6e0b51a3420a79cd5e13158e3bf3f996a5bd30dbd1c2927c5b91"
|
|
8866
8876
|
},
|
|
8867
8877
|
{
|
|
8868
8878
|
"path": "package.json",
|
|
8869
8879
|
"mode": "0000644",
|
|
8870
|
-
"sha256": "
|
|
8880
|
+
"sha256": "15c8438b1ea0a683e536e21ca2ed353042533ddf893b727c3835d5422595797a"
|
|
8871
8881
|
},
|
|
8872
8882
|
{
|
|
8873
8883
|
"path": "public-docs/index.html",
|
|
@@ -8887,7 +8897,7 @@
|
|
|
8887
8897
|
{
|
|
8888
8898
|
"path": "release-notes.json",
|
|
8889
8899
|
"mode": "0000644",
|
|
8890
|
-
"sha256": "
|
|
8900
|
+
"sha256": "426ac20e29712617fe5881fdc428ff184ab09e2e0cfc093a99b1f10c192291a3"
|
|
8891
8901
|
},
|
|
8892
8902
|
{
|
|
8893
8903
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -10047,7 +10057,7 @@
|
|
|
10047
10057
|
{
|
|
10048
10058
|
"path": "scripts/gds/run-unit-tests.js",
|
|
10049
10059
|
"mode": "0000644",
|
|
10050
|
-
"sha256": "
|
|
10060
|
+
"sha256": "456b185fcbab4fb486fb15aecf0de260183c9eda9942c12c0123000a8baeaa57"
|
|
10051
10061
|
},
|
|
10052
10062
|
{
|
|
10053
10063
|
"path": "scripts/gds/runner-drift.js",
|
|
@@ -10667,7 +10677,7 @@
|
|
|
10667
10677
|
{
|
|
10668
10678
|
"path": "src/bongos/auth-admission.js",
|
|
10669
10679
|
"mode": "0000644",
|
|
10670
|
-
"sha256": "
|
|
10680
|
+
"sha256": "9fd9311df751a418985073db3aed97825e52d2a45ca7501b93f93cf5019c1fe6"
|
|
10671
10681
|
},
|
|
10672
10682
|
{
|
|
10673
10683
|
"path": "src/bongos/auth-config.js",
|
|
@@ -11002,7 +11012,7 @@
|
|
|
11002
11012
|
{
|
|
11003
11013
|
"path": "src/module-api.js",
|
|
11004
11014
|
"mode": "0000644",
|
|
11005
|
-
"sha256": "
|
|
11015
|
+
"sha256": "f80e044dd532aabc41f3731129d98f63c98c2d60a9a4fc3eecbf713e3a86650e"
|
|
11006
11016
|
},
|
|
11007
11017
|
{
|
|
11008
11018
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11104,10 +11114,20 @@
|
|
|
11104
11114
|
"mode": "0000644",
|
|
11105
11115
|
"sha256": "f7434d510d656e5081f735d1f9356ecb11e8c3630a86a86137c3290134541c8d"
|
|
11106
11116
|
},
|
|
11117
|
+
{
|
|
11118
|
+
"path": "tests/activity_rollup_as_of.mjs",
|
|
11119
|
+
"mode": "0000644",
|
|
11120
|
+
"sha256": "d5903404f0fed48e599b68f259e0d79a44d11c249bb69bc4b59643e7dac62cbb"
|
|
11121
|
+
},
|
|
11122
|
+
{
|
|
11123
|
+
"path": "tests/activity_rollup_order_db.mjs",
|
|
11124
|
+
"mode": "0000644",
|
|
11125
|
+
"sha256": "497b453aa441ba06276d5146fc71f54da111a12c7c0c2e192da199cc1f2812ec"
|
|
11126
|
+
},
|
|
11107
11127
|
{
|
|
11108
11128
|
"path": "tests/activity_snapshots_db.mjs",
|
|
11109
11129
|
"mode": "0000644",
|
|
11110
|
-
"sha256": "
|
|
11130
|
+
"sha256": "73cb5a43e3f3ce61dd7f57720e523a310390ed60ed491d39fdaf8b8e9c78053a"
|
|
11111
11131
|
},
|
|
11112
11132
|
{
|
|
11113
11133
|
"path": "tests/admission_ceiling_gate.mjs",
|
|
@@ -14347,7 +14367,7 @@
|
|
|
14347
14367
|
{
|
|
14348
14368
|
"path": "tests/profile_rollup_consent.mjs",
|
|
14349
14369
|
"mode": "0000644",
|
|
14350
|
-
"sha256": "
|
|
14370
|
+
"sha256": "093f078f965454e535d6026c1852a0e0668695809f270f84e5d26dba4025abbd"
|
|
14351
14371
|
},
|
|
14352
14372
|
{
|
|
14353
14373
|
"path": "tests/profile_route.mjs",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# ADR 0352 — A rollup report carries its "as of" in a header, and the hub keeps the newest
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-30
|
|
5
|
+
- **Task:** [task 1004268](https://cloudbongos.com/builders#/task/1004268) (goal 1000110), found in [task 1004244](https://cloudbongos.com/builders#/task/1004244)'s code review
|
|
6
|
+
- **Interacts with:** [ADR 0141](<redacted>.md) §4 (the activity rollup), [ADR 0171](0171-profile-rollup-join-and-consent.md) D7 (a report is third-party self-report), [ADR 0083](0083-modular-architecture-strangler-build-plan.md) §5 (module-namespaced migrations)
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
`POST /sso/activity/rollup` upserted the live activity row last-writer-wins, and a report carried no timestamp. An instance pushes one on every federated sign-in and one on every ship, so a sign-in report read earlier and a ship report read later can reach the hub together, and the older one can commit last. The live row and, since task 1004244, the newest snapshot then show the older totals. The leaderboard reads a regression that never happened until the builder's next report. It is low-urgency, since the next report corrects it, but it is wrong in exactly the place people compare numbers.
|
|
11
|
+
|
|
12
|
+
The fix the task names is for the instance to send when it read the totals, and for the hub to refuse an older report. What is not obvious is where that stamp travels.
|
|
13
|
+
|
|
14
|
+
## Decision
|
|
15
|
+
|
|
16
|
+
**D1 — The stamp is a request header, `Bongos-Report-As-Of`, not a body field.** The hub validates the rollup body strictly (`validateOrRespond`), so an unknown field is a 400. The hub and its instances upgrade independently, and an instance can run a newer core than the hub (today the hub's pin trails main). A body field sent by an upgraded instance would be refused by a hub that predates it. The sender never reads the response, so every report from that instance would be lost silently until the hub caught up. An unknown header is ignored, so the header is safe in both directions: an old hub ignores it, and a new hub treats its absence as "unstamped".
|
|
17
|
+
|
|
18
|
+
**D2 — Unstamped means the old behaviour, never a refusal.** A missing or unparseable header binds NULL. A NULL on either side of the comparison (an older instance's report, or a row written before migration 026) applies last-writer-wins, as before. An unstamped report also clears the stored stamp, so a stamp never outlives the numbers it described. A malformed header is not a 400, for D1's reason: a refusal drops the report, when the only thing lost should be its ordering.
|
|
19
|
+
|
|
20
|
+
**D3 — The guard is a WHERE on the upsert's conflict branch.** `ON CONFLICT (client_id, github_id) DO UPDATE … WHERE EXCLUDED.reported_as_of IS NULL OR <row>.reported_as_of IS NULL OR EXCLUDED.reported_as_of >= <row>.reported_as_of`. A skipped update still takes the row lock, so racing reports stay queued exactly as task 1004244 arranged, and the snapshot beside it copies an unchanged live row, which records nothing. Ties apply (the later arrival wins, as before).
|
|
21
|
+
|
|
22
|
+
**D4 — The stamp is the instance's clock, compared only with itself, and clamped to the hub's.** A row's stored stamp only ever comes from the same client for the same builder, so skew between an instance and the hub does not matter. A stamp later than the hub's `now()` is stored as `now()`, so an instance whose clock runs ahead cannot freeze the row until its clock is reached. The sender stamps before its first read, so a report is never ordered ahead of totals read after it.
|
|
23
|
+
|
|
24
|
+
## Consequences
|
|
25
|
+
|
|
26
|
+
- Migration `platform_identity_026_activity_reported_as_of.sql` adds one nullable column, with no backfill and no default. An invented stamp on an existing row could refuse an honest report from an instance whose clock reads earlier than the hub's.
|
|
27
|
+
- A mixed fleet behaves as before until each instance upgrades. An upgraded instance talking to an old hub is also unaffected.
|
|
28
|
+
- **Honest limit:** an instance clock that steps backwards (an NTP correction after running fast) has its reports refused until it passes the last stamp it sent. The skew bounds this, the clamp bounds the skew by the hub's own time, and the next report after that applies. A report refused this way still answers `{ ok: true }`, because the instance ignores the body.
|
|
29
|
+
- **The pattern generalises:** a field added to a hub↔instance wire that the older side validates strictly must be able to ride where the older side ignores it, or the change must land on the hub first and be released before any instance sends it.
|
|
30
|
+
|
|
31
|
+
Proof: `tests/activity_rollup_order_db.mjs` (real Postgres: the out-of-order race, ties, NULLs on each side, the future clamp, and ten rounds of five racing writers ending on the latest stamp with the snapshot agreeing) and `tests/activity_rollup_as_of.mjs` (DB-free: the sender stamps a header before its reads and never the body; the route binds it and degrades a bad one to unstamped).
|
|
32
|
+
|
|
33
|
+
## Alternatives considered
|
|
34
|
+
|
|
35
|
+
- **A body field `as_of`.** This is what the task sketched. It is lost against any older hub (D1).
|
|
36
|
+
- **`GREATEST` on each total.** Credits and karma can legitimately fall, so this would pin a clawback forever.
|
|
37
|
+
- **The standard `Date` header.** It has one-second granularity, and a sign-in push and a ship push inside the same second are exactly the case being fixed.
|
|
38
|
+
- **A 400 on a malformed stamp.** It turns a sender bug into silent data loss (D2).
|
package/docs/adr/README.md
CHANGED
|
@@ -470,3 +470,4 @@ These 20 numbers are each shared by exactly two files. They are **accepted histo
|
|
|
470
470
|
| 0349 | [**A version preview is a sandboxed child the web tier launches, reached by a cookie** ([task 1004300](https://cloudbongos.com/builders#/task/1004300), goal 1000090 — BONGOS-V2, owner Lars, approved 2026-09-29). Amends ADR 0293 D1 for this feature only. **D1 (owner):** route by a `bongos_preview` cookie through an optional `request.divert` seam, not a path; forward only with the cookie, off the sign-in and preview paths, with no Bearer, and with `core.pin.move` re-checked on every request. **D2 (owner):** the npm-release module launches the preview itself, with a deny-by-default environment, a dump-and-restore copy of the database (never a template copy), one at a time, 20-minute idle stop, published versions within 30 of the running one. **D3:** the same database role and no egress firewall are accepted for now.](0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md) | modules / npm-release / deploy |
|
|
471
471
|
| 0350 | [**The hub holds a per-project WRITE deploy key, so a hosted project's upgraded pin always reaches its GitHub repo** ([task 1004291](https://cloudbongos.com/builders#/task/1004291), follow-up to task 1004065). Amends ADR 0176. The owner's token lasts one hour and the checkout's own key is read-only, so most hosted upgrades ended "GitHub was not updated". **D1:** a second deploy key per repo, registered `read_only: false` as `cloudbongos-pin-<slug>`, the pull key untouched. **D2:** kept outside every checkout in the runner's own directory (0700 dir, 0600 key, modes verified), unreadable by the project's account. **D3:** minted at standup and whenever a fresh owner token finds none, so reconnecting GitHub once is enough. **D4:** `pushUpgradePin` tries write key, then owner token, then origin, never forced. **D5:** revoked on teardown and disconnect. **D6:** a project on the shared app user is refused a key: "needs its own account first". **D7:** /deploy says reconnecting sets up lasting access.](0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md) | provisioning / security |
|
|
472
472
|
| 0351 | [**A criterion closes on a UAT, not on its linked tasks shipping** ([task 1004392](https://cloudbongos.com/builders#/task/1004392), goal 1000089 — working area 3). Supersedes ADR 0183's close rule. `wa7-government` had closed on six Board Room navigation tasks while nothing it describes was on the live site. **D1:** three checks — Code (linked tasks done, ≥1 shipped; automatic), Live, UAT — and the sweep closes a criterion only with a CURRENT sign-off (no older than the newest linked ship). **D2 (owner):** "Awaiting UAT" is a visible state. **D3 (owner):** one step; the signer is refused if they shipped linked work, the project owner excepted. **D4 (owner):** an mp4/webm up to 100 MB on the project's own server. **D5 (owner):** backend-only is set while a criterion is open and takes a recording-free sign-off. **D6 (owner):** Live is read via the `deploy.taskWhere` port where the site can tell, attested otherwise. **D7 (owner):** already-closed criteria stay closed ("Closed before UAT"). **D8:** `/satisfy` is a recorded override that needs a reason. **D9:** `/goal-uat` + `scripts/gds/uat.js`; the hall is task 1004402.](0351-a-criterion-closes-on-a-uat.md) | lifecycle / criteria / review |
|
|
473
|
+
| 0352 | [**A rollup report carries its "as of" in a header, and the hub keeps the newest** ([task 1004268](https://cloudbongos.com/builders#/task/1004268), goal 1000110). `POST /sso/activity/rollup` was last-writer-wins with no report timestamp, so a sign-in push read earlier and a ship push read later could land the older totals last. **D1:** the instance's read time rides a `Bongos-Report-As-Of` HEADER, not a body field, because the hub validates the body strictly and hub and instances upgrade independently: a body field from a newer instance would 400 on an older hub and lose the report, while an unknown header is ignored. **D2:** missing or unparseable = unstamped = last-writer-wins, never a 400. **D3:** the guard is a WHERE on the upsert's conflict branch (migration 026, `reported_as_of`, no backfill); a skipped update keeps the row lock, so the task 1004244 snapshot stays in step. **D4:** the stamp is compared only with the same client's, and clamped to the hub's `now()`.](0352-a-rollup-report-carries-its-as-of-in-a-header.md) | platform identity / federation |
|
package/docs/api/openapi.json
CHANGED
|
@@ -18502,7 +18502,7 @@
|
|
|
18502
18502
|
"sso"
|
|
18503
18503
|
],
|
|
18504
18504
|
"summary": "POST /sso/activity/rollup",
|
|
18505
|
-
"description": "POST /sso/activity/rollup — a federated instance reports a builder's activity snapshot (ADR 0141 §4). Server-to-server, client_id+client_secret auth. Keyed to the AUTHENTICATED client_id. Descriptive analytics for the Archon-only scouting view — never authority. rank: public — authenticated by client_id + client_secret, not a builder rank.\n\n**Rank:** `public` — No authentication — any caller.",
|
|
18505
|
+
"description": "POST /sso/activity/rollup — a federated instance reports a builder's activity snapshot (ADR 0141 §4). Server-to-server, client_id+client_secret auth. Keyed to the AUTHENTICATED client_id. Descriptive analytics for the Archon-only scouting view — never authority. Optional `Bongos-Report-As-Of` header (task 1004268): when the instance read these totals, so an older report landing last cannot overwrite a newer one. A HEADER, not a body field, because the body is validated strictly and the hub and its instances upgrade independently: a new instance's body field would 400 on an older hub and the report would be lost, while an unknown header is ignored. Absent or unparseable reads as unstamped (last-writer-wins, as before) rather than a 400 — the sender never reads the response, so a refusal would silently drop the report instead of only its ordering. rank: public — authenticated by client_id + client_secret, not a builder rank.\n\n**Rank:** `public` — No authentication — any caller.",
|
|
18506
18506
|
"x-rank": "public",
|
|
18507
18507
|
"x-source": "modules/platform-identity/routes/sso.js",
|
|
18508
18508
|
"requestBody": {
|
|
@@ -2693,5 +2693,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2693
2693
|
landed since 1.20.16 with no explicit bump. run 36722139698. (task 1002620)
|
|
2694
2694
|
1.20.18 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2695
2695
|
landed since 1.20.17 with no explicit bump. run 36724081934. (task 1002620)
|
|
2696
|
+
1.20.19 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2697
|
+
landed since 1.20.18 with no explicit bump. run 36727774141. (task 1002620)
|
|
2696
2698
|
---------------------------------------------------------------------------
|
|
2697
2699
|
```
|
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;
|
|
@@ -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 });
|
|
@@ -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.19",
|
|
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.19",
|
|
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.19",
|
|
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,11 @@
|
|
|
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
|
+
}
|
|
8167
8173
|
]
|
|
8168
8174
|
}
|
|
@@ -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
|
|
@@ -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.19'; // 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);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// tests/activity_rollup_order_db.mjs — task 1004268 (real DB).
|
|
2
|
+
//
|
|
3
|
+
// A rollup report computed EARLIER never overwrites one computed LATER. The
|
|
4
|
+
// instance stamps each report with the moment it read the totals (the
|
|
5
|
+
// Bongos-Report-As-Of header, carried here as upsertActivity's `asOf`), and the
|
|
6
|
+
// live row's ON CONFLICT DO UPDATE only fires when the incoming stamp is at least
|
|
7
|
+
// the stored one. Before this, a sign-in report and a ship report racing to the
|
|
8
|
+
// hub were last-writer-wins, so the older totals could commit last and the
|
|
9
|
+
// leaderboard would read a regression that never happened.
|
|
10
|
+
//
|
|
11
|
+
// Only a real Postgres can settle this: the guard is a WHERE on the conflict
|
|
12
|
+
// branch, and what it does with NULLs (a report from an instance too old to stamp,
|
|
13
|
+
// a row written before the column existed) and with a skipped update (does the
|
|
14
|
+
// snapshot beside it still agree with the live row?) is planner behaviour, not
|
|
15
|
+
// statement shape.
|
|
16
|
+
//
|
|
17
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
18
|
+
// INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
|
|
19
|
+
// never runs it.
|
|
20
|
+
//
|
|
21
|
+
// Run: DATABASE_URL=... node tests/activity_rollup_order_db.mjs
|
|
22
|
+
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { dirname, join } from 'node:path';
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const { Pool } = require('pg');
|
|
30
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
31
|
+
|
|
32
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
34
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
35
|
+
|
|
36
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
37
|
+
try {
|
|
38
|
+
await pool.query('SELECT 1');
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.log(`activity_rollup_order_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
41
|
+
await pool.end().catch(() => {});
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// High-numbered fixture ids so a shared DB can never collide with real data.
|
|
46
|
+
const ORDER = 90042681, LEGACY = 90042682, FUTURE = 90042683, RACE = 90042684;
|
|
47
|
+
const IDS = [ORDER, LEGACY, FUTURE, RACE];
|
|
48
|
+
const CLIENT = 't1004268-client';
|
|
49
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
50
|
+
const report = (githubId, credits, asOf) =>
|
|
51
|
+
pi.upsertActivity({ clientId: CLIENT, githubId, credits, tasksShipped: 1, karma: 0, asOf }, { pool });
|
|
52
|
+
const at = (sec) => new Date(Date.UTC(2026, 8, 30, 12, 0, sec)).toISOString();
|
|
53
|
+
|
|
54
|
+
async function cleanup() {
|
|
55
|
+
await q('DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])', [IDS]).catch(() => {});
|
|
56
|
+
await q('DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])', [IDS]).catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
async function live(githubId) {
|
|
59
|
+
const { rows } = await q(
|
|
60
|
+
`SELECT credits, reported_as_of FROM platform_identity_builder_project_activity
|
|
61
|
+
WHERE client_id = $1 AND github_id = $2`, [CLIENT, githubId]);
|
|
62
|
+
return rows[0];
|
|
63
|
+
}
|
|
64
|
+
async function newestSnapshot(githubId) {
|
|
65
|
+
const { rows } = await q(
|
|
66
|
+
`SELECT credits FROM platform_identity_activity_snapshots
|
|
67
|
+
WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at DESC, id DESC LIMIT 1`, [CLIENT, githubId]);
|
|
68
|
+
return rows[0] ? rows[0].credits : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let pass = 0;
|
|
72
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
73
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
for (const f of ['platform_identity_005_builder_activity.sql', 'platform_identity_012_recent_ships.sql',
|
|
77
|
+
'platform_identity_022_activity_snapshots.sql', 'platform_identity_026_activity_reported_as_of.sql']) {
|
|
78
|
+
await q(migrationSql(f));
|
|
79
|
+
}
|
|
80
|
+
await q(migrationSql('platform_identity_026_activity_reported_as_of.sql')); // idempotent: replays cleanly
|
|
81
|
+
await cleanup();
|
|
82
|
+
|
|
83
|
+
// 1 — the race the task describes, landed in the bad order.
|
|
84
|
+
await report(ORDER, 200, at(20)); // the ship report, computed later, lands first
|
|
85
|
+
await report(ORDER, 100, at(10)); // the sign-in report, computed earlier, lands last
|
|
86
|
+
same((await live(ORDER)).credits, 200, 'an older report landing last does not overwrite the newer totals');
|
|
87
|
+
same(await newestSnapshot(ORDER), 200, 'and the snapshot history does not record the regression either');
|
|
88
|
+
await report(ORDER, 300, at(30));
|
|
89
|
+
same((await live(ORDER)).credits, 300, 'a genuinely newer report still applies');
|
|
90
|
+
await report(ORDER, 250, at(30));
|
|
91
|
+
same((await live(ORDER)).credits, 250, 'an equal stamp applies (ties go to the later arrival, as before)');
|
|
92
|
+
|
|
93
|
+
// 2 — mixed versions. An instance too old to stamp sends no as_of: last-writer-wins,
|
|
94
|
+
// exactly as before, and it clears the guard rather than leaving a stamp its numbers
|
|
95
|
+
// no longer match.
|
|
96
|
+
await report(LEGACY, 10, null);
|
|
97
|
+
same((await live(LEGACY)).reported_as_of, null, 'an unstamped report stores no stamp');
|
|
98
|
+
await report(LEGACY, 20, at(10));
|
|
99
|
+
same((await live(LEGACY)).credits, 20, 'a stamped report over an unstamped row applies');
|
|
100
|
+
await report(LEGACY, 5, null);
|
|
101
|
+
same([(await live(LEGACY)).credits, (await live(LEGACY)).reported_as_of], [5, null],
|
|
102
|
+
'an unstamped report always applies and clears the stamp (the old behaviour, for old instances)');
|
|
103
|
+
await report(LEGACY, 30, at(1));
|
|
104
|
+
same((await live(LEGACY)).credits, 30, 'after it, the next stamped report applies whatever its stamp');
|
|
105
|
+
|
|
106
|
+
// 3 — a sender whose clock runs ahead cannot freeze the row: a stamp past the hub's
|
|
107
|
+
// own now() is stored as now(), so the next honest report is not refused until then.
|
|
108
|
+
await report(FUTURE, 1, new Date(Date.now() + 365 * 86400e3).toISOString());
|
|
109
|
+
const stored = new Date((await live(FUTURE)).reported_as_of).getTime();
|
|
110
|
+
ok(stored <= Date.now() + 1000, 'a future stamp is clamped to the hub clock');
|
|
111
|
+
await report(FUTURE, 2, new Date(Date.now() + 2000).toISOString());
|
|
112
|
+
same((await live(FUTURE)).credits, 2, 'so the next report from a correct clock still applies');
|
|
113
|
+
|
|
114
|
+
// 4 — racing writers: the row lock queues them, and whatever the commit order the
|
|
115
|
+
// row ends on the report with the LATEST stamp, and the newest snapshot agrees.
|
|
116
|
+
for (let round = 0; round < 10; round++) {
|
|
117
|
+
await q('DELETE FROM platform_identity_activity_snapshots WHERE github_id = $1', [RACE]);
|
|
118
|
+
await q('DELETE FROM platform_identity_builder_project_activity WHERE github_id = $1', [RACE]);
|
|
119
|
+
const stamps = [1, 2, 3, 4, 5].sort(() => Math.random() - 0.5);
|
|
120
|
+
await Promise.all(stamps.map((s) => report(RACE, s * 100, at(s))));
|
|
121
|
+
const row = await live(RACE);
|
|
122
|
+
assert.equal(row.credits, 500, `round ${round}: the row ended on ${row.credits}, not the latest-stamped 500`);
|
|
123
|
+
assert.equal(await newestSnapshot(RACE), 500, `round ${round}: the newest snapshot disagrees with the live row`);
|
|
124
|
+
}
|
|
125
|
+
pass++; console.log(' ok - 10 rounds of five racing reports in random order: the latest stamp always wins');
|
|
126
|
+
|
|
127
|
+
console.log(`\nactivity_rollup_order_db.mjs: ${pass} passed`);
|
|
128
|
+
} finally {
|
|
129
|
+
await cleanup();
|
|
130
|
+
await pool.end().catch(() => {});
|
|
131
|
+
}
|
|
@@ -97,6 +97,8 @@ try {
|
|
|
97
97
|
// column); without it here, the upsertActivity steps fail 42703 on a fresh database.
|
|
98
98
|
await q(migrationSql('platform_identity_012_recent_ships.sql'));
|
|
99
99
|
await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
|
|
100
|
+
// upsertActivity also writes reported_as_of (task 1004268) — same reason as 012 above.
|
|
101
|
+
await q(migrationSql('platform_identity_026_activity_reported_as_of.sql'));
|
|
100
102
|
await cleanup();
|
|
101
103
|
|
|
102
104
|
// 1 — the writer snapshots the LIVE row, and only when it changed.
|
|
@@ -100,6 +100,8 @@ try {
|
|
|
100
100
|
// before the fixtures, so its one-shot grandfather never sees LEGACY below.
|
|
101
101
|
await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
|
|
102
102
|
await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
|
|
103
|
+
// upsertActivity also writes reported_as_of (026, task 1004268): 42703 without it.
|
|
104
|
+
await q(migrationSql('platform_identity_026_activity_reported_as_of.sql'));
|
|
103
105
|
await cleanup();
|
|
104
106
|
|
|
105
107
|
// CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state='active' AND
|