@bongos/core 1.19.427 → 1.19.428
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 +47 -27
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +71 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +22 -14
- package/docs/copy-registry.json +93 -21
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/platform-identity/account-visibility.js +31 -12
- package/modules/platform-identity/connections.js +4 -3
- package/modules/platform-identity/migrations/platform_identity_019_terms_acceptance.sql +47 -0
- package/modules/platform-identity/platform-identity.js +12 -10
- package/modules/platform-identity/routes/connections.js +4 -3
- package/modules/platform-identity/terms-acceptance.js +97 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/terms.html +8 -0
- package/src/module-api.js +1 -1
- package/tests/connections_api.mjs +13 -8
- package/tests/connections_ownership.mjs +6 -1
- package/tests/effective_visibility_predicate.mjs +89 -14
- package/tests/profile_connections_ui.mjs +22 -7
- package/tests/profile_privacy_owner_view.mjs +31 -4
- package/tests/profile_route.mjs +15 -8
- package/tests/terms_acceptance.mjs +573 -0
package/docs/copy-registry.json
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
"generator": "scripts/gds/copy-inventory.js",
|
|
4
4
|
"goal": 1000074,
|
|
5
5
|
"counts": {
|
|
6
|
-
"strings":
|
|
7
|
-
"unique_ids":
|
|
8
|
-
"certain":
|
|
6
|
+
"strings": 1588,
|
|
7
|
+
"unique_ids": 1382,
|
|
8
|
+
"certain": 1588,
|
|
9
9
|
"heuristic": 0,
|
|
10
10
|
"duplicate_ids": 157
|
|
11
11
|
},
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"landing": {
|
|
24
24
|
"at": "public-landing",
|
|
25
25
|
"files": 5,
|
|
26
|
-
"strings":
|
|
26
|
+
"strings": 326
|
|
27
27
|
}
|
|
28
28
|
},
|
|
29
29
|
"blind_spots": [
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"public-landing/index.html:900",
|
|
93
93
|
"public-landing/privacy.html:80",
|
|
94
94
|
"public-landing/projects.html:1115",
|
|
95
|
-
"public-landing/terms.html:
|
|
95
|
+
"public-landing/terms.html:84"
|
|
96
96
|
]
|
|
97
97
|
},
|
|
98
98
|
{
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"public-landing/index.html:900",
|
|
106
106
|
"public-landing/privacy.html:80",
|
|
107
107
|
"public-landing/projects.html:1115",
|
|
108
|
-
"public-landing/terms.html:
|
|
108
|
+
"public-landing/terms.html:84"
|
|
109
109
|
]
|
|
110
110
|
},
|
|
111
111
|
{
|
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
"public-landing/contact.html:71",
|
|
143
143
|
"public-landing/index.html:900",
|
|
144
144
|
"public-landing/privacy.html:80",
|
|
145
|
-
"public-landing/terms.html:
|
|
145
|
+
"public-landing/terms.html:84"
|
|
146
146
|
]
|
|
147
147
|
},
|
|
148
148
|
{
|
|
@@ -1104,7 +1104,7 @@
|
|
|
1104
1104
|
"occurrences": 2,
|
|
1105
1105
|
"at": [
|
|
1106
1106
|
"public-landing/privacy.html:75",
|
|
1107
|
-
"public-landing/terms.html:
|
|
1107
|
+
"public-landing/terms.html:79"
|
|
1108
1108
|
]
|
|
1109
1109
|
},
|
|
1110
1110
|
{
|
|
@@ -1414,7 +1414,7 @@
|
|
|
1414
1414
|
"occurrences": 2,
|
|
1415
1415
|
"at": [
|
|
1416
1416
|
"public-landing/privacy.html:72",
|
|
1417
|
-
"public-landing/terms.html:
|
|
1417
|
+
"public-landing/terms.html:80"
|
|
1418
1418
|
]
|
|
1419
1419
|
},
|
|
1420
1420
|
{
|
|
@@ -1484,7 +1484,7 @@
|
|
|
1484
1484
|
"occurrences": 2,
|
|
1485
1485
|
"at": [
|
|
1486
1486
|
"public-landing/contact.html:59",
|
|
1487
|
-
"public-landing/terms.html:
|
|
1487
|
+
"public-landing/terms.html:80"
|
|
1488
1488
|
]
|
|
1489
1489
|
},
|
|
1490
1490
|
{
|
|
@@ -12257,7 +12257,7 @@
|
|
|
12257
12257
|
"surface": "landing",
|
|
12258
12258
|
"text": "A non-profit, capture-resistant commons.",
|
|
12259
12259
|
"file": "public-landing/terms.html",
|
|
12260
|
-
"line":
|
|
12260
|
+
"line": 84,
|
|
12261
12261
|
"origin": "html-text",
|
|
12262
12262
|
"confidence": "certain"
|
|
12263
12263
|
},
|
|
@@ -12518,7 +12518,7 @@
|
|
|
12518
12518
|
"surface": "landing",
|
|
12519
12519
|
"text": "Built by agents, on Cloud Bongos.",
|
|
12520
12520
|
"file": "public-landing/terms.html",
|
|
12521
|
-
"line":
|
|
12521
|
+
"line": 84,
|
|
12522
12522
|
"origin": "html-text",
|
|
12523
12523
|
"confidence": "certain"
|
|
12524
12524
|
},
|
|
@@ -12698,7 +12698,7 @@
|
|
|
12698
12698
|
"surface": "landing",
|
|
12699
12699
|
"text": "Don't use {{productName}} to break the law, to attack this site or other people's projects, to mine, spam or otherwise abuse hosted resources, or to harass other builders. The owner may deactivate a builder or take a project offline for breaking these, and may set further house rules inside the hall.",
|
|
12700
12700
|
"file": "public-landing/terms.html",
|
|
12701
|
-
"line":
|
|
12701
|
+
"line": 73,
|
|
12702
12702
|
"origin": "html-text",
|
|
12703
12703
|
"confidence": "certain"
|
|
12704
12704
|
},
|
|
@@ -12779,7 +12779,7 @@
|
|
|
12779
12779
|
"surface": "landing",
|
|
12780
12780
|
"text": "Fair use",
|
|
12781
12781
|
"file": "public-landing/terms.html",
|
|
12782
|
-
"line":
|
|
12782
|
+
"line": 72,
|
|
12783
12783
|
"origin": "html-text",
|
|
12784
12784
|
"confidence": "certain"
|
|
12785
12785
|
},
|
|
@@ -12882,6 +12882,15 @@
|
|
|
12882
12882
|
"origin": "html-text",
|
|
12883
12883
|
"confidence": "certain"
|
|
12884
12884
|
},
|
|
12885
|
+
{
|
|
12886
|
+
"id": "0808df6e434c",
|
|
12887
|
+
"surface": "landing",
|
|
12888
|
+
"text": "From your settings you can hide your statistics, drop out of the recruiting directory, or make your account private. Each takes effect immediately, everywhere, including on a page someone already has open. Hiding removes the platform’s combined view; it does not retract the per-project numbers each project publishes itself, so a determined reader could still add them up the hard way.",
|
|
12889
|
+
"file": "public-landing/terms.html",
|
|
12890
|
+
"line": 69,
|
|
12891
|
+
"origin": "html-text",
|
|
12892
|
+
"confidence": "certain"
|
|
12893
|
+
},
|
|
12885
12894
|
{
|
|
12886
12895
|
"id": "8bb1f00b1558",
|
|
12887
12896
|
"surface": "landing",
|
|
@@ -13170,6 +13179,15 @@
|
|
|
13170
13179
|
"origin": "html-text",
|
|
13171
13180
|
"confidence": "certain"
|
|
13172
13181
|
},
|
|
13182
|
+
{
|
|
13183
|
+
"id": "69400ca12477",
|
|
13184
|
+
"surface": "landing",
|
|
13185
|
+
"text": "Once you have signed in here and accepted these terms, your profile and that record are public by default: at your handle’s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in here stays provisional and is published nowhere.",
|
|
13186
|
+
"file": "public-landing/terms.html",
|
|
13187
|
+
"line": 68,
|
|
13188
|
+
"origin": "html-text",
|
|
13189
|
+
"confidence": "certain"
|
|
13190
|
+
},
|
|
13173
13191
|
{
|
|
13174
13192
|
"id": "8329bb1a3693",
|
|
13175
13193
|
"surface": "landing",
|
|
@@ -13238,7 +13256,7 @@
|
|
|
13238
13256
|
"surface": "landing",
|
|
13239
13257
|
"text": "Open source (AGPL-3.0)",
|
|
13240
13258
|
"file": "public-landing/terms.html",
|
|
13241
|
-
"line":
|
|
13259
|
+
"line": 84,
|
|
13242
13260
|
"origin": "html-text",
|
|
13243
13261
|
"confidence": "certain"
|
|
13244
13262
|
},
|
|
@@ -13382,7 +13400,7 @@
|
|
|
13382
13400
|
"surface": "landing",
|
|
13383
13401
|
"text": "Questions go through the",
|
|
13384
13402
|
"file": "public-landing/terms.html",
|
|
13385
|
-
"line":
|
|
13403
|
+
"line": 80,
|
|
13386
13404
|
"origin": "html-text",
|
|
13387
13405
|
"confidence": "certain"
|
|
13388
13406
|
},
|
|
@@ -13400,7 +13418,7 @@
|
|
|
13400
13418
|
"surface": "landing",
|
|
13401
13419
|
"text": "Read the source.",
|
|
13402
13420
|
"file": "public-landing/terms.html",
|
|
13403
|
-
"line":
|
|
13421
|
+
"line": 79,
|
|
13404
13422
|
"origin": "html-text",
|
|
13405
13423
|
"confidence": "certain"
|
|
13406
13424
|
},
|
|
@@ -13881,6 +13899,15 @@
|
|
|
13881
13899
|
"origin": "html-text",
|
|
13882
13900
|
"confidence": "certain"
|
|
13883
13901
|
},
|
|
13902
|
+
{
|
|
13903
|
+
"id": "198f1eac5a95",
|
|
13904
|
+
"surface": "landing",
|
|
13905
|
+
"text": "The platform stores the date you accepted these terms, and which version, on your account. It is written only by signing in here, and only once — signing in again does not change the original date. This is version",
|
|
13906
|
+
"file": "public-landing/terms.html",
|
|
13907
|
+
"line": 70,
|
|
13908
|
+
"origin": "html-text",
|
|
13909
|
+
"confidence": "certain"
|
|
13910
|
+
},
|
|
13884
13911
|
{
|
|
13885
13912
|
"id": "20fcebaa85c4",
|
|
13886
13913
|
"surface": "landing",
|
|
@@ -13944,12 +13971,21 @@
|
|
|
13944
13971
|
"origin": "html-text",
|
|
13945
13972
|
"confidence": "certain"
|
|
13946
13973
|
},
|
|
13974
|
+
{
|
|
13975
|
+
"id": "02e596cc4569",
|
|
13976
|
+
"surface": "landing",
|
|
13977
|
+
"text": "These numbers describe; they do not govern.",
|
|
13978
|
+
"file": "public-landing/terms.html",
|
|
13979
|
+
"line": 67,
|
|
13980
|
+
"origin": "html-text",
|
|
13981
|
+
"confidence": "certain"
|
|
13982
|
+
},
|
|
13947
13983
|
{
|
|
13948
13984
|
"id": "ea7de26ce2f0",
|
|
13949
13985
|
"surface": "landing",
|
|
13950
13986
|
"text": "These terms ship with the software and change with it. The version served on this page is the one that applies; the repository keeps the history.",
|
|
13951
13987
|
"file": "public-landing/terms.html",
|
|
13952
|
-
"line":
|
|
13988
|
+
"line": 79,
|
|
13953
13989
|
"origin": "html-text",
|
|
13954
13990
|
"confidence": "certain"
|
|
13955
13991
|
},
|
|
@@ -14003,7 +14039,7 @@
|
|
|
14003
14039
|
"surface": "landing",
|
|
14004
14040
|
"text": "To the extent the law allows, the people running {{productName}} are not liable for loss arising from its use. It is free software, offered without warranty, as the AGPL-3.0 states.",
|
|
14005
14041
|
"file": "public-landing/terms.html",
|
|
14006
|
-
"line":
|
|
14042
|
+
"line": 76,
|
|
14007
14043
|
"origin": "html-text",
|
|
14008
14044
|
"confidence": "certain"
|
|
14009
14045
|
},
|
|
@@ -14106,6 +14142,15 @@
|
|
|
14106
14142
|
"origin": "html-text",
|
|
14107
14143
|
"confidence": "certain"
|
|
14108
14144
|
},
|
|
14145
|
+
{
|
|
14146
|
+
"id": "9b3312899fb1",
|
|
14147
|
+
"surface": "landing",
|
|
14148
|
+
"text": "What is public.",
|
|
14149
|
+
"file": "public-landing/terms.html",
|
|
14150
|
+
"line": 68,
|
|
14151
|
+
"origin": "html-text",
|
|
14152
|
+
"confidence": "certain"
|
|
14153
|
+
},
|
|
14109
14154
|
{
|
|
14110
14155
|
"id": "b19e6b7ddc42",
|
|
14111
14156
|
"surface": "landing",
|
|
@@ -14142,6 +14187,15 @@
|
|
|
14142
14187
|
"origin": "html-text",
|
|
14143
14188
|
"confidence": "certain"
|
|
14144
14189
|
},
|
|
14190
|
+
{
|
|
14191
|
+
"id": "6130dcb60532",
|
|
14192
|
+
"surface": "landing",
|
|
14193
|
+
"text": "What you control.",
|
|
14194
|
+
"file": "public-landing/terms.html",
|
|
14195
|
+
"line": 69,
|
|
14196
|
+
"origin": "html-text",
|
|
14197
|
+
"confidence": "certain"
|
|
14198
|
+
},
|
|
14145
14199
|
{
|
|
14146
14200
|
"id": "b938baf0f723",
|
|
14147
14201
|
"surface": "landing",
|
|
@@ -14250,6 +14304,15 @@
|
|
|
14250
14304
|
"origin": "html-text",
|
|
14251
14305
|
"confidence": "certain"
|
|
14252
14306
|
},
|
|
14307
|
+
{
|
|
14308
|
+
"id": "0f06f7e72ea7",
|
|
14309
|
+
"surface": "landing",
|
|
14310
|
+
"text": "Your acceptance is recorded.",
|
|
14311
|
+
"file": "public-landing/terms.html",
|
|
14312
|
+
"line": 70,
|
|
14313
|
+
"origin": "html-text",
|
|
14314
|
+
"confidence": "certain"
|
|
14315
|
+
},
|
|
14253
14316
|
{
|
|
14254
14317
|
"id": "a4d2357d486b",
|
|
14255
14318
|
"surface": "landing",
|
|
@@ -14358,6 +14421,15 @@
|
|
|
14358
14421
|
"origin": "html-text",
|
|
14359
14422
|
"confidence": "certain"
|
|
14360
14423
|
},
|
|
14424
|
+
{
|
|
14425
|
+
"id": "cd379e3e0111",
|
|
14426
|
+
"surface": "landing",
|
|
14427
|
+
"text": "Your work across projects",
|
|
14428
|
+
"file": "public-landing/terms.html",
|
|
14429
|
+
"line": 65,
|
|
14430
|
+
"origin": "html-text",
|
|
14431
|
+
"confidence": "certain"
|
|
14432
|
+
},
|
|
14361
14433
|
{
|
|
14362
14434
|
"id": "bb158e10340b",
|
|
14363
14435
|
"surface": "landing",
|
|
@@ -14444,7 +14516,7 @@
|
|
|
14444
14516
|
"surface": "landing",
|
|
14445
14517
|
"text": "contact page",
|
|
14446
14518
|
"file": "public-landing/terms.html",
|
|
14447
|
-
"line":
|
|
14519
|
+
"line": 80,
|
|
14448
14520
|
"origin": "html-text",
|
|
14449
14521
|
"confidence": "certain"
|
|
14450
14522
|
},
|
|
@@ -14606,7 +14678,7 @@
|
|
|
14606
14678
|
"surface": "landing",
|
|
14607
14679
|
"text": "privacy page",
|
|
14608
14680
|
"file": "public-landing/terms.html",
|
|
14609
|
-
"line":
|
|
14681
|
+
"line": 80,
|
|
14610
14682
|
"origin": "html-text",
|
|
14611
14683
|
"confidence": "certain"
|
|
14612
14684
|
},
|
package/docs/file-map.md
CHANGED
|
@@ -355,6 +355,7 @@ tests/
|
|
|
355
355
|
├── application_echo.mjs ← (task 1002284, goal 1000045, ADR 0201 D1.3/D1.4): the hub's STATUSLESS echo of an application. Part A pins the domain functions — the read binds `APPLICATION_ECHO_WINDOW_DAYS` itself (one 30, not three), the write is an upsert on the pair, neither statement mentions a status, and a non-numeric principal is refused before any query (ADR 0171 D2). Part B runs the real `POST /my-projects/join` and `GET /my-projects` over an in-memory `platform_identity_application_echoes` table with the relay's answer scripted: only an accepted application leaves a trace — a standing membership, either door refusal, a members-only project and an unreachable project leave none — and best-effort is asserted in both directions (a failed echo write still reports the filed application; a failed echo read still lands the memberships listing). DB-free `unit` lane.
|
|
356
356
|
├── scouting_owner_access_and_reach.mjs ← (task 1002290, goal 1000045 C5, ADR 0210): the R14 proof — WHO may read the cross-project recruiting directory, and WHO appears in it. A project owner (an instance-asserted, hub-witnessed `membership_kind='owner'`) reads `GET /scouting` with no rank at all; a builder who is neither owner nor `project.curate` holder gets the unchanged 403; an un-witnessed owner claim is refused; a broken ownership read falls closed onto the permission gate rather than admitting or 500ing. And the recruiting opt-out finally bites: a public, unhidden, active account that opted out is absent from `/scouting` and the hall's `/people` (the same endpoint), returns when it opts back in, and the opt-out grants and denies no permission — an opted-out owner still reads the directory while being absent from their own copy of it. Fake pool filtered by each statement's own WHERE clause, real route, real `requirePermission` over the real government catalog, real HTTP. Mutation-checked three ways. DB-free `unit` lane.
|
|
357
357
|
├── scouting_page_gate.mjs ← (task 1002290, goal 1000045 C5, ADR 0210 D3): the OTHER half of R14 — the `/builders/people` SHELL must admit exactly the two audiences `GET /scouting` admits. A project owner holding no permission reaches it; everyone the route refuses gets the byte-identical `?denied=project.curate` redirect; and each of six ways the `alsoAdmits` door can fail (module absent, function absent, throw, reject, non-boolean, prototype-only) fails CLOSED. Also pins that the other four page gates never consult the seam at all, and that exactly ONE gate declares a second door. Its own file because the route proof's ESM imports pull `auth.js` in before a session double could be installed — this one installs it first, the `government_page_gates.mjs` pattern. DB-free `unit` lane.
|
|
358
|
+
├── terms_acceptance.mjs ← (task 1002913, goal 1000045, ADR 0213): the platform T&C acceptance record. Proves the four properties that make it evidence rather than a claim — the /terms page carries the cross-project consent ADR 0141 §4 deferred to it and it is apex-only; acceptance is written ONLY by the hub's own /sso/authorize (driven over the real router with an attacker-controlled query string, so a forged timestamp/version/activate flag reaches nothing); it is written ONCE (the fake pool interprets the upsert's own column assignments, so COALESCE is executed, not string-matched, and an assignment shape it cannot read throws instead of reading as "unchanged"); and consent is two columns in the shared predicate, so an account the hub witnessed before the terms existed publishes nothing. Plus the projection rule — every read whose row a JS twin judges must select both consent columns, or the twin denies everyone silently. DB-free `unit` lane, asserts its own lane membership.
|
|
358
359
|
└── public_task_resolver.mjs ← (#604): the public `/public/tasks/:id` projection — title/value_summary exposed only for shipped|confirmed; withheld for in-flight so unshipped/security task names can't leak.
|
|
359
360
|
```
|
|
360
361
|
|
|
@@ -1291,5 +1291,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1291
1291
|
landed since 1.19.425 with no explicit bump. run 33270772607. (task 1002620)
|
|
1292
1292
|
1.19.427 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1293
1293
|
landed since 1.19.426 with no explicit bump. run 33271340372. (task 1002620)
|
|
1294
|
+
1.19.428 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1295
|
+
landed since 1.19.427 with no explicit bump. run 33274315388. (task 1002620)
|
|
1294
1296
|
---------------------------------------------------------------------------
|
|
1295
1297
|
```
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// query cannot drift (ADR 0202 D4; the ADR 0182 `publishableSql` precedent).
|
|
4
4
|
//
|
|
5
5
|
// WHAT IT ANSWERS: "may the world see that this account exists at all?" —
|
|
6
|
-
// profile_state = 'active'
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// CONSENT (profile_state = 'active' AND terms_accepted_at IS NOT NULL: a hub
|
|
7
|
+
// session has touched the account AND that human accepted the platform terms,
|
|
8
|
+
// ADR 0171 D3 + ADR 0213) AND account_visibility = 'public' (existence: the
|
|
9
|
+
// privacy-spec D1 axis). Both floors, ANDed, in one place.
|
|
9
10
|
//
|
|
10
11
|
// WHY ITS OWN FILE. ADR 0202 D4 requires connections to consume ONE shared
|
|
11
12
|
// reader rather than grow a connections-flavoured variant — "adding a second
|
|
@@ -52,11 +53,11 @@
|
|
|
52
53
|
// (ADR 0192 §6 / ADR 0194 §2).
|
|
53
54
|
'use strict';
|
|
54
55
|
|
|
55
|
-
// The JS twin. `account` is any row carrying
|
|
56
|
-
// anything falsy (absent row, unreadable account) answers
|
|
56
|
+
// The JS twin. `account` is any row carrying the consent columns +
|
|
57
|
+
// account_visibility; anything falsy (absent row, unreadable account) answers
|
|
58
|
+
// false. COMPOSED from isAccountActive so the consent axis has one definition.
|
|
57
59
|
function isAccountVisible(account) {
|
|
58
|
-
|
|
59
|
-
return account.profile_state === 'active' && account.account_visibility === 'public';
|
|
60
|
+
return isAccountActive(account) && account.account_visibility === 'public';
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
// The SAME predicate as a SQL fragment, for the reads that filter in the
|
|
@@ -70,25 +71,43 @@ function accountVisibleSql(alias) {
|
|
|
70
71
|
throw new Error('accountVisibleSql: alias must be a bare SQL identifier');
|
|
71
72
|
}
|
|
72
73
|
const q = alias ? `${alias}.` : '';
|
|
73
|
-
return `(${
|
|
74
|
+
return `(${accountActiveSql(alias)} AND ${q}account_visibility = 'public')`;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
// The CONSENT floor on its own —
|
|
77
|
+
// The CONSENT floor on its own — consented, without the existence axis. A private
|
|
77
78
|
// account is still a real participant in its OWN scope: ADR 0202 D4 §2 says its
|
|
78
79
|
// connections appear in its own list and in the counterparty's own list, and an
|
|
79
80
|
// incoming request must name who is asking or it cannot be decided. Those
|
|
80
81
|
// own-scope reads floor on this; every PUBLIC read floors on accountVisibleSql.
|
|
82
|
+
//
|
|
83
|
+
// CONSENT IS TWO COLUMNS, NOT ONE (ADR 0213, task 1002913). profile_state='active'
|
|
84
|
+
// says the hub witnessed a human at /sso/authorize; terms_accepted_at says that
|
|
85
|
+
// human accepted the platform terms — the artifact ADR 0141 §4 deferred
|
|
86
|
+
// cross-project-aggregation consent to, and which ADR 0171 D3 wrote its own
|
|
87
|
+
// definition around ("the one path where a human is demonstrably at the hub, and
|
|
88
|
+
// where T&C acceptance can later be captured"). Both are stamped by the SAME
|
|
89
|
+
// upsertAccount branch, so they cannot disagree for any account activated after
|
|
90
|
+
// ADR 0213 shipped; requiring both here is what makes that structural rather than
|
|
91
|
+
// a promise, and what keeps a legacy row that predates the terms from publishing
|
|
92
|
+
// on a consent premise that was never true. It re-publishes on its next hub
|
|
93
|
+
// sign-in — the lazy upgrade D3's own backfill rule and P03 already chose.
|
|
94
|
+
//
|
|
95
|
+
// This is the ONE place terms_accepted_at is read. tests/effective_visibility_-
|
|
96
|
+
// predicate.mjs fails any other file that tests it, exactly as for the columns
|
|
97
|
+
// above.
|
|
81
98
|
function accountActiveSql(alias) {
|
|
82
99
|
if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
|
|
83
100
|
throw new Error('accountActiveSql: alias must be a bare SQL identifier');
|
|
84
101
|
}
|
|
85
|
-
|
|
102
|
+
const q = alias ? `${alias}.` : '';
|
|
103
|
+
return `(${q}profile_state = 'active' AND ${q}terms_accepted_at IS NOT NULL)`;
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
// The JS twin of accountActiveSql, for an own-scope reader that already holds
|
|
89
|
-
// the row (routes/connections.js). Fail-closed, same as isAccountVisible
|
|
107
|
+
// the row (routes/connections.js). Fail-closed, same as isAccountVisible: a row
|
|
108
|
+
// missing EITHER consent column is not consented.
|
|
90
109
|
function isAccountActive(account) {
|
|
91
|
-
return !!account && account.profile_state === 'active';
|
|
110
|
+
return !!account && account.profile_state === 'active' && account.terms_accepted_at != null;
|
|
92
111
|
}
|
|
93
112
|
|
|
94
113
|
// -------------------------------------------------------------------------
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
// (ADR 0171 D4 / ADR 0202 D4).
|
|
24
24
|
//
|
|
25
25
|
// WHOSE GRAPH. Node visibility is viewer-relative by design (ADR 0202 title):
|
|
26
|
-
// the viewer's OWN connections floor on
|
|
27
|
-
//
|
|
26
|
+
// the viewer's OWN connections floor on the CONSENT axis alone (accountActiveSql:
|
|
27
|
+
// profile_state + terms acceptance, ADR 0213) — a private account that accepted
|
|
28
|
+
// you appears in your own-scope list (D4 §2) — while every
|
|
28
29
|
// DEEPER hop floors on the full public predicate in account-visibility.js. So a
|
|
29
30
|
// bridge the viewer may not see yields `null`, not "3rd", and two viewers
|
|
30
31
|
// legitimately seeing different labels for the same target is the model working.
|
|
@@ -128,7 +129,7 @@ async function findAccountByHandle(handle, { pool = defaultPool } = {}) {
|
|
|
128
129
|
const norm = String(handle ?? '').trim().toLowerCase();
|
|
129
130
|
if (!/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(norm)) return null;
|
|
130
131
|
const { rows } = await pool.query(
|
|
131
|
-
`SELECT github_id, handle, display_name, avatar_url, profile_state, account_visibility
|
|
132
|
+
`SELECT github_id, handle, display_name, avatar_url, profile_state, terms_accepted_at, account_visibility
|
|
132
133
|
FROM platform_identity_accounts
|
|
133
134
|
WHERE lower(handle) = $1 OR lower(previous_handle) = $1
|
|
134
135
|
ORDER BY (lower(handle) = $1) DESC
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
-- platform_identity_019_terms_acceptance.sql — the platform T&C acceptance
|
|
2
|
+
-- record (ADR 0213, task 1002913; the artifact ADR 0141 §4 assumed and ADR 0171
|
|
3
|
+
-- D3 reserved a place for).
|
|
4
|
+
--
|
|
5
|
+
-- WHAT IT IS. ADR 0141 deferred consent for CROSS-PROJECT AGGREGATION to "the
|
|
6
|
+
-- platform T&C" and conditioned the scouting view on it; ADR 0171 D3 then made
|
|
7
|
+
-- consent a fail-closed profile_state whose activation moment is "the one path
|
|
8
|
+
-- where a human is demonstrably at the hub (and where T&C acceptance can later
|
|
9
|
+
-- be captured, task 1002913)". This is that capture. terms_accepted_at is the
|
|
10
|
+
-- moment the hub itself witnessed the human accept; terms_version records WHICH
|
|
11
|
+
-- text they accepted, so a later revision is a distinguishable event rather than
|
|
12
|
+
-- a silent overwrite.
|
|
13
|
+
--
|
|
14
|
+
-- WRITTEN ONLY BY THE HUB'S OWN SIGN-IN. Both columns are set exclusively by
|
|
15
|
+
-- upsertAccount's `activate` branch, whose sole caller is /sso/authorize, where
|
|
16
|
+
-- `activate: true` is a source literal and every other field comes from the
|
|
17
|
+
-- hub's resolved session (ADR 0205 D3). No request-body field can reach them —
|
|
18
|
+
-- an acceptance record a caller could assert is not evidence of anything.
|
|
19
|
+
--
|
|
20
|
+
-- IDEMPOTENT BY CONSTRUCTION. The conflict branch writes
|
|
21
|
+
-- COALESCE(existing, now()), so the FIRST acceptance is the one that stands and
|
|
22
|
+
-- signing in again never rewrites or duplicates it.
|
|
23
|
+
--
|
|
24
|
+
-- NO BACKFILL — DELIBERATE, and the same asymmetry ADR 0171 D3 chose. Existing
|
|
25
|
+
-- accounts get NULL: no platform T&C covering cross-project aggregation has ever
|
|
26
|
+
-- been shown to anyone, so no acceptance exists to record, and inventing a
|
|
27
|
+
-- timestamp would fabricate the very artifact this task exists to make real.
|
|
28
|
+
-- They re-accept on their next hub-session authorize, exactly as D3's own
|
|
29
|
+
-- backfill rule provides ("all existing accounts start provisional and lazily
|
|
30
|
+
-- upgrade on their next hub-session authorize") and P03 ratified (task 1002799:
|
|
31
|
+
-- no backfill activation). The consequence — currently-active accounts are not
|
|
32
|
+
-- publicly visible until their next hub sign-in — is the owner decision carried
|
|
33
|
+
-- on blocker 1000122; grandfathering is one line here if the owner prefers it.
|
|
34
|
+
--
|
|
35
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
|
|
36
|
+
|
|
37
|
+
BEGIN;
|
|
38
|
+
|
|
39
|
+
ALTER TABLE platform_identity_accounts
|
|
40
|
+
ADD COLUMN IF NOT EXISTS terms_accepted_at timestamptz,
|
|
41
|
+
ADD COLUMN IF NOT EXISTS terms_version text;
|
|
42
|
+
|
|
43
|
+
-- No index: acceptance is read only as part of the per-account effective-
|
|
44
|
+
-- visibility predicate, and accounts are already looked up by github_id via the
|
|
45
|
+
-- UNIQUE constraint from platform_identity_001_tables.sql.
|
|
46
|
+
|
|
47
|
+
COMMIT;
|
|
@@ -18,6 +18,7 @@ const api = require('../../src/module-api');
|
|
|
18
18
|
// D4 named). Every public read below joins it from here; nothing in this file
|
|
19
19
|
// re-derives one (see account-visibility.js).
|
|
20
20
|
const visibility = require('./account-visibility');
|
|
21
|
+
const terms = require('./terms-acceptance');
|
|
21
22
|
|
|
22
23
|
const { pool: defaultPool } = api;
|
|
23
24
|
const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
|
|
@@ -139,10 +140,11 @@ async function ensureAccount({ githubId, githubLogin, displayName, avatarUrl },
|
|
|
139
140
|
// github_id). Called at /sso/authorize for the signed-in hub user. Identity only.
|
|
140
141
|
//
|
|
141
142
|
// `activate` is the ONE consent upgrade path (ADR 0171 D3): an account becomes
|
|
142
|
-
// profile_state='active' iff touched by a hub-session /sso/authorize —
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
143
|
+
// profile_state='active' iff touched by a hub-session /sso/authorize — where a
|
|
144
|
+
// human is demonstrably at the hub — and the SAME branch stamps the platform T&C
|
|
145
|
+
// acceptance (ADR 0213, idempotent; ./terms-acceptance.js), so no account is
|
|
146
|
+
// active without one. Other callers (device-poll, check-in) leave the fail-closed
|
|
147
|
+
// 'provisional' + a NULL acceptance; activation is one-way (never demotes).
|
|
146
148
|
//
|
|
147
149
|
// display_name is source-guarded (the ADR 0156 mirror, P02/task 1002798): once
|
|
148
150
|
// a builder sets the account name themselves (setAccountProfileFields stamps
|
|
@@ -154,18 +156,18 @@ async function ensureAccount({ githubId, githubLogin, displayName, avatarUrl },
|
|
|
154
156
|
// rewrites identity; a federated check-in gets `ensureAccount`.
|
|
155
157
|
async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, activate = false }, { pool = defaultPool } = {}) {
|
|
156
158
|
const { rows } = await pool.query(
|
|
157
|
-
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
|
|
158
|
-
VALUES ($1, $2, $3, $4, CASE WHEN $5::boolean THEN 'active' ELSE 'provisional' END)
|
|
159
|
+
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state${terms.acceptanceInsertColumns()})
|
|
160
|
+
VALUES ($1, $2, $3, $4, CASE WHEN $5::boolean THEN 'active' ELSE 'provisional' END${terms.acceptanceInsertValues('$5', '$6')})
|
|
159
161
|
ON CONFLICT (github_id) DO UPDATE SET
|
|
160
162
|
github_login = EXCLUDED.github_login,
|
|
161
163
|
display_name = CASE WHEN platform_identity_accounts.display_name_source = 'user'
|
|
162
164
|
THEN platform_identity_accounts.display_name
|
|
163
165
|
ELSE EXCLUDED.display_name END,
|
|
164
166
|
avatar_url = EXCLUDED.avatar_url,
|
|
165
|
-
profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END,
|
|
167
|
+
profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END, ${terms.acceptanceConflictSet('$5', '$6')}
|
|
166
168
|
updated_at = now()
|
|
167
169
|
RETURNING id, github_id, github_login, display_name, avatar_url, profile_state, handle`,
|
|
168
|
-
[githubId, githubLogin, displayName ?? null, safeAvatarUrl(avatarUrl), !!activate],
|
|
170
|
+
[githubId, githubLogin, displayName ?? null, safeAvatarUrl(avatarUrl), !!activate, terms.TERMS_VERSION],
|
|
169
171
|
);
|
|
170
172
|
const row = rows[0];
|
|
171
173
|
// Seed the handle on first sight (spec Decision 3); a no-op once set.
|
|
@@ -189,7 +191,7 @@ async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
|
|
|
189
191
|
async function getAccount(githubId, { pool = defaultPool } = {}) {
|
|
190
192
|
const { rows } = await pool.query(
|
|
191
193
|
`SELECT id, github_id, github_login, display_name, avatar_url, profile_state, hide_stats,
|
|
192
|
-
account_visibility, recruiter_discoverable,
|
|
194
|
+
terms_accepted_at, terms_version, account_visibility, recruiter_discoverable,
|
|
193
195
|
handle, previous_handle, handle_changed_at, bio, links, display_name_source,
|
|
194
196
|
created_at, updated_at
|
|
195
197
|
FROM platform_identity_accounts WHERE github_id = $1`,
|
|
@@ -861,7 +863,7 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
|
861
863
|
async function getPublicProfileExtras(githubId, { pool = defaultPool } = {}) {
|
|
862
864
|
if (!isNumericGithubId(githubId)) return null;
|
|
863
865
|
const { rows } = await pool.query(
|
|
864
|
-
`SELECT profile_state, account_visibility, hide_stats
|
|
866
|
+
`SELECT profile_state, terms_accepted_at, account_visibility, hide_stats
|
|
865
867
|
FROM platform_identity_accounts WHERE github_id = $1`,
|
|
866
868
|
[githubId],
|
|
867
869
|
);
|
|
@@ -55,9 +55,10 @@ function connectionId(req) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// The caller's platform account, or a { fail } describing why they cannot take
|
|
58
|
-
// part.
|
|
59
|
-
//
|
|
60
|
-
//
|
|
58
|
+
// part. The CONSENT axis (isAccountActive — active AND the platform terms
|
|
59
|
+
// accepted, ADR 0213) is the floor for the WHOLE feature (ADR 0202 D4 §1): a
|
|
60
|
+
// provisional account cannot send, cannot receive, and never appears in a list
|
|
61
|
+
// or a frontier. This is about the CALLER, so it may say so plainly.
|
|
61
62
|
async function actingAccount(builder) {
|
|
62
63
|
const githubId = builder && builder.github_id;
|
|
63
64
|
if (!pi.isNumericGithubId(githubId)) {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// modules/platform-identity/terms-acceptance.js — THE platform T&C acceptance
|
|
2
|
+
// record: which text is current, and the SQL that stamps a builder's acceptance
|
|
3
|
+
// of it (ADR 0213, task 1002913).
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS ANSWERS: "did the hub itself witness this human accept the platform
|
|
6
|
+
// terms, and which text did they accept?" — the artifact ADR 0141 §4 deferred
|
|
7
|
+
// consent for cross-project aggregation to, and ADR 0171 D3 reserved a place for
|
|
8
|
+
// ("the one path where a human is demonstrably at the hub, and where T&C
|
|
9
|
+
// acceptance can later be captured, task 1002913").
|
|
10
|
+
//
|
|
11
|
+
// WHY ITS OWN FILE. platform-identity.js sits at the 1500-line fitness ratchet
|
|
12
|
+
// with `oversized_file_count: 0` as the baseline — there is no headroom, and
|
|
13
|
+
// four sessions have already set the precedent that new logic lands beside it
|
|
14
|
+
// (account-visibility.js, application-echo.js, routes/connections.js,
|
|
15
|
+
// src/bongos/routes/my-sessions.js). This file is that, for consent.
|
|
16
|
+
//
|
|
17
|
+
// THE WRITE SIDE ONLY — read the record through account-visibility.js. That file
|
|
18
|
+
// is the SOLE owner of every effective-visibility answer, and acceptance is now
|
|
19
|
+
// part of the consent axis it publishes (accountActiveSql / isAccountActive).
|
|
20
|
+
// Nothing here compares terms_accepted_at to anything; these are the statements
|
|
21
|
+
// that SET it. tests/effective_visibility_predicate.mjs enforces that split —
|
|
22
|
+
// its SPELLINGS list fails any file but the owner that writes the acceptance
|
|
23
|
+
// predicate, and deliberately does not match a write.
|
|
24
|
+
//
|
|
25
|
+
// WHY THE CALLER CANNOT FORGE ONE. These fragments have exactly one call site:
|
|
26
|
+
// upsertAccount's `activate` branch, whose only caller is /sso/authorize
|
|
27
|
+
// (routes/sso.js). There `activate: true` is a SOURCE LITERAL and every other
|
|
28
|
+
// field comes from the hub's own resolved session — the ADR 0205 D3 property
|
|
29
|
+
// established for recordFederatedSignin by task 1003340. No request-body field
|
|
30
|
+
// reaches these statements, which is the whole point: an acceptance record a
|
|
31
|
+
// caller could assert would be evidence of nothing.
|
|
32
|
+
//
|
|
33
|
+
// IDEMPOTENT BY CONSTRUCTION: the conflict branch is COALESCE(existing, new), so
|
|
34
|
+
// the FIRST acceptance stands and a second sign-in never rewrites or duplicates
|
|
35
|
+
// it. The two columns are only ever written together, so they cannot diverge.
|
|
36
|
+
//
|
|
37
|
+
// A LATER REVISION IS A KNOWN GAP, NOT A SILENT ONE. Because the version is
|
|
38
|
+
// recorded and never overwritten, publishing a new TERMS_VERSION leaves every
|
|
39
|
+
// existing row naming the text it actually agreed to — so the platform can see
|
|
40
|
+
// who has not accepted the new one. Re-acceptance on revision is deliberately
|
|
41
|
+
// NOT built here (ADR 0213 D4): it is a product flow, not a storage shape.
|
|
42
|
+
'use strict';
|
|
43
|
+
|
|
44
|
+
// The version of the platform terms currently served at /terms. It is stamped
|
|
45
|
+
// onto every acceptance, so it must match the version the page states — the
|
|
46
|
+
// pairing is asserted by tests/terms_acceptance.mjs, which reads both.
|
|
47
|
+
//
|
|
48
|
+
// Date-stamped rather than numbered: the page says "Version <this>", and a
|
|
49
|
+
// reader comparing page to record should not have to translate.
|
|
50
|
+
const TERMS_VERSION = '2026-08-29';
|
|
51
|
+
|
|
52
|
+
// A Postgres placeholder, e.g. '$5'. Interpolated into SQL, so it is validated
|
|
53
|
+
// rather than trusted — the account-visibility.js alias-guard pattern.
|
|
54
|
+
function assertPlaceholder(fn, value) {
|
|
55
|
+
if (!/^\$[1-9][0-9]*$/.test(String(value))) {
|
|
56
|
+
throw new Error(`${fn}: expected a Postgres placeholder like '$5', got ${JSON.stringify(value)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The acceptance columns, for an INSERT column list. Leading comma: it appends
|
|
61
|
+
// to an existing list rather than standing alone.
|
|
62
|
+
function acceptanceInsertColumns() {
|
|
63
|
+
return ', terms_accepted_at, terms_version';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The matching VALUES fragment. A row created by a NON-activating caller
|
|
67
|
+
// (ensureAccount's check-in / device-poll path) gets NULL — fail-closed: no hub
|
|
68
|
+
// session witnessed that human, so there is no acceptance to record.
|
|
69
|
+
function acceptanceInsertValues(activateParam, versionParam) {
|
|
70
|
+
assertPlaceholder('acceptanceInsertValues', activateParam);
|
|
71
|
+
assertPlaceholder('acceptanceInsertValues', versionParam);
|
|
72
|
+
return `, CASE WHEN ${activateParam}::boolean THEN now() ELSE NULL END`
|
|
73
|
+
+ `, CASE WHEN ${activateParam}::boolean THEN ${versionParam}::text ELSE NULL END`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The ON CONFLICT DO UPDATE SET fragment — the idempotent half. COALESCE keeps
|
|
77
|
+
// the FIRST acceptance, so signing in again re-reads the terms but does not
|
|
78
|
+
// restamp them; a non-activating upsert leaves both columns exactly as they are,
|
|
79
|
+
// mirroring the one-way rule profile_state already follows.
|
|
80
|
+
//
|
|
81
|
+
// Trailing comma: it sits between two other assignments in the SET list.
|
|
82
|
+
function acceptanceConflictSet(activateParam, versionParam) {
|
|
83
|
+
assertPlaceholder('acceptanceConflictSet', activateParam);
|
|
84
|
+
assertPlaceholder('acceptanceConflictSet', versionParam);
|
|
85
|
+
const self = 'platform_identity_accounts';
|
|
86
|
+
return `terms_accepted_at = CASE WHEN ${activateParam}::boolean`
|
|
87
|
+
+ ` THEN COALESCE(${self}.terms_accepted_at, now()) ELSE ${self}.terms_accepted_at END,`
|
|
88
|
+
+ ` terms_version = CASE WHEN ${activateParam}::boolean`
|
|
89
|
+
+ ` THEN COALESCE(${self}.terms_version, ${versionParam}::text) ELSE ${self}.terms_version END,`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
TERMS_VERSION,
|
|
94
|
+
acceptanceInsertColumns,
|
|
95
|
+
acceptanceInsertValues,
|
|
96
|
+
acceptanceConflictSet,
|
|
97
|
+
};
|