@bongos/core 1.21.134 → 1.21.136
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 +40 -20
- package/docs/architecture.md +10 -0
- package/docs/copy-inventory.md +10 -7
- package/docs/copy-registry.json +37 -10
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +1 -1
- package/migrations/core_277_passkey_foundation.sql +53 -0
- package/modules/hall-ui/public/goal-author-lib.js +25 -1
- package/modules/hall-ui/public/goal-author.js +55 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/run-unit-tests.js +3 -0
- package/src/bongos/passkeys.js +143 -0
- package/src/module-api.js +1 -1
- package/tests/goal_author_ui.mjs +27 -0
- package/tests/passkey_foundation_db.mjs +90 -0
- package/tests/passkey_pure.mjs +24 -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.21.
|
|
6
|
-
"core_contract": "1.21.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.21.136",
|
|
6
|
+
"core_contract": "1.21.136",
|
|
7
|
+
"source_commit": "e4b8cee4f6222b363f9b2e8aa1ceb79c6426a2eb",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-10-
|
|
9
|
+
"built_at": "2026-10-15T23:35:12.033Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 608,
|
|
13
13
|
"agent_docs_stubbed": 28,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 3153,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3790,
|
|
20
|
+
"tree_sha256": "2bb4525360079b817aa7bc06f384d604867c6896a6a2caa355d01487669160c2",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2392,7 +2392,7 @@
|
|
|
2392
2392
|
{
|
|
2393
2393
|
"path": "docs/architecture.md",
|
|
2394
2394
|
"mode": "0000644",
|
|
2395
|
-
"sha256": "
|
|
2395
|
+
"sha256": "3850775c198edeece6ac5497a9f5788421a8082e21edb496a4b00a3fc7a6eb3d"
|
|
2396
2396
|
},
|
|
2397
2397
|
{
|
|
2398
2398
|
"path": "docs/branding-contract.md",
|
|
@@ -2412,12 +2412,12 @@
|
|
|
2412
2412
|
{
|
|
2413
2413
|
"path": "docs/copy-inventory.md",
|
|
2414
2414
|
"mode": "0000644",
|
|
2415
|
-
"sha256": "
|
|
2415
|
+
"sha256": "befe58f901848724fd48c74e38ee396b31d2f523313708dbfe90138b9f86a2b2"
|
|
2416
2416
|
},
|
|
2417
2417
|
{
|
|
2418
2418
|
"path": "docs/copy-registry.json",
|
|
2419
2419
|
"mode": "0000644",
|
|
2420
|
-
"sha256": "
|
|
2420
|
+
"sha256": "26b2738a40324254a1222b3493eafceb00d77dbe07bfc9a5c882f576901040e0"
|
|
2421
2421
|
},
|
|
2422
2422
|
{
|
|
2423
2423
|
"path": "docs/design/apex-pass-2-direction.md",
|
|
@@ -2927,7 +2927,7 @@
|
|
|
2927
2927
|
{
|
|
2928
2928
|
"path": "docs/module-api-changelog.md",
|
|
2929
2929
|
"mode": "0000644",
|
|
2930
|
-
"sha256": "
|
|
2930
|
+
"sha256": "72c0a2b3d8e44eb7ae5e0b0b9c27eb4c70055f9eb8755496c9f174db024fec57"
|
|
2931
2931
|
},
|
|
2932
2932
|
{
|
|
2933
2933
|
"path": "docs/modules-contract.md",
|
|
@@ -3017,7 +3017,7 @@
|
|
|
3017
3017
|
{
|
|
3018
3018
|
"path": "docs/page-readings.json",
|
|
3019
3019
|
"mode": "0000644",
|
|
3020
|
-
"sha256": "
|
|
3020
|
+
"sha256": "7fefba487e6aee704f8d23d876d4db7720c9c1231ab61e94f5903f060d96c424"
|
|
3021
3021
|
},
|
|
3022
3022
|
{
|
|
3023
3023
|
"path": "docs/project-context.template.md",
|
|
@@ -4164,6 +4164,11 @@
|
|
|
4164
4164
|
"mode": "0000644",
|
|
4165
4165
|
"sha256": "550884fa117e707b65f5c56faca0e4e3850905db94d3f275cad5d520aa68db03"
|
|
4166
4166
|
},
|
|
4167
|
+
{
|
|
4168
|
+
"path": "migrations/core_277_passkey_foundation.sql",
|
|
4169
|
+
"mode": "0000644",
|
|
4170
|
+
"sha256": "52ebc3ce9c3e006ef2dfe0d85c9a97c53896f7b0d8f8fb700a1488a24c7bcb3c"
|
|
4171
|
+
},
|
|
4167
4172
|
{
|
|
4168
4173
|
"path": "modules/agents/CLAUDE.md",
|
|
4169
4174
|
"mode": "0000644",
|
|
@@ -5927,12 +5932,12 @@
|
|
|
5927
5932
|
{
|
|
5928
5933
|
"path": "modules/hall-ui/public/goal-author-lib.js",
|
|
5929
5934
|
"mode": "0000644",
|
|
5930
|
-
"sha256": "
|
|
5935
|
+
"sha256": "cacce0bf30ff04fbd040783aab51f53393259b537df7537664aedd084412eaef"
|
|
5931
5936
|
},
|
|
5932
5937
|
{
|
|
5933
5938
|
"path": "modules/hall-ui/public/goal-author.js",
|
|
5934
5939
|
"mode": "0000644",
|
|
5935
|
-
"sha256": "
|
|
5940
|
+
"sha256": "ef1c80876cf921aa03f2eefc4f04efa40d97428d8c1537e070f6b16e1d137602"
|
|
5936
5941
|
},
|
|
5937
5942
|
{
|
|
5938
5943
|
"path": "modules/hall-ui/public/goal-forest-lib.js",
|
|
@@ -10522,12 +10527,12 @@
|
|
|
10522
10527
|
{
|
|
10523
10528
|
"path": "package-lock.json",
|
|
10524
10529
|
"mode": "0000644",
|
|
10525
|
-
"sha256": "
|
|
10530
|
+
"sha256": "ad4055f1eef9ede69c3eda6f957baa66186ec383ac30b9f3b411302d651aae2e"
|
|
10526
10531
|
},
|
|
10527
10532
|
{
|
|
10528
10533
|
"path": "package.json",
|
|
10529
10534
|
"mode": "0000644",
|
|
10530
|
-
"sha256": "
|
|
10535
|
+
"sha256": "af3b71b069ad704356796bf041f8a2798a07e9c20aca50aaf3758c0fb311f8ca"
|
|
10531
10536
|
},
|
|
10532
10537
|
{
|
|
10533
10538
|
"path": "public-docs/index.html",
|
|
@@ -10547,7 +10552,7 @@
|
|
|
10547
10552
|
{
|
|
10548
10553
|
"path": "release-notes.json",
|
|
10549
10554
|
"mode": "0000644",
|
|
10550
|
-
"sha256": "
|
|
10555
|
+
"sha256": "b170ded56d5ca6cc029de29e1ceade0100ef20de335fa810f4aa0d220012fc84"
|
|
10551
10556
|
},
|
|
10552
10557
|
{
|
|
10553
10558
|
"path": "scripts/cron/style_guide_audit.sh",
|
|
@@ -11832,7 +11837,7 @@
|
|
|
11832
11837
|
{
|
|
11833
11838
|
"path": "scripts/gds/run-unit-tests.js",
|
|
11834
11839
|
"mode": "0000644",
|
|
11835
|
-
"sha256": "
|
|
11840
|
+
"sha256": "213d8e4199ab429e64d0c65f4eda42a9d0cb4c64baa1424f793e223af3e66308"
|
|
11836
11841
|
},
|
|
11837
11842
|
{
|
|
11838
11843
|
"path": "scripts/gds/runner-drift.js",
|
|
@@ -12689,6 +12694,11 @@
|
|
|
12689
12694
|
"mode": "0000644",
|
|
12690
12695
|
"sha256": "b2f6d65e3463052609e51d135ea560370104d0457a0e7e86bf9b7120fe1cddfb"
|
|
12691
12696
|
},
|
|
12697
|
+
{
|
|
12698
|
+
"path": "src/bongos/passkeys.js",
|
|
12699
|
+
"mode": "0000644",
|
|
12700
|
+
"sha256": "2fe8018148a70b21515352084e3bc9047da0ee4749e27df6f68c93ba29a2368a"
|
|
12701
|
+
},
|
|
12692
12702
|
{
|
|
12693
12703
|
"path": "src/bongos/path-match.js",
|
|
12694
12704
|
"mode": "0000644",
|
|
@@ -12902,7 +12912,7 @@
|
|
|
12902
12912
|
{
|
|
12903
12913
|
"path": "src/module-api.js",
|
|
12904
12914
|
"mode": "0000644",
|
|
12905
|
-
"sha256": "
|
|
12915
|
+
"sha256": "5968c611fba761d24e4118debb2aeb34772640722e0306a0c61a351bf7455f0f"
|
|
12906
12916
|
},
|
|
12907
12917
|
{
|
|
12908
12918
|
"path": "src/module-loader/catalog.js",
|
|
@@ -14692,7 +14702,7 @@
|
|
|
14692
14702
|
{
|
|
14693
14703
|
"path": "tests/goal_author_ui.mjs",
|
|
14694
14704
|
"mode": "0000644",
|
|
14695
|
-
"sha256": "
|
|
14705
|
+
"sha256": "fe81fe8835244f6e98aad16fd46b304ac23bc0d755b74481b9d720f19af6c3d2"
|
|
14696
14706
|
},
|
|
14697
14707
|
{
|
|
14698
14708
|
"path": "tests/goal_category.mjs",
|
|
@@ -16859,6 +16869,16 @@
|
|
|
16859
16869
|
"mode": "0000644",
|
|
16860
16870
|
"sha256": "fd69c44ebf3d246ecedf527275448389cca809e231d1c6cb34e4c12bfdc38111"
|
|
16861
16871
|
},
|
|
16872
|
+
{
|
|
16873
|
+
"path": "tests/passkey_foundation_db.mjs",
|
|
16874
|
+
"mode": "0000644",
|
|
16875
|
+
"sha256": "5ff76c608f9f7b687e6b65af6bc667bdb42a21d520425b16bf15d36ca03848d9"
|
|
16876
|
+
},
|
|
16877
|
+
{
|
|
16878
|
+
"path": "tests/passkey_pure.mjs",
|
|
16879
|
+
"mode": "0000644",
|
|
16880
|
+
"sha256": "d0466c6a46c2eadd023801640d5da2d395d68cab8c8a9a46eee9b2e88e8c5182"
|
|
16881
|
+
},
|
|
16862
16882
|
{
|
|
16863
16883
|
"path": "tests/paste_token_api_base.mjs",
|
|
16864
16884
|
"mode": "0000644",
|
package/docs/architecture.md
CHANGED
|
@@ -251,6 +251,16 @@ store_module_prices(id, module_key, price_cents, price_credits, set_by, set_at)
|
|
|
251
251
|
-- in that currency, at least one must be set.
|
|
252
252
|
```
|
|
253
253
|
|
|
254
|
+
Passkey foundation (migration `core_277`, task 1004750, [ADR 0382](adr/0382-a-hardware-card-is-a-passkey-step-up-an-archon-switches-on-per-builder.md) D1/D6 — storage only; the ceremonies and routes are their own tasks; code in `src/bongos/passkeys.js`):
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
passkey_credentials(id, credential_id UNIQUE, builder_id FK, public_key bytea, sign_count,
|
|
258
|
+
transports[], aaguid, nickname, created_at, last_used_at)
|
|
259
|
+
passkey_user_handles(builder_id PK FK, user_handle UNIQUE, created_at) -- opaque, never the email
|
|
260
|
+
passkey_challenges(challenge PK, purpose, builder_id NULL, created_at, expires_at)
|
|
261
|
+
-- single-use (DELETEd on consume), TTL swept; builder_id NULL for sign-in
|
|
262
|
+
```
|
|
263
|
+
|
|
254
264
|
Module release channels (migration `core_276`, task 1003815, [ADR 0344](adr/0344-a-tester-is-a-project-that-opts-in-once.md) — which channel a published version is on; the data only, the tester-first publish, promotion gate and channel-aware install are their own tasks):
|
|
255
265
|
|
|
256
266
|
```
|
package/docs/copy-inventory.md
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
> Deterministic on a given tree — regenerate and commit rather than hand-editing.
|
|
5
5
|
> Machine-readable twin: [`copy-registry.json`](copy-registry.json).
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**2742 strings** across 3 surfaces · 2320 distinct pieces of copy · 2742 certain, 0 heuristic · 242 shown in more than one place.
|
|
8
8
|
|
|
9
9
|
## Surfaces
|
|
10
10
|
|
|
11
11
|
| surface | lives at | files | strings |
|
|
12
12
|
|---|---|---|---|
|
|
13
|
-
| `builders-hall` | `modules/hall-ui/public` | 145 |
|
|
13
|
+
| `builders-hall` | `modules/hall-ui/public` | 145 | 1934 |
|
|
14
14
|
| `status-dashboard` | `modules/status-ui/public` | 4 | 100 |
|
|
15
15
|
| `landing` | `modules/public-landing/public` | 9 | 708 |
|
|
16
16
|
|
|
@@ -78,7 +78,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
|
|
|
78
78
|
| `8226c979db4d` | 3x | Not now | modules/hall-ui/public/collab.js:668, modules/hall-ui/public/paintbrush.js:119, modules/hall-ui/public/paintbrush.js:183 |
|
|
79
79
|
| … | | _202 more in the JSON registry_ | |
|
|
80
80
|
|
|
81
|
-
## `builders-hall` —
|
|
81
|
+
## `builders-hall` — 1934 strings
|
|
82
82
|
|
|
83
83
|
| id | conf | text | where |
|
|
84
84
|
|---|---|---|---|
|
|
@@ -151,10 +151,12 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
|
|
|
151
151
|
| `524188fe0078` | certain | Active sessions | `modules/hall-ui/public/settings.html:159` |
|
|
152
152
|
| `d628b36ec58e` | certain | Activity, by role | `modules/hall-ui/public/profile.html:138` |
|
|
153
153
|
| `32509514479f` | certain | Add a done-when criterion | `modules/hall-ui/public/goal-author.js:38` |
|
|
154
|
+
| `83beba462b20` | certain | Add a task | `modules/hall-ui/public/goal-author.js:38` |
|
|
154
155
|
| `f4c5a03f715c` | certain | Add criterion | `modules/hall-ui/public/goal-author.js:38` |
|
|
155
156
|
| `2b94171ce525` | certain | Add dependency | `modules/hall-ui/public/task.js:592` |
|
|
156
157
|
| `a70b72ec4dcc` | certain | Add images | `modules/hall-ui/public/page-notes.js:120` |
|
|
157
158
|
| `da8659b0099b` | certain | Add something you wrote — notes, a standard, a worked example. It is indexed for recall, not pasted into every | `modules/hall-ui/public/settings-specialities.js:354` |
|
|
159
|
+
| `8b51e2e70cfb` | certain | Add task | `modules/hall-ui/public/goal-author.js:38` |
|
|
158
160
|
| `82a8bd77d374` | certain | Add these ranks | `modules/hall-ui/public/government.js:929` |
|
|
159
161
|
| `eded707fab69` | certain | Add to what it knows | `modules/hall-ui/public/settings-specialities.js:354` |
|
|
160
162
|
| `cc4125ca7cff` | certain | Added to this version after it started building — an Archon override of the scope gate. Open the goal to see w | `modules/hall-ui/public/goals-render.js:194` |
|
|
@@ -641,6 +643,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
|
|
|
641
643
|
| `ef224c9de7c8` | certain | Ideas — {{worldName}} | `modules/hall-ui/public/ideas.html:6` |
|
|
642
644
|
| `9e9f8a44276b` | certain | Identity vouched | `modules/hall-ui/public/watch.js:396` |
|
|
643
645
|
| `3c24eedc9f2d` | certain | If that page does not let you in, ask {…} to install it for you.{…} | `modules/hall-ui/public/settings-software-update.js:101` |
|
|
646
|
+
| `65977bbcda48` | certain | If the goal still has a “Spec goal” task that is not done, this task waits behind it until the spec is finishe | `modules/hall-ui/public/goal-author.js:38` |
|
|
644
647
|
| `7d6a7de7a69e` | certain | If the {…} has invited you, sign in with GitHub and you're in. If not, ask them for an invite — this project d | `modules/hall-ui/public/hall-render.js:292` |
|
|
645
648
|
| `4ef9f2a4df3b` | certain | If you own this project, | `modules/hall-ui/public/deploy.js:424` |
|
|
646
649
|
| `b6c47d4ee42c` | certain | Image key removed. | `modules/hall-ui/public/settings.js:1012` |
|
|
@@ -1365,7 +1368,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
|
|
|
1365
1368
|
| `bcdbded93d4e` | certain | The work board failed to load. Reload to try again. | `modules/hall-ui/public/work.js:1214` |
|
|
1366
1369
|
| `996d2aa0e2f0` | certain | Then copy the project onto your machine, install its dependencies, and sign in: | `modules/hall-ui/public/hall-render.js:837` |
|
|
1367
1370
|
| `383018c7df70` | certain | There is no transcript you can read for this session. | `modules/hall-ui/public/sessions.js:234` |
|
|
1368
|
-
| `9f09494abfa1` | certain | These open goals look related — use one instead? | `modules/hall-ui/public/goal-author.js:
|
|
1371
|
+
| `9f09494abfa1` | certain | These open goals look related — use one instead? | `modules/hall-ui/public/goal-author.js:144` |
|
|
1369
1372
|
| `d6b9826f1bd3` | certain | These words are the studio page itself. Take the page, type over them here, and submit it from the editor when | `modules/hall-ui/public/studio.html:106` |
|
|
1370
1373
|
| `775a73b066e3` | certain | These words come from the skill’s own description, which was written for Claude to recognise rather than for y | `modules/hall-ui/public/settings-specialities.js:128` |
|
|
1371
1374
|
| `a0140098c0e0` | certain | They already have an invitation or a join-request pending on this goal. | `modules/hall-ui/public/goals-render.js:1302` |
|
|
@@ -1970,11 +1973,11 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
|
|
|
1970
1973
|
| `e31a588bb9e1` | certain | — moved {…}, and the box confirmed it. | `modules/hall-ui/public/deploy.js:400` |
|
|
1971
1974
|
| `417adabd1a00` | certain | — no goal suggested — open the goal board and pick one — | `modules/hall-ui/public/task-detail.js:412` |
|
|
1972
1975
|
| `1f9504667df5` | certain | — none — | `modules/hall-ui/public/goal-author.js:38` |
|
|
1973
|
-
| `1f9504667df5` | certain | — none — | `modules/hall-ui/public/goal-author.js:
|
|
1976
|
+
| `1f9504667df5` | certain | — none — | `modules/hall-ui/public/goal-author.js:128` |
|
|
1974
1977
|
| `da9962dc1729` | certain | — or open any diagram fullscreen. | `modules/hall-ui/public/diagrams.html:33` |
|
|
1975
1978
|
| `b1ca53ebfc10` | certain | — pick a goal — | `modules/hall-ui/public/goal-author.js:38` |
|
|
1976
|
-
| `b1ca53ebfc10` | certain | — pick a goal — | `modules/hall-ui/public/goal-author.js:
|
|
1977
|
-
| `825930577f01` | certain | — pick a version — | `modules/hall-ui/public/goal-author.js:
|
|
1979
|
+
| `b1ca53ebfc10` | certain | — pick a goal — | `modules/hall-ui/public/goal-author.js:112` |
|
|
1980
|
+
| `825930577f01` | certain | — pick a version — | `modules/hall-ui/public/goal-author.js:125` |
|
|
1978
1981
|
| `61ebc5d684fd` | certain | — stops the project and its nightly backups but keeps everything: its database, its backups and your repositor | `modules/hall-ui/public/project-settings.html:60` |
|
|
1979
1982
|
| `e5c5e94152b3` | certain | — that's all the tooling you need up front. | `modules/hall-ui/public/hall-render.js:837` |
|
|
1980
1983
|
| `85f1dd2ee732` | certain | — the whole project | `modules/hall-ui/public/goal-map.html:69` |
|
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": 2742,
|
|
7
|
+
"unique_ids": 2320,
|
|
8
|
+
"certain": 2742,
|
|
9
9
|
"heuristic": 0,
|
|
10
10
|
"duplicate_ids": 242
|
|
11
11
|
},
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"builders-hall": {
|
|
14
14
|
"at": "modules/hall-ui/public",
|
|
15
15
|
"files": 145,
|
|
16
|
-
"strings":
|
|
16
|
+
"strings": 1934
|
|
17
17
|
},
|
|
18
18
|
"status-dashboard": {
|
|
19
19
|
"at": "modules/status-ui/public",
|
|
@@ -2615,7 +2615,7 @@
|
|
|
2615
2615
|
"occurrences": 2,
|
|
2616
2616
|
"at": [
|
|
2617
2617
|
"modules/hall-ui/public/goal-author.js:38",
|
|
2618
|
-
"modules/hall-ui/public/goal-author.js:
|
|
2618
|
+
"modules/hall-ui/public/goal-author.js:128"
|
|
2619
2619
|
]
|
|
2620
2620
|
},
|
|
2621
2621
|
{
|
|
@@ -2625,7 +2625,7 @@
|
|
|
2625
2625
|
"occurrences": 2,
|
|
2626
2626
|
"at": [
|
|
2627
2627
|
"modules/hall-ui/public/goal-author.js:38",
|
|
2628
|
-
"modules/hall-ui/public/goal-author.js:
|
|
2628
|
+
"modules/hall-ui/public/goal-author.js:112"
|
|
2629
2629
|
]
|
|
2630
2630
|
},
|
|
2631
2631
|
{
|
|
@@ -3261,6 +3261,15 @@
|
|
|
3261
3261
|
"origin": "js-markup",
|
|
3262
3262
|
"confidence": "certain"
|
|
3263
3263
|
},
|
|
3264
|
+
{
|
|
3265
|
+
"id": "83beba462b20",
|
|
3266
|
+
"surface": "builders-hall",
|
|
3267
|
+
"text": "Add a task",
|
|
3268
|
+
"file": "modules/hall-ui/public/goal-author.js",
|
|
3269
|
+
"line": 38,
|
|
3270
|
+
"origin": "js-markup",
|
|
3271
|
+
"confidence": "certain"
|
|
3272
|
+
},
|
|
3264
3273
|
{
|
|
3265
3274
|
"id": "f4c5a03f715c",
|
|
3266
3275
|
"surface": "builders-hall",
|
|
@@ -3297,6 +3306,15 @@
|
|
|
3297
3306
|
"origin": "js-markup",
|
|
3298
3307
|
"confidence": "certain"
|
|
3299
3308
|
},
|
|
3309
|
+
{
|
|
3310
|
+
"id": "8b51e2e70cfb",
|
|
3311
|
+
"surface": "builders-hall",
|
|
3312
|
+
"text": "Add task",
|
|
3313
|
+
"file": "modules/hall-ui/public/goal-author.js",
|
|
3314
|
+
"line": 38,
|
|
3315
|
+
"origin": "js-markup",
|
|
3316
|
+
"confidence": "certain"
|
|
3317
|
+
},
|
|
3300
3318
|
{
|
|
3301
3319
|
"id": "82a8bd77d374",
|
|
3302
3320
|
"surface": "builders-hall",
|
|
@@ -7671,6 +7689,15 @@
|
|
|
7671
7689
|
"origin": "js-markup",
|
|
7672
7690
|
"confidence": "certain"
|
|
7673
7691
|
},
|
|
7692
|
+
{
|
|
7693
|
+
"id": "65977bbcda48",
|
|
7694
|
+
"surface": "builders-hall",
|
|
7695
|
+
"text": "If the goal still has a “Spec goal” task that is not done, this task waits behind it until the spec is finished.",
|
|
7696
|
+
"file": "modules/hall-ui/public/goal-author.js",
|
|
7697
|
+
"line": 38,
|
|
7698
|
+
"origin": "js-markup",
|
|
7699
|
+
"confidence": "certain"
|
|
7700
|
+
},
|
|
7674
7701
|
{
|
|
7675
7702
|
"id": "7d6a7de7a69e",
|
|
7676
7703
|
"surface": "builders-hall",
|
|
@@ -14192,7 +14219,7 @@
|
|
|
14192
14219
|
"surface": "builders-hall",
|
|
14193
14220
|
"text": "These open goals look related — use one instead?",
|
|
14194
14221
|
"file": "modules/hall-ui/public/goal-author.js",
|
|
14195
|
-
"line":
|
|
14222
|
+
"line": 144,
|
|
14196
14223
|
"origin": "js-markup",
|
|
14197
14224
|
"confidence": "certain"
|
|
14198
14225
|
},
|
|
@@ -19637,7 +19664,7 @@
|
|
|
19637
19664
|
"surface": "builders-hall",
|
|
19638
19665
|
"text": "— none —",
|
|
19639
19666
|
"file": "modules/hall-ui/public/goal-author.js",
|
|
19640
|
-
"line":
|
|
19667
|
+
"line": 128,
|
|
19641
19668
|
"origin": "js-markup",
|
|
19642
19669
|
"confidence": "certain"
|
|
19643
19670
|
},
|
|
@@ -19664,7 +19691,7 @@
|
|
|
19664
19691
|
"surface": "builders-hall",
|
|
19665
19692
|
"text": "— pick a goal —",
|
|
19666
19693
|
"file": "modules/hall-ui/public/goal-author.js",
|
|
19667
|
-
"line":
|
|
19694
|
+
"line": 112,
|
|
19668
19695
|
"origin": "js-markup",
|
|
19669
19696
|
"confidence": "certain"
|
|
19670
19697
|
},
|
|
@@ -19673,7 +19700,7 @@
|
|
|
19673
19700
|
"surface": "builders-hall",
|
|
19674
19701
|
"text": "— pick a version —",
|
|
19675
19702
|
"file": "modules/hall-ui/public/goal-author.js",
|
|
19676
|
-
"line":
|
|
19703
|
+
"line": 125,
|
|
19677
19704
|
"origin": "js-markup",
|
|
19678
19705
|
"confidence": "certain"
|
|
19679
19706
|
},
|
|
@@ -3105,5 +3105,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
3105
3105
|
landed since 1.21.132 with no explicit bump. run 38803950047. (task 1002620)
|
|
3106
3106
|
1.21.134 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3107
3107
|
landed since 1.21.133 with no explicit bump. run 38810062389. (task 1002620)
|
|
3108
|
+
1.21.135 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3109
|
+
landed since 1.21.134 with no explicit bump. run 38812449926. (task 1002620)
|
|
3110
|
+
1.21.136 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3111
|
+
landed since 1.21.135 with no explicit bump. run 38824385358. (task 1002620)
|
|
3108
3112
|
---------------------------------------------------------------------------
|
|
3109
3113
|
```
|
package/docs/page-readings.json
CHANGED
|
@@ -1201,7 +1201,7 @@
|
|
|
1201
1201
|
"surface": "builders",
|
|
1202
1202
|
"title": "Goals",
|
|
1203
1203
|
"states_read": ["list","filters","cards","detail","dependencies","out","uat","uat-panel","author"],
|
|
1204
|
-
"files_hash": "
|
|
1204
|
+
"files_hash": "3f07bbb487b850ec",
|
|
1205
1205
|
"reading_hash": "7aa1a6e1946dc077",
|
|
1206
1206
|
"counts": {"lines":156,"placed":52,"shared":8,"unplaced":96},
|
|
1207
1207
|
"omitted": {"data":268},
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- core_277 passkey foundation: credentials, per-builder user handle, challenge store
|
|
2
|
+
-- Applied under task 1004750 (ADR 0382 D1, D6).
|
|
3
|
+
--
|
|
4
|
+
-- WHY. A hardware card (a WebAuthn passkey) is a second sign-in step an Archon can
|
|
5
|
+
-- switch on per builder. Before any ceremony route can exist, the server needs three
|
|
6
|
+
-- things to keep: the public half of each registered card, an opaque handle that
|
|
7
|
+
-- stands in for the builder inside the card (never the email), and the challenges it
|
|
8
|
+
-- has issued and not yet seen answered.
|
|
9
|
+
--
|
|
10
|
+
-- SHAPE.
|
|
11
|
+
-- * passkey_credentials one row per registered card. credential_id is the
|
|
12
|
+
-- authenticator's own id (base64url) and is UNIQUE: a card can belong to one
|
|
13
|
+
-- builder. sign_count is kept so a counter that goes backwards can be caught.
|
|
14
|
+
-- * passkey_user_handles one random handle per builder, minted on first use.
|
|
15
|
+
-- * passkey_challenges short-lived, single-use. A row is DELETED when consumed;
|
|
16
|
+
-- expires_at lets a sweep remove the ones nobody answered. builder_id is nullable
|
|
17
|
+
-- because a sign-in ceremony starts before the builder is known.
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS passkey_credentials (
|
|
20
|
+
id bigserial PRIMARY KEY,
|
|
21
|
+
credential_id text NOT NULL UNIQUE,
|
|
22
|
+
builder_id bigint NOT NULL REFERENCES builders(id) ON DELETE CASCADE,
|
|
23
|
+
public_key bytea NOT NULL,
|
|
24
|
+
sign_count bigint NOT NULL DEFAULT 0,
|
|
25
|
+
transports text[] NOT NULL DEFAULT '{}',
|
|
26
|
+
aaguid text,
|
|
27
|
+
nickname text,
|
|
28
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
29
|
+
last_used_at timestamptz
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_passkey_credentials_builder
|
|
33
|
+
ON passkey_credentials (builder_id);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS passkey_user_handles (
|
|
36
|
+
builder_id bigint PRIMARY KEY REFERENCES builders(id) ON DELETE CASCADE,
|
|
37
|
+
user_handle text NOT NULL UNIQUE,
|
|
38
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
|
42
|
+
challenge text PRIMARY KEY,
|
|
43
|
+
purpose text NOT NULL CHECK (purpose IN ('register', 'authenticate')),
|
|
44
|
+
builder_id bigint REFERENCES builders(id) ON DELETE CASCADE,
|
|
45
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
46
|
+
expires_at timestamptz NOT NULL
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_passkey_challenges_expires
|
|
50
|
+
ON passkey_challenges (expires_at);
|
|
51
|
+
|
|
52
|
+
INSERT INTO schema_migrations (version) VALUES ('core_277_passkey_foundation')
|
|
53
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -89,6 +89,29 @@
|
|
|
89
89
|
return null;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// POST /tasks body (task 1003462). The goal and its version are the whole of
|
|
93
|
+
// what places a task; the server validates goal-belongs-to-version, the open
|
|
94
|
+
// goal, the rank floor and the kind/discipline inference, so none of those are
|
|
95
|
+
// sent or pre-checked here.
|
|
96
|
+
function buildTaskBody(f) {
|
|
97
|
+
const body = {
|
|
98
|
+
version_id: clean(f.version_id),
|
|
99
|
+
goal_id: Number(f.goal_id),
|
|
100
|
+
title: clean(f.title),
|
|
101
|
+
};
|
|
102
|
+
const description = clean(f.description);
|
|
103
|
+
if (description) body.description = description;
|
|
104
|
+
return body;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function taskFormProblem(f) {
|
|
108
|
+
if (!(Number(f.goal_id) > 0)) return 'Pick the goal this task belongs to.';
|
|
109
|
+
const t = clean(f.title);
|
|
110
|
+
if (!t) return 'Give the task a name.';
|
|
111
|
+
if (t.length > TITLE_MAX) return `Keep the name under ${TITLE_MAX} characters.`;
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
92
115
|
// The refusal, in words. The server's envelope is { error: { code, message } };
|
|
93
116
|
// an older route may send { error: 'code' } or { message }. A refusal the server
|
|
94
117
|
// phrased keeps its own sentence — this only supplies one where the server sent
|
|
@@ -99,6 +122,7 @@
|
|
|
99
122
|
criterion_exists: 'A criterion with that name already exists on this version. Reword the outcome slightly.',
|
|
100
123
|
reserved_criterion_slug: 'That name looks like a position (C1, C2…). Reword the outcome.',
|
|
101
124
|
bad_goal_id: 'That goal is not on this version.',
|
|
125
|
+
goal_id_required: 'Pick the goal this task belongs to.',
|
|
102
126
|
goal_not_yours: 'Only a goal’s owner or manager (or an Archon) can add criteria to it.',
|
|
103
127
|
version_not_found: 'That version no longer exists.',
|
|
104
128
|
};
|
|
@@ -124,6 +148,6 @@
|
|
|
124
148
|
|
|
125
149
|
window.OTBGoalAuthor = {
|
|
126
150
|
TITLE_MAX, criterionSlug, buildGoalBody, goalFormProblem,
|
|
127
|
-
buildCriterionRequest, criterionFormProblem, refusalText, openVersions,
|
|
151
|
+
buildCriterionRequest, criterionFormProblem, buildTaskBody, taskFormProblem, refusalText, openVersions,
|
|
128
152
|
};
|
|
129
153
|
})();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// panel (task 1003463, goal 1000089; ADR 0366 D1.2/D1.3 + D3). The hall could
|
|
3
3
|
// SATISFY a criterion and never AUTHOR one, and had no POST /goals call at all.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
5
|
+
// Three forms, nothing else (the third, a task against a goal, is task 1003462):
|
|
6
6
|
// · GOAL — a name, a one-line subtitle, an optional longer description, the
|
|
7
7
|
// version, an optional category. NO scope field: the goal is created with
|
|
8
8
|
// spec_task:true, so it arrives with a "Spec goal N" task that gates its other
|
|
@@ -75,6 +75,23 @@
|
|
|
75
75
|
<p class="goal-author__err" id="ga-crit-err" role="alert" hidden></p>
|
|
76
76
|
<button type="submit" class="btn btn--primary" id="ga-crit-go">Add criterion</button>
|
|
77
77
|
</form>
|
|
78
|
+
|
|
79
|
+
<h2 class="scroll__h goal-author__h2" id="ga-task-h">Add a task</h2>
|
|
80
|
+
<form id="ga-task" class="goal-author__form" novalidate aria-labelledby="ga-task-h">
|
|
81
|
+
<label class="goal-author__field">Goal
|
|
82
|
+
<select id="ga-task-goal" required><option value="">— pick a goal —</option></select>
|
|
83
|
+
</label>
|
|
84
|
+
<label class="goal-author__field">Name
|
|
85
|
+
<input id="ga-task-title" type="text" maxlength="${lib.TITLE_MAX}" autocomplete="off" required>
|
|
86
|
+
</label>
|
|
87
|
+
<label class="goal-author__field">Description <span class="goal-author__hint">optional</span>
|
|
88
|
+
<textarea id="ga-task-desc" rows="3"></textarea>
|
|
89
|
+
</label>
|
|
90
|
+
<p class="goal-author__note">If the goal still has a “Spec goal” task that is not done, this task waits behind it until the spec is finished.</p>
|
|
91
|
+
<p class="goal-author__note" id="ga-task-ok" role="status" aria-live="polite" hidden></p>
|
|
92
|
+
<p class="goal-author__err" id="ga-task-err" role="alert" hidden></p>
|
|
93
|
+
<button type="submit" class="btn btn--primary" id="ga-task-go">Add task</button>
|
|
94
|
+
</form>
|
|
78
95
|
</section>`;
|
|
79
96
|
|
|
80
97
|
const $q = (id) => root.querySelector(`#${id}`);
|
|
@@ -88,12 +105,14 @@
|
|
|
88
105
|
}
|
|
89
106
|
|
|
90
107
|
function fillGoalChoices() {
|
|
91
|
-
const sel = $q('ga-crit-goal');
|
|
92
|
-
const keep = sel.value;
|
|
93
108
|
const goals = (getGoals() || []).filter((g) => g && g.status === 'open');
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
109
|
+
for (const id of ['ga-crit-goal', 'ga-task-goal']) {
|
|
110
|
+
const sel = $q(id);
|
|
111
|
+
const keep = sel.value;
|
|
112
|
+
sel.innerHTML = '<option value="">— pick a goal —</option>' + goals.map((g) =>
|
|
113
|
+
`<option value="${escapeHtml(String(g.id))}" data-version="${escapeHtml(String(g.version_id || ''))}">${escapeHtml(`${g.version_id || ''} · ${g.title}`)}</option>`).join('');
|
|
114
|
+
sel.value = keep;
|
|
115
|
+
}
|
|
97
116
|
}
|
|
98
117
|
|
|
99
118
|
async function loadChoices() {
|
|
@@ -191,6 +210,36 @@
|
|
|
191
210
|
showErr('ga-crit-err', 'Could not reach the server. Try again in a moment.');
|
|
192
211
|
} finally { btn.disabled = false; }
|
|
193
212
|
});
|
|
213
|
+
|
|
214
|
+
// task 1003462 — a task against a goal. Sends what was typed to POST /tasks and
|
|
215
|
+
// lets the server's refusals (floor, goal/version, closed goal) speak.
|
|
216
|
+
$q('ga-task').addEventListener('submit', async (e) => {
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
showErr('ga-task-err', '');
|
|
219
|
+
const ok = $q('ga-task-ok');
|
|
220
|
+
ok.hidden = true;
|
|
221
|
+
const sel = $q('ga-task-goal');
|
|
222
|
+
const opt = sel.options[sel.selectedIndex];
|
|
223
|
+
const fields = {
|
|
224
|
+
goal_id: sel.value, version_id: opt ? opt.getAttribute('data-version') : '',
|
|
225
|
+
title: $q('ga-task-title').value, description: $q('ga-task-desc').value,
|
|
226
|
+
};
|
|
227
|
+
const problem = lib.taskFormProblem(fields);
|
|
228
|
+
if (problem) return showErr('ga-task-err', problem);
|
|
229
|
+
const btn = $q('ga-task-go');
|
|
230
|
+
btn.disabled = true;
|
|
231
|
+
try {
|
|
232
|
+
const res = await api.request('POST', `${API}/tasks`, { body: lib.buildTaskBody(fields) });
|
|
233
|
+
if (!res.ok) return showErr('ga-task-err', lib.refusalText(res.status, res.data));
|
|
234
|
+
const task = (res.data && res.data.task) || {};
|
|
235
|
+
$q('ga-task-title').value = '';
|
|
236
|
+
$q('ga-task-desc').value = '';
|
|
237
|
+
ok.textContent = task.id != null ? `Task ${task.id} added to the goal. It waits behind the goal’s spec task if that is not done yet.` : 'Task added.';
|
|
238
|
+
ok.hidden = false;
|
|
239
|
+
} catch (_) {
|
|
240
|
+
showErr('ga-task-err', 'Could not reach the server. Try again in a moment.');
|
|
241
|
+
} finally { btn.disabled = false; }
|
|
242
|
+
});
|
|
194
243
|
}
|
|
195
244
|
|
|
196
245
|
window.OTBGoalAuthorUI = { mount };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.136",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.136",
|
|
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.21.
|
|
3
|
+
"version": "1.21.136",
|
|
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
|
@@ -9806,5 +9806,17 @@
|
|
|
9806
9806
|
"id": "1004676",
|
|
9807
9807
|
"text": "Researched adding a tap-your-security-card check to Bongos. Plan written up and follow-up build work filed."
|
|
9808
9808
|
}
|
|
9809
|
+
],
|
|
9810
|
+
"1.21.135": [
|
|
9811
|
+
{
|
|
9812
|
+
"id": "1003462",
|
|
9813
|
+
"text": "Adds an Add-a-task form to the Goals page: pick an open goal, name the task, optional description. Sends POST /tasks unchanged; the server's rank floor, goal/version and closed-goal refusals render as sentences. States that a"
|
|
9814
|
+
}
|
|
9815
|
+
],
|
|
9816
|
+
"1.21.136": [
|
|
9817
|
+
{
|
|
9818
|
+
"id": "1004750",
|
|
9819
|
+
"text": "Groundwork for hardware-card sign-in: the database tables and storage code that remember registered cards and the short-lived one-time challenges. Nothing is visible to builders yet."
|
|
9820
|
+
}
|
|
9809
9821
|
]
|
|
9810
9822
|
}
|
|
@@ -232,6 +232,9 @@ const INTEGRATION = new Set([
|
|
|
232
232
|
// admission reads' statements run on Postgres, and the hub's notice restamp and
|
|
233
233
|
// listing. The DB-free sibling (application_lifecycle) interprets the same WHEREs.
|
|
234
234
|
'invite_expiry_db',
|
|
235
|
+
// task 1004750 (ADR 0382 D6): passkey tables from the real migration — single-use
|
|
236
|
+
// challenges, TTL sweep, unique credential id, sign-count regression.
|
|
237
|
+
'passkey_foundation_db',
|
|
235
238
|
// task 1004408: migration platform_identity_028 deletes the old hub invite's
|
|
236
239
|
// pending rows before a cutoff it reads from the core_upgrades ledger. Only a real
|
|
237
240
|
// engine runs the DO block, the numeric version compare and the one-shot guard.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// src/bongos/passkeys.js — passkey (WebAuthn) storage: credentials, the opaque user
|
|
2
|
+
// handle, and the single-use challenge store (task 1004750, ADR 0382 D1/D6).
|
|
3
|
+
//
|
|
4
|
+
// This file is the foundation only: it keeps and hands back state. The registration
|
|
5
|
+
// and sign-in ceremonies, and the routes that drive them, are their own tasks and
|
|
6
|
+
// build on these functions with @simplewebauthn/server.
|
|
7
|
+
//
|
|
8
|
+
// A challenge is single-use: consumeChallenge DELETEs the row and returns it, so a
|
|
9
|
+
// replay finds nothing. A challenge past its TTL is refused even before the sweep
|
|
10
|
+
// has removed it.
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const crypto = require('node:crypto');
|
|
14
|
+
|
|
15
|
+
const CHALLENGE_TTL_SECONDS = 300;
|
|
16
|
+
const MAX_CHALLENGE_TTL_SECONDS = 600;
|
|
17
|
+
const PURPOSES = Object.freeze(['register', 'authenticate']);
|
|
18
|
+
|
|
19
|
+
function defaultPool() { return require('./pool').pool; }
|
|
20
|
+
|
|
21
|
+
function randomToken(bytes) { return crypto.randomBytes(bytes).toString('base64url'); }
|
|
22
|
+
|
|
23
|
+
// A TTL is a positive whole number of seconds no longer than the ceiling, so a caller
|
|
24
|
+
// forwarding an untrusted value cannot mint a long-lived (or already-dead) challenge.
|
|
25
|
+
function clampTtlSeconds(ttl) {
|
|
26
|
+
const n = Math.floor(Number(ttl));
|
|
27
|
+
if (!Number.isFinite(n) || n <= 0) return CHALLENGE_TTL_SECONDS;
|
|
28
|
+
return Math.min(n, MAX_CHALLENGE_TTL_SECONDS);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The clone check. A counter that does not advance is suspect, unless the card
|
|
32
|
+
// does not keep one at all (stored and reported both zero).
|
|
33
|
+
function counterAdvances(stored, reported) {
|
|
34
|
+
if (stored === 0 && reported === 0) return true;
|
|
35
|
+
return reported > stored;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Mint and store a challenge. Returns { challenge, expiresAt }.
|
|
39
|
+
async function createChallenge({ purpose, builderId = null, ttlSeconds = CHALLENGE_TTL_SECONDS, pool = defaultPool() } = {}) {
|
|
40
|
+
if (!PURPOSES.includes(purpose)) throw new Error(`passkeys: bad challenge purpose ${JSON.stringify(purpose)}`);
|
|
41
|
+
const challenge = randomToken(32);
|
|
42
|
+
// Housekeeping rides on issuing: expired rows are removed here, so the table is
|
|
43
|
+
// swept by use and needs no separate scheduler.
|
|
44
|
+
await sweepExpiredChallenges({ pool });
|
|
45
|
+
const { rows } = await pool.query(
|
|
46
|
+
`INSERT INTO passkey_challenges (challenge, purpose, builder_id, expires_at)
|
|
47
|
+
VALUES ($1, $2, $3, now() + make_interval(secs => $4::int))
|
|
48
|
+
RETURNING expires_at`,
|
|
49
|
+
[challenge, purpose, builderId, String(clampTtlSeconds(ttlSeconds))],
|
|
50
|
+
);
|
|
51
|
+
return { challenge, expiresAt: rows[0].expires_at };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Take a challenge: delete it and return { purpose, builderId } if it was live, else
|
|
55
|
+
// null. Pass expectPurpose (and expectBuilderId for a signed-in registration) and a
|
|
56
|
+
// challenge issued for something else is refused. It is still spent: a wrong-purpose
|
|
57
|
+
// presentation burns it rather than leaving it to be tried again.
|
|
58
|
+
async function consumeChallenge(challenge, { expectPurpose = null, expectBuilderId = null, pool = defaultPool() } = {}) {
|
|
59
|
+
if (typeof challenge !== 'string' || !challenge) return null;
|
|
60
|
+
const { rows } = await pool.query(
|
|
61
|
+
`DELETE FROM passkey_challenges WHERE challenge = $1
|
|
62
|
+
RETURNING purpose, builder_id, (expires_at > now()) AS live`,
|
|
63
|
+
[challenge],
|
|
64
|
+
);
|
|
65
|
+
const r = rows[0];
|
|
66
|
+
if (!r || !r.live) return null;
|
|
67
|
+
if (expectPurpose != null && r.purpose !== expectPurpose) return null;
|
|
68
|
+
if (expectBuilderId != null && String(r.builder_id) !== String(expectBuilderId)) return null;
|
|
69
|
+
return { purpose: r.purpose, builderId: r.builder_id == null ? null : String(r.builder_id) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Remove every challenge past its TTL. Returns the number removed.
|
|
73
|
+
async function sweepExpiredChallenges({ pool = defaultPool() } = {}) {
|
|
74
|
+
const r = await pool.query('DELETE FROM passkey_challenges WHERE expires_at <= now()');
|
|
75
|
+
return r.rowCount;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The builder's opaque WebAuthn user handle, minted on first use. Never the email.
|
|
79
|
+
async function getOrCreateUserHandle(builderId, { pool = defaultPool() } = {}) {
|
|
80
|
+
const { rows } = await pool.query(
|
|
81
|
+
`INSERT INTO passkey_user_handles (builder_id, user_handle) VALUES ($1, $2)
|
|
82
|
+
ON CONFLICT (builder_id) DO UPDATE SET builder_id = EXCLUDED.builder_id
|
|
83
|
+
RETURNING user_handle`,
|
|
84
|
+
[builderId, randomToken(32)],
|
|
85
|
+
);
|
|
86
|
+
return rows[0].user_handle;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function addCredential({ builderId, credentialId, publicKey, signCount = 0, transports = [], aaguid = null, nickname = null }, { pool = defaultPool() } = {}) {
|
|
90
|
+
const { rows } = await pool.query(
|
|
91
|
+
`INSERT INTO passkey_credentials (credential_id, builder_id, public_key, sign_count, transports, aaguid, nickname)
|
|
92
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at`,
|
|
93
|
+
[credentialId, builderId, Buffer.from(publicKey), signCount, transports, aaguid, nickname],
|
|
94
|
+
);
|
|
95
|
+
return rows[0];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function getCredentialById(credentialId, { pool = defaultPool() } = {}) {
|
|
99
|
+
const { rows } = await pool.query(
|
|
100
|
+
`SELECT id, credential_id, builder_id, public_key, sign_count, transports, aaguid, nickname, created_at, last_used_at
|
|
101
|
+
FROM passkey_credentials WHERE credential_id = $1`,
|
|
102
|
+
[credentialId],
|
|
103
|
+
);
|
|
104
|
+
return rows[0] || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function listCredentials(builderId, { pool = defaultPool() } = {}) {
|
|
108
|
+
const { rows } = await pool.query(
|
|
109
|
+
`SELECT id, credential_id, transports, aaguid, nickname, created_at, last_used_at
|
|
110
|
+
FROM passkey_credentials WHERE builder_id = $1 ORDER BY created_at, id`,
|
|
111
|
+
[builderId],
|
|
112
|
+
);
|
|
113
|
+
return rows;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Record a successful use. One guarded UPDATE both checks and writes, so two
|
|
117
|
+
// concurrent assertions carrying the same counter cannot both pass and the stored
|
|
118
|
+
// counter can never be lowered. A counter that does not advance (unless the card keeps
|
|
119
|
+
// none) is a possible clone: nothing is written and the reason says why.
|
|
120
|
+
async function recordUse(credentialId, newSignCount, { pool = defaultPool() } = {}) {
|
|
121
|
+
const r = await pool.query(
|
|
122
|
+
`UPDATE passkey_credentials SET sign_count = $2, last_used_at = now()
|
|
123
|
+
WHERE credential_id = $1 AND (($2 = 0 AND sign_count = 0) OR $2 > sign_count)`,
|
|
124
|
+
[credentialId, newSignCount],
|
|
125
|
+
);
|
|
126
|
+
if (r.rowCount > 0) return { ok: true };
|
|
127
|
+
const cred = await getCredentialById(credentialId, { pool });
|
|
128
|
+
return { ok: false, reason: cred ? 'sign_count_regressed' : 'unknown_credential' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function removeCredential(builderId, credentialId, { pool = defaultPool() } = {}) {
|
|
132
|
+
const r = await pool.query(
|
|
133
|
+
'DELETE FROM passkey_credentials WHERE builder_id = $1 AND credential_id = $2',
|
|
134
|
+
[builderId, credentialId],
|
|
135
|
+
);
|
|
136
|
+
return r.rowCount > 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
CHALLENGE_TTL_SECONDS, MAX_CHALLENGE_TTL_SECONDS, PURPOSES, clampTtlSeconds, counterAdvances,
|
|
141
|
+
createChallenge, consumeChallenge, sweepExpiredChallenges,
|
|
142
|
+
getOrCreateUserHandle, addCredential, getCredentialById, listCredentials, recordUse, removeCredential,
|
|
143
|
+
};
|
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.21.
|
|
78
|
+
const CORE_VERSION = '1.21.136'; // 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');
|
package/tests/goal_author_ui.mjs
CHANGED
|
@@ -208,3 +208,30 @@ test('wall: both routes still sit behind their permission atoms', () => {
|
|
|
208
208
|
assert.match(g, /router\.post\('\/goals', auth\.requireBuilder, auth\.requirePermission\('goal\.create'\)/);
|
|
209
209
|
assert.match(d, /router\.post\('\/versions\/:id\/done-when', auth\.requireBuilder, auth\.requirePermission\('criterion\.create'\)/);
|
|
210
210
|
});
|
|
211
|
+
|
|
212
|
+
// ── 4. creating a task against a goal (task 1003462) ─────────────────────────
|
|
213
|
+
|
|
214
|
+
test('the task body names the goal and its version and nothing the server owns', () => {
|
|
215
|
+
const body = lib.buildTaskBody({
|
|
216
|
+
version_id: ' V9 ', goal_id: '12', title: ' Write it down ', description: ' more ',
|
|
217
|
+
status: 'ready', requires_rank: 'xenos', credits_reward: 5, created_by: 'x',
|
|
218
|
+
});
|
|
219
|
+
assert.deepEqual(plain(body), { version_id: 'V9', goal_id: 12, title: 'Write it down', description: 'more' });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('an empty description is omitted; the form asks only for goal and name', () => {
|
|
223
|
+
assert.deepEqual(plain(lib.buildTaskBody({ version_id: 'V9', goal_id: 3, title: 'x', description: ' ' })), { version_id: 'V9', goal_id: 3, title: 'x' });
|
|
224
|
+
assert.match(lib.taskFormProblem({ goal_id: '', title: 'x' }), /goal/i);
|
|
225
|
+
assert.match(lib.taskFormProblem({ goal_id: '3', title: ' ' }), /name/i);
|
|
226
|
+
assert.match(lib.taskFormProblem({ goal_id: '3', title: 'x'.repeat(lib.TITLE_MAX + 1) }), /under/);
|
|
227
|
+
assert.equal(lib.taskFormProblem({ goal_id: '3', title: 'x' }), null);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('the task form is wired, reuses the suggester, and states the spec-task wait', () => {
|
|
231
|
+
const src = read('goal-author.js');
|
|
232
|
+
assert.match(src, /ga-task/);
|
|
233
|
+
assert.match(src, /buildTaskBody/);
|
|
234
|
+
assert.match(src, /\$\{API\}\/tasks`/);
|
|
235
|
+
assert.match(src, /waits behind/i);
|
|
236
|
+
assert.ok(!/requires_rank|task\.create/.test(src), 'no client-side authority pre-filter');
|
|
237
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// tests/passkey_foundation_db.mjs — task 1004750 (ADR 0382 D1, D6). Real DB.
|
|
2
|
+
// The passkey tables built from the real migration in a throwaway schema, and the
|
|
3
|
+
// store's rules: single-use challenges, TTL, sweep, unique credential ids, sign-count.
|
|
4
|
+
// Self-skips without a reachable Postgres.
|
|
5
|
+
// Run: DATABASE_URL=... node tests/passkey_foundation_db.mjs
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const { Pool } = require('pg');
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
16
|
+
let client;
|
|
17
|
+
try { client = await pgPool.connect(); } catch (e) {
|
|
18
|
+
console.log(`passkey_foundation_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
19
|
+
await pgPool.end().catch(() => {});
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
const SCHEMA = `t1004750_${process.pid}`;
|
|
23
|
+
let pass = 0;
|
|
24
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
28
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
29
|
+
await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY)');
|
|
30
|
+
await client.query('CREATE TABLE schema_migrations (version text PRIMARY KEY)');
|
|
31
|
+
await client.query(readFileSync(join(here, '..', 'migrations', 'core_277_passkey_foundation.sql'), 'utf8'));
|
|
32
|
+
await client.query('INSERT INTO builders DEFAULT VALUES');
|
|
33
|
+
await client.query('INSERT INTO builders DEFAULT VALUES');
|
|
34
|
+
|
|
35
|
+
const pk = require('../src/bongos/passkeys.js');
|
|
36
|
+
const o = { pool: client };
|
|
37
|
+
|
|
38
|
+
const c = await pk.createChallenge({ purpose: 'register', builderId: 1, ...o });
|
|
39
|
+
ok(typeof c.challenge === 'string' && c.challenge.length >= 40, 'challenge is long random base64url');
|
|
40
|
+
const got = await pk.consumeChallenge(c.challenge, o);
|
|
41
|
+
ok(got && got.purpose === 'register' && got.builderId === '1', 'a live challenge is consumed once');
|
|
42
|
+
ok((await pk.consumeChallenge(c.challenge, o)) === null, 'a second consume (replay) finds nothing');
|
|
43
|
+
|
|
44
|
+
const expire = (ch) => client.query("UPDATE passkey_challenges SET expires_at = now() - interval '1 second' WHERE challenge = $1", [ch]);
|
|
45
|
+
const old = await pk.createChallenge({ purpose: 'authenticate', ...o });
|
|
46
|
+
await expire(old.challenge);
|
|
47
|
+
ok((await pk.consumeChallenge(old.challenge, o)) === null, 'an expired challenge is refused');
|
|
48
|
+
const stale = await pk.createChallenge({ purpose: 'authenticate', ...o });
|
|
49
|
+
await expire(stale.challenge);
|
|
50
|
+
const live = await pk.createChallenge({ purpose: 'authenticate', ...o });
|
|
51
|
+
ok((await client.query('SELECT 1 FROM passkey_challenges WHERE challenge = $1', [stale.challenge])).rowCount === 0, 'issuing a challenge sweeps expired rows');
|
|
52
|
+
const stale2 = await pk.createChallenge({ purpose: 'authenticate', ...o });
|
|
53
|
+
await expire(stale2.challenge);
|
|
54
|
+
ok((await pk.sweepExpiredChallenges(o)) === 1, 'sweep removes only expired challenges');
|
|
55
|
+
ok((await pk.consumeChallenge(live.challenge, o)) !== null, 'sweep left the live one');
|
|
56
|
+
await assert.rejects(pk.createChallenge({ purpose: 'nope', ...o }));
|
|
57
|
+
pass++; console.log(' ok - unknown purpose rejected');
|
|
58
|
+
await assert.rejects(client.query("INSERT INTO passkey_challenges (challenge, purpose, expires_at) VALUES ('x','bogus', now())"));
|
|
59
|
+
pass++; console.log(' ok - the table itself refuses an unknown purpose');
|
|
60
|
+
|
|
61
|
+
const wrong = await pk.createChallenge({ purpose: 'register', builderId: 1, ...o });
|
|
62
|
+
ok((await pk.consumeChallenge(wrong.challenge, { ...o, expectPurpose: 'authenticate' })) === null, 'a challenge for another purpose is refused');
|
|
63
|
+
ok((await pk.consumeChallenge(wrong.challenge, o)) === null, 'and it is spent by the wrong presentation');
|
|
64
|
+
const b1 = await pk.createChallenge({ purpose: 'register', builderId: 1, ...o });
|
|
65
|
+
ok((await pk.consumeChallenge(b1.challenge, { ...o, expectBuilderId: 2 })) === null, 'a challenge for another builder is refused');
|
|
66
|
+
const b2 = await pk.createChallenge({ purpose: 'register', builderId: 1, ...o });
|
|
67
|
+
ok((await pk.consumeChallenge(b2.challenge, { ...o, expectPurpose: 'register', expectBuilderId: 1 })) !== null, 'matching purpose and builder pass');
|
|
68
|
+
|
|
69
|
+
const h1 = await pk.getOrCreateUserHandle(1, o);
|
|
70
|
+
ok(h1 === await pk.getOrCreateUserHandle(1, o) && h1 !== await pk.getOrCreateUserHandle(2, o), 'user handle is stable per builder and distinct');
|
|
71
|
+
|
|
72
|
+
await pk.addCredential({ builderId: 1, credentialId: 'cred-a', publicKey: Buffer.from([1, 2, 3]), transports: ['usb'], nickname: 'card' }, o);
|
|
73
|
+
await assert.rejects(pk.addCredential({ builderId: 2, credentialId: 'cred-a', publicKey: Buffer.from([9]) }, o));
|
|
74
|
+
pass++; console.log(' ok - a credential id belongs to one builder');
|
|
75
|
+
ok((await pk.listCredentials(1, o)).length === 1 && (await pk.listCredentials(2, o)).length === 0, 'credentials listed per builder');
|
|
76
|
+
|
|
77
|
+
ok((await pk.recordUse('cred-a', 5, o)).ok, 'sign count advances');
|
|
78
|
+
ok((await pk.recordUse('cred-a', 5, o)).reason === 'sign_count_regressed', 'a non-advancing counter is refused');
|
|
79
|
+
ok((await pk.recordUse('cred-a', 3, o)).reason === 'sign_count_regressed' && Number((await pk.getCredentialById('cred-a', o)).sign_count) === 5, 'a lower counter never overwrites the stored one');
|
|
80
|
+
ok((await pk.recordUse('nope', 1, o)).reason === 'unknown_credential', 'unknown credential reported');
|
|
81
|
+
ok((await pk.getCredentialById('cred-a', o)).last_used_at != null, 'last_used_at is stamped');
|
|
82
|
+
ok(!(await pk.removeCredential(2, 'cred-a', o)), 'another builder cannot remove it');
|
|
83
|
+
ok(await pk.removeCredential(1, 'cred-a', o), 'owner removes it');
|
|
84
|
+
console.log(`passkey_foundation_db.mjs: ${pass} passed`);
|
|
85
|
+
} finally {
|
|
86
|
+
await client.query('SET search_path TO public').catch(() => {});
|
|
87
|
+
await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
88
|
+
client.release();
|
|
89
|
+
await pgPool.end();
|
|
90
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// tests/passkey_pure.mjs — task 1004750 (ADR 0382 D6). DB-free: the pure rules of
|
|
2
|
+
// src/bongos/passkeys.js that the required unit lane can run. The real-SQL half is
|
|
3
|
+
// tests/passkey_foundation_db.mjs.
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { strict as assert } from 'node:assert';
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const pk = require('../src/bongos/passkeys.js');
|
|
8
|
+
|
|
9
|
+
let pass = 0;
|
|
10
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
11
|
+
|
|
12
|
+
ok(pk.PURPOSES.includes('register') && pk.PURPOSES.includes('authenticate') && pk.PURPOSES.length === 2, 'two ceremony purposes');
|
|
13
|
+
ok(pk.clampTtlSeconds(60) === 60, 'a sane TTL passes through');
|
|
14
|
+
ok(pk.clampTtlSeconds(-5) === pk.CHALLENGE_TTL_SECONDS, 'a negative TTL falls back to the default');
|
|
15
|
+
ok(pk.clampTtlSeconds('abc') === pk.CHALLENGE_TTL_SECONDS, 'a non-number falls back to the default');
|
|
16
|
+
ok(pk.clampTtlSeconds(86400) === pk.MAX_CHALLENGE_TTL_SECONDS, 'a huge TTL is capped');
|
|
17
|
+
ok(pk.counterAdvances(0, 0), 'a card with no counter is accepted');
|
|
18
|
+
ok(pk.counterAdvances(5, 6), 'an advancing counter is accepted');
|
|
19
|
+
ok(!pk.counterAdvances(5, 5), 'a repeated counter is refused');
|
|
20
|
+
ok(!pk.counterAdvances(5, 3), 'a lower counter is refused');
|
|
21
|
+
ok(!pk.counterAdvances(5, 0), 'a counter reset to zero is refused');
|
|
22
|
+
await assert.rejects(pk.createChallenge({ purpose: 'bogus', pool: { query: async () => { throw new Error('should not query'); } } }));
|
|
23
|
+
pass++; console.log(' ok - an unknown purpose is rejected before any query');
|
|
24
|
+
console.log(`passkey_pure.mjs: ${pass} passed`);
|