vtc-service 0.12.0

Service for Verifiable Trust Communities
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
pub(crate) mod acl;
pub(crate) mod admin;
#[cfg(feature = "admin-ui")]
mod admin_ui;
pub(crate) mod audit;
pub mod auth;
pub(crate) mod backup;
pub(crate) mod ceremonies;
pub(crate) mod community;
pub(crate) mod credential_exchange;
pub(crate) mod did_log;
pub(crate) mod endorsement_types;
pub(crate) mod endorsements;
pub(crate) mod health;
pub(crate) mod install;
pub(crate) mod invitations;
pub mod join_requests;
pub(crate) mod member_portal;
pub(crate) mod members;
pub(crate) mod oid4vp;
pub(crate) mod policies;
pub mod recognise;
pub(crate) mod recognition_admin;
pub(crate) mod registry_admin;
pub(crate) mod relationships;
pub(crate) mod rooms;
pub(crate) mod schemas;
pub(crate) mod status_lists;
pub mod trust_tasks;
pub(crate) mod vetting;
/// Hidden-vetter admission: the operator's publish route (development branch `zkp-pcs`).
#[cfg(feature = "vetting-pcs")]
pub mod vetting_hidden;
#[cfg(feature = "website")]
pub(crate) mod website;

use std::sync::Arc;

use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{any, delete, get, post};
use ipnetwork::IpNetwork;
use tower_governor::GovernorLayer;
use tower_governor::governor::GovernorConfigBuilder;

use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use vti_common::rate_limit::TrustedProxyKeyExtractor;
use vti_common::trust_task::{TrustTask, task_routes};

use crate::config::RoutingConfig;
use crate::server::AppState;

/// OpenAPI document root for the VTC REST surface.
///
/// As in the VTA, the router is the single source of truth for *paths*: each
/// handler annotated with `#[utoipa::path]` and registered via
/// `routes!()` — wrapped in [`task_routes`] so the per-route Trust-Task header
/// validation is preserved — contributes its operation to the served
/// `/openapi.json`. This struct only seeds document metadata + the security
/// scheme.
#[derive(OpenApi)]
#[openapi(
    info(
        title = "Verifiable Trust Community (VTC) API",
        description = "Community lifecycle, ACL, audit, policy, credentials, \
                       endorsements, and cross-community recognition REST surface \
                       of a Verifiable Trust Community.",
        version = env!("CARGO_PKG_VERSION"),
    ),
    modifiers(&SecurityAddon),
    // Schemas reachable *only* from a query parameter. utoipa collects
    // schemas transitively from `request_body` and `body =`, but not through
    // an `IntoParams` field, so a `params(...)`-only type is referenced by the
    // document and absent from its components — a dangling `$ref`, which
    // `no_dangling_refs` below fails on and `openapi-typescript` refuses to
    // generate from at all.
    //
    // And the `git-ns/*` administrator reads' response bodies, which no route
    // returns — they are Trust Task responses on the document endpoint
    // (`git_ns::admin_reads`) — but which the admin console's wire types are
    // generated from, so no shape is written twice.
    //
    // Likewise the admin verbs with no route at all — signed documents only —
    // whose shapes the console's signed calls are typed by.
    components(schemas(
        policies::read::PolicyStatusFilter,
        crate::git_ns::admin_reads::GitNsNamespaceList,
        crate::git_ns::admin_reads::GitNsRepoList,
        crate::git_ns::admin_reads::GitNsRightList,
        crate::git_ns::admin_reads::GitNsDepartedGrants,
        crate::git_ns::admin_reads::GitNsJobList,
        crate::git_ns::admin_reads::GitNsProjection,
        crate::git_ns::admin_reads::GitNsAccountList,
        crate::git_ns::admin_reads::GitNsActivity,
        // A repository's drift, as `git-ns/view/0.5`'s `repos[].sync` carries it.
        vta_sdk::openapi::GitNsView01DriftItem,
        acl::AclListResponse,
        acl::AclEntryEnvelope,
        vta_sdk::openapi::VetterList01Payload,
        vta_sdk::openapi::VetterList01Response,
        endorsement_types::RegisterBody,
        endorsement_types::RegisterResponse,
        vta_sdk::openapi::EndorsementTypeDelete01Response,
        // The operational verbs the console signs (`trust_tasks::admin_tasks`).
        health::DiagnosticsResponse,
        vta_sdk::openapi::RegistrySyncJobsList01Response,
        vta_sdk::openapi::RegistrySyncJobsRetry02Response,
        vta_sdk::openapi::RegistrySyncJobsDiscard01Response,
        vta_sdk::openapi::RegistryRecordsList01Response,
        // The room-hosting administrator's reads and limit override
        // (`trust_tasks::room_admin_tasks`), signed documents only.
        vta_sdk::openapi::RoomsGet01Response,
        vta_sdk::openapi::RoomsUsage01Payload,
        vta_sdk::openapi::RoomsUsage01Response,
        vta_sdk::openapi::RoomsLimitsSet01Payload,
        vta_sdk::openapi::RoomsLimitsSet01Response,
        // Rooms the community itself owns (`trust_tasks::community_room_tasks`),
        // signed documents only.
        vta_sdk::openapi::RoomsCommunityCreate01Payload,
        vta_sdk::openapi::RoomsCommunityCreate01Response,
        vta_sdk::openapi::RoomsCommunityList01Response,
        vta_sdk::openapi::RoomsCommunityGet01Response,
        // 0.2 — each room's description, tags and who last changed them — and the
        // update that changes them.
        vta_sdk::openapi::RoomsCommunityCreate02Payload,
        vta_sdk::openapi::RoomsCommunityCreate02Response,
        vta_sdk::openapi::RoomsCommunityList02Payload,
        vta_sdk::openapi::RoomsCommunityList02Response,
        vta_sdk::openapi::RoomsCommunityGet02Response,
        vta_sdk::openapi::RoomsCommunityUpdate01Payload,
        vta_sdk::openapi::RoomsCommunityUpdate01Response,
        // 0.3 / 0.2: the label and description also written into the room as its profile.
        vta_sdk::openapi::RoomsCommunityCreate03Payload,
        vta_sdk::openapi::RoomsCommunityCreate03Response,
        vta_sdk::openapi::RoomsCommunityUpdate02Payload,
        vta_sdk::openapi::RoomsCommunityUpdate02Response,
        vta_sdk::openapi::RoomsCommunityMembersAdd01Payload,
        vta_sdk::openapi::RoomsCommunityMembersAdd01Response,
        vta_sdk::openapi::RoomsCommunityMembersRemove01Payload,
        vta_sdk::openapi::RoomsCommunityMembersRemove01Response,
        vta_sdk::openapi::RoomsCommunityMembersRemove02Payload,
        vta_sdk::openapi::RoomsCommunityMembersRemove02Response,
        // The member directory the console administers (`trust_tasks::directory_tasks`).
        vta_sdk::openapi::DirectoryFieldsList01Payload,
        vta_sdk::openapi::DirectoryFieldsList01Response,
        vta_sdk::openapi::DirectoryFieldsDefine01Payload,
        vta_sdk::openapi::DirectoryFieldsDefine01Response,
        vta_sdk::openapi::DirectoryFieldsUpdate01Payload,
        vta_sdk::openapi::DirectoryFieldsUpdate01Response,
        vta_sdk::openapi::DirectoryFieldsRetire01Payload,
        vta_sdk::openapi::DirectoryFieldsRetire01Response,
        vta_sdk::openapi::DirectoryCardAssert01Payload,
        vta_sdk::openapi::DirectoryCardAssert01Response,
        vta_sdk::openapi::DirectoryEntriesList01Payload,
        vta_sdk::openapi::DirectoryEntriesList01Response,
        vta_sdk::openapi::DirectoryEntriesShow01Payload,
        vta_sdk::openapi::DirectoryEntriesShow01Response,
        vta_sdk::openapi::RoomsCommunityMembersList01Payload,
        vta_sdk::openapi::RoomsCommunityMembersList01Response,
        // Where rooms' files are kept (`trust_tasks::storage_tasks`), signed
        // documents only.
        vta_sdk::openapi::RoomsStorageAssign01Payload,
        vta_sdk::openapi::RoomsStorageAssign01Response,
        vta_sdk::openapi::StorageConfigsList01Response,
        vta_sdk::openapi::StorageConfigsGet01Response,
        vta_sdk::openapi::StorageConfigsCreate01Payload,
        vta_sdk::openapi::StorageConfigsCreate01Response,
        vta_sdk::openapi::StorageConfigsUpdate01Payload,
        vta_sdk::openapi::StorageConfigsUpdate01Response,
        vta_sdk::openapi::StorageConfigsRetire01Response,
        vta_sdk::openapi::StorageConfigsProbe01Response,
        audit::AuditListResponse,
        crate::config_store::EffectiveConfig,
        admin::config::PatchResponse,
        admin::config::ReloadResponse,
        admin::config::RestartResponse,
        admin::invites::ListInvitesResponse,
        admin::invites::CreateInviteRequest,
        admin::invites::CreateInviteResponse,
        admin::invites::RevokeInviteResponse,
        auth::SessionListResponse,
        auth::RevokeSessionResponse,
        // The console's bearer → cookie bridge and an administrator's own
        // passkeys (`trust_tasks::{admin_session_tasks,admin_passkey_tasks}`),
        // signed documents only since #1641's last batch.
        auth::AdminSessionResponse,
        auth::WhoamiResponse,
        auth::SessionView,
        admin::passkeys::ListResponse,
        admin::passkeys::RegisteredCredential,
        admin::passkeys::RegisterStartResponse,
        admin::passkeys::RegisterFinishResponse,
        admin::passkeys::RevokeStartResponse,
        admin::passkeys::RevokeFinishResponse,
        // The community verbs the console signs (`trust_tasks::community_tasks`).
        community::profile::CommunityProfileResponse,
        ceremonies::CeremonyListResponse,
        vti_common::pagination::Paginated<crate::endorsement_types::EndorsementType>,
        recognition_admin::RecognitionCheck,
        members::read::RemovedMembersResponse,
        members::read::MemberEnvelope,
        members::request_vmc::RequestVmcBody,
        members::request_vmc::RequestVmcResponse,
        join_requests::read::JoinRequestEnvelope,
        relationships::RelationshipsGraph,
        invitations::IssueInvitationBody,
        invitations::IssueInvitationResponse,
        invitations::InvitationListResponse,
        invitations::RevokeResponse,
        vta_sdk::openapi::InvitationDeliver01Payload,
        vta_sdk::openapi::InvitationDeliver01Response,
        // The verbs whose shapes are documented for codegen though every
        // bearer route is gone: the roster and join queue, a member's
        // credentials, the join decision, a vetter grant, and the policy log.
        vti_common::pagination::Paginated<members::read::MemberResponse>,
        vti_common::pagination::Paginated<crate::join::JoinRequest>,
        vta_sdk::openapi::MemberCredentials01Response,
        join_requests::decide::DecideResponse,
        vta_sdk::openapi::VetterGrant01Response,
        policies::read::PolicyListResponse,
        policies::read::PolicyModuleResponse,
        policies::admin::UploadResponse,
        crate::policy::PolicyPurpose,
        // The administration surfaces the console signs (`trust_tasks::surface_tasks`).
        join_requests::read::JoinRequestVettingResponse,
        join_requests::read::JoinRequestVetting,
        join_requests::read::JoinRequestVettingStatement,
        // The join criteria, as the console registers and lists them
        // (`vtc/schemas/accepts/{register,list}/0.2`) and as applicants read
        // them (`vtc/join-requests/manifest/0.3`).
        vta_sdk::openapi::AcceptsRegister02Payload,
        vta_sdk::openapi::AcceptsList02Response,
        vta_sdk::openapi::JoinManifest03Response,
        rooms::HostedRoom,
        // The custom-endorsement reads/revoke the console signs
        // (`vtc/endorsements/{list,show,revoke}/0.1`) — bearer REST routes for
        // all three had no caller once the spine dispatched them (tt-tf#689)
        // and were removed.
        endorsements::EndorsementRow,
        vti_common::pagination::Paginated<endorsements::EndorsementRow>,
        endorsements::EndorsementEnvelope,
        endorsements::RevokeResponse,
        // The admin resend-on-behalf the console signs
        // (`vtc/vetting/vetters/resend/0.2`) — its admin-only bearer REST
        // route had no caller once the spine dispatched it (tt-tf#689) and
        // was removed. `0.1`'s response shape covers `0.2`'s too: neither
        // adds a member.
        vta_sdk::openapi::VetterResend01Response,
        // The website admin verbs the console signs
        // (`vtc/website/{files/list,files/delete,generations/list,rollback}/0.1`,
        // `trust_tasks::website_tasks`) — every bearer REST route for these had
        // no caller once vtc-client/cnm-cli/admin-ui moved onto the signed door.
        website::files::ListResponse,
        website::files::FileEntry,
        website::files::DeleteResponse,
        website::generations::GenerationsResponse,
        website::generations::GenerationRow,
        website::generations::RollbackResponse,
        // The vetting admin reads the console signs
        // (`vtc/vetting/vetters/grants/list/0.1`, `vtc/vetting/auto-grant/
        // {show,update}/0.1`, `vtc/vetting/revocations/list/0.1`,
        // `trust_tasks::surface_tasks`) — their admin-only bearer REST routes
        // had no caller left once `vtc-client` and the admin console signed
        // them instead, so nothing else forces these shapes into the spec any
        // more; the admin-ui's generated wire types still need them.
        vta_sdk::protocols::vetting::VetterGrantRow,
        vta_sdk::protocols::vetting::VetterProfileSummary,
        vta_sdk::protocols::vetting::GrantOrigin,
        vta_sdk::protocols::vetting::AutoGrantStatus,
        vta_sdk::protocols::vetting::AutoGrantConfig,
        vta_sdk::protocols::vetting::AutoGrantSweep,
        crate::routes::vetting::VettingRevocationRow,
        crate::routes::vetting::RevocationReviewState,
    )),
)]
pub struct ApiDoc;

/// Registers the `bearer_jwt` HTTP-bearer security scheme referenced by
/// authenticated operations' `security(("bearer_jwt" = []))`.
struct SecurityAddon;

impl utoipa::Modify for SecurityAddon {
    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
        use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
        let components = openapi.components.get_or_insert_with(Default::default);
        components.add_security_scheme(
            "bearer_jwt",
            SecurityScheme::Http(
                HttpBuilder::new()
                    .scheme(HttpAuthScheme::Bearer)
                    .bearer_format("JWT")
                    .build(),
            ),
        );
    }
}

/// Serve the assembled OpenAPI document as JSON at `GET /openapi.json`.
/// Unauthenticated by design — it describes the API shape, not any secret.
async fn serve_openapi(api: utoipa::openapi::OpenApi) -> axum::Json<utoipa::openapi::OpenApi> {
    axum::Json(api)
}

/// The assembled OpenAPI document describing the VTC REST surface.
///
/// Built from the same [`build_api_chain`] assembly that wires the live
/// routes — every handler registered via [`task_routes`]`(routes!(...))`
/// contributes its operation — so the document cannot drift from what the
/// service serves. The API surface is nested under the `/v1` mount exactly as
/// [`assemble`] mounts the live router; `OpenApiRouter::nest` composes the
/// documented paths the same way. Served at `GET /openapi.json`.
///
pub fn openapi_spec() -> utoipa::openapi::OpenApi {
    OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
        .nest("/v1", build_api_chain(&RoutingConfig::default(), &[]))
        .split_for_parts()
        .1
}

/// Global API surface body cap (Phase 5 M5.1.4 — §14.4 runtime
/// guard). Matches the VTA's `MAX_BODY_SIZE`. Website management
/// routes (M5.5) override per-route with larger caps via
/// `DefaultBodyLimit::disable() + RequestBodyLimitLayer::new(...)`.
pub const MAX_BODY_SIZE: usize = 1024 * 1024;

/// Tighter body cap for unauthenticated routes. Aligned with
/// `vta-service`'s `UNAUTH_BODY_SIZE` — generous enough for a
/// JWE / sealed-transfer envelope but small enough to reject 1 MB
/// blob floods that the rate limiter alone cannot starve out.
pub const UNAUTH_BODY_SIZE: usize = 64 * 1024;

/// Attach the static Trust-Task URL gate to a `routes!(...)` group in one call.
///
/// Collapses the former two-step `let <name> = TrustTask::new(<url>).expect(...)`
/// declaration + `task_routes(routes!(handler), <name>)` usage into a single
/// `tt(routes!(handler), <url>)`, so each mount reads as "handler(s) → their
/// Trust-Task URL" on one line and the URL lives at the route, not in a separate
/// block at the top of the builder.
fn tt(
    routes: utoipa_axum::router::UtoipaMethodRouter<AppState>,
    url: &'static str,
) -> utoipa_axum::router::UtoipaMethodRouter<AppState> {
    task_routes(routes, TrustTask::new(url).expect("static Trust-Task URL"))
}

/// Build the public router with default routing (path mode, `/v1`
/// API mount, `/admin` UX placeholder, `/` website fallback).
///
/// Convenience wrapper around [`router_with`] for integration-test
/// fixtures and any caller that doesn't carry a [`RoutingConfig`].
/// Production startup goes through [`router_with`] from `server.rs`
/// so operator-supplied mount overrides take effect.
pub fn router() -> Router<AppState> {
    #[cfg(feature = "website")]
    {
        router_with(&RoutingConfig::default(), None)
    }
    #[cfg(not(feature = "website"))]
    {
        router_with(&RoutingConfig::default())
    }
}

/// Build the public router with operator-supplied routing config
/// (Phase 5 M5.1.1). Three logical surfaces under one
/// [`axum::Router`]:
///
/// - **API** (`routing.api.mount`, default `/v1`): the existing
///   [`TrustTaskRouter`]-built handler set. Every mutating + read
///   handler the daemon ships lives here. Phase 5 keeps handler
///   attach order identical to Phase 0–4; only the prefix moves
///   from inline `/v1/...` literals to a single `nest` boundary.
/// - **Admin UX** (`routing.admin_ui.mount`, default `/admin`):
///   placeholder router that returns 503 until M5.7 lands the
///   baked SPA. The mount is reserved so cookie-scope isolation
///   (§9.3) doesn't have to wait for the SPA to exist.
/// - **Website** (`routing.website.mount`, default `/`):
///   placeholder fallback that returns 503 until M5.4 lands the
///   filesystem-backed static handler. When the website mount is
///   `/`, attached as a catch-all fallback; otherwise nested.
///
/// `/health` is the **single** Trust-Task-exempt endpoint — kept
/// at the parent-router root (above every nest boundary) so
/// monitoring integration stays trivial regardless of routing
/// mode.
#[cfg(feature = "website")]
pub fn router_with(
    routing: &RoutingConfig,
    website_state: Option<crate::website::WebsiteState>,
) -> Router<AppState> {
    router_with_inner(routing, website_state, &[])
}

#[cfg(feature = "website")]
pub fn router_with_xff(
    routing: &RoutingConfig,
    website_state: Option<crate::website::WebsiteState>,
    trust_xff_cidrs: &[IpNetwork],
) -> Router<AppState> {
    router_with_inner(routing, website_state, trust_xff_cidrs)
}

#[cfg(not(feature = "website"))]
pub fn router_with(routing: &RoutingConfig) -> Router<AppState> {
    router_with_inner(routing, &[])
}

#[cfg(not(feature = "website"))]
pub fn router_with_xff(routing: &RoutingConfig, trust_xff_cidrs: &[IpNetwork]) -> Router<AppState> {
    router_with_inner(routing, trust_xff_cidrs)
}

#[cfg(not(feature = "website"))]
fn router_with_inner(routing: &RoutingConfig, trust_xff_cidrs: &[IpNetwork]) -> Router<AppState> {
    // `build_api_chain` returns an `OpenApiRouter` (the single source of truth
    // for both routes and `/openapi.json`); split off the served axum `Router`
    // for `assemble` to nest. The OpenAPI document is rebuilt from the same
    // assembly by [`openapi_spec`] (which `assemble` serves), so the two cannot
    // drift.
    let api_chain = build_api_chain(routing, trust_xff_cidrs)
        .split_for_parts()
        .0;
    with_csrf(assemble(routing, api_chain))
}

#[cfg(feature = "website")]
fn router_with_inner(
    routing: &RoutingConfig,
    website_state: Option<crate::website::WebsiteState>,
    trust_xff_cidrs: &[IpNetwork],
) -> Router<AppState> {
    let api_chain = build_api_chain(routing, trust_xff_cidrs)
        .split_for_parts()
        .0;
    with_csrf(assemble_with_website(routing, api_chain, website_state))
}

/// Attach the CSRF double-submit + `Sec-Fetch-Site` middleware
/// (Phase 5 M5.2.2). Applied here in the canonical router builder
/// — not in `server.rs` — so every integration test exercises CSRF
/// exactly as production does (P3.2). The matcher in `routing::csrf`
/// compares against the post-nest URI, so the layer must sit outside
/// the `/v1` nest, which it does (the assembled router is the full
/// path surface). `server.rs` wraps this with host-dispatch / CORS /
/// trace / timeout, leaving the inner→outer ordering identical to the
/// previous in-`server.rs` placement.
fn with_csrf(app: Router<AppState>) -> Router<AppState> {
    app.layer(axum::middleware::from_fn(crate::routing::csrf::enforce))
}

/// Build the merged API+unauth surface. Identical shape regardless
/// of the `website` feature; `routing` is currently unused inside
/// the chain (the API mount prefix is applied by [`assemble`] /
/// [`assemble_with_website`]) but threaded through so a future
/// per-mount override can land without changing this function's
/// signature.
fn build_api_chain(
    _routing: &RoutingConfig,
    trust_xff_cidrs: &[IpNetwork],
) -> OpenApiRouter<AppState> {
    // Canonical cross-cutting auth tasks from trusttasks-tf. The legacy
    // openvtc/vtc/auth/legacy/* slugs were VTC-specific reimplementations
    // of primitives that VTA + did-hosting also have; consolidating here
    // so a multi-service deployment can use one client library.
    // Browser-SPA convenience surface: `sign-out`
    // revokes the session and clears the browser cookies in one trip; it
    // is on the unauthenticated chain because it also answers a session
    // that has already ended.
    // Audit log list — super-admin only since envelopes carry
    // plaintext DIDs.
    // Admin invites — REST surface for `vtc admin invite`. Single
    // Trust Task covers GET + POST on `/admin/invites` (same Phase-0
    // workaround community/profile + admin/config use); DELETE on
    // `/admin/invites/{jti}` has its own Trust Task since it's on a
    // distinct mount.
    // `members_update` (`members/update/1.0`) shares the
    // `members/{did}` mount with `show` for now — TrustTaskRouter
    // doesn't support per-method Trust-Task selectors yet
    // (same Phase-0 workaround `admin/config` + `community/profile`
    // use). When that lands, split show + update.
    // `members_admin_remove` (`members/admin-remove/1.0`) shares
    // the `members/{did}` mount with show + update for now —
    // TrustTaskRouter doesn't support per-method Trust-Task
    // selectors yet. The standalone task exists on disk +
    // index.json so the soft-gate surface stays complete.
    // POST + GET share `/v1/join-requests`, each carrying its own Trust
    // Task. The earlier note here said enforcement had to collapse onto
    // `submit` until TrustTaskRouter gained per-method selectors; that
    // was mistaken — `task_routes` layers the *method* router and axum
    // merges same-path routers per method, so both verbs are enforced
    // independently.
    // The unauthenticated `/join-requests` POST submit and `/status` move to
    // `build_unauth_routes` (P0.5) so the governor + 64 KiB cap apply; their
    // Trust Tasks are declared there. (`accept` is gone — retired upstream,
    // folded into `members/vmc` + `requestId`.) The admin GET list shares the
    // `/join-requests` path with the governed POST but carries its own
    // `join-requests/list/0.1` task (axum merges same-path method routers
    // per method).
    // Policies (Phase 2 M2.3). Three distinct Trust Tasks for the
    // three POST endpoints — upload, activate, test — so SIEM
    // filters + soft-gate consumers can target each precisely.
    // Phase 4 M4.3 + M4.4 — personhood lifecycle.
    // `members_personhood_revoke` (`members/personhood/revoke/1.0`)
    // exists on disk + in index.json so the soft-gate surface
    // stays complete, but the DELETE method shares the
    // `members/personhood/assert/1.0` mount at the router
    // layer pending per-method selectors. Same workaround as
    // `members/{did}` show + update + admin-remove.
    // Phase 4 M4.6 — VRC trust-graph endpoints.
    // Phase 4 M4.8 — endorsement type registry + custom
    // endorsement CRUD. Each verb carries its own canonical
    // task (same-path verbs are enforced per method — see
    // `vti_common::trust_task::openapi`).
    // `endorsements_show` + `endorsements_revoke` share the
    // `endorsements/{id}` mount with the Trust Task header
    // pinned to the `show` variant. Standalone tasks ship on
    // disk + in index.json so the soft-gate surface stays
    // complete (same workaround as members/{did}, etc.).
    // Phase 3 M3.8 — trust-registry reconciler diagnostics.
    // Admin-gated (not super-admin) so on-call ops can read
    // queue depth + RTBF-batched + failed counts without the
    // super-admin role.
    // Phase 3 M3.10 — cross-community session mint. The Trust
    // Task declaration moved to `build_unauth_routes` so the
    // handler sits behind the tower-governor + the 64 KB body
    // cap — it's an unauthenticated endpoint that does DID
    // resolution + outbound HTTP fetch + Rego policy eval +
    // session-JWT mint, all driven by attacker-controlled VAC/VMC
    // JSON, and it was previously exposed on the 1 MB / no-rate-
    // limit main chain.
    // Read endpoints (M2.4). GET /v1/policies and
    // GET /v1/policies/{id} share their mounts with the POST
    // /v1/policies upload and POST /v1/policies/{id}/activate
    // endpoints respectively — TrustTaskRouter doesn't yet support
    // per-method selectors (same workaround community/profile,
    // admin/config, members/{did}, join-requests use). The
    // standalone `policies/list/1.0` + `policies/show/1.0` Trust
    // Tasks exist on disk + in index.json so the soft-gate
    // surface stays complete; the wire enforcement collapses to
    // the POST task on the shared mount.

    let api = OpenApiRouter::<AppState>::new()
        // The git namespace family has no REST route at all: every mutation
        // and every read — the administrator's view, the namespace and
        // repository listings, the break-glass list, each repository's
        // drift, the rights lists, the bridge job queue, the Trust Registry
        // projection, the linked-account roster and the activity feed alike
        // — is a signed `git-ns/*` Trust Task on the document endpoint
        // (`git_ns::admin_reads`, `git_ns::tasks`), authorized by the
        // signer's own git rights or ACL capability.
        // BitstringStatusList publication (M2.11). Trust-Task-
        // exempt — external verifiers don't carry our extension
        // header (same rationale as `did.jsonl`).
        .routes(routes!(status_lists::show))
        // Auth routes. `POST /v1/auth/{challenge,authenticate,refresh}`
        // are unauthenticated and live in `build_unauth_routes` so the
        // tower-governor + tighter body cap apply. The two
        // session-management endpoints below are authenticated and
        // stay on the main chain.
        //
        // The session listing and revocation are the signed
        // `auth/sessions/list/0.1` and `auth/revoke-session/0.2`, served by the
        // spine (`trust_tasks::admin_tasks`) on every transport; neither has a
        // route. Nor has `auth/whoami/0.1`: it is answered on the spine
        // (`trust_tasks::whoami_tasks`) for the key a sign-in bound to the
        // session, never for a cookie (#1641).
        //
        // Sign-out is on the unauthenticated chain (`build_unauth_routes`):
        // it answers a caller whose session has already ended.
        // Audit log (super-admin only): `audit/list` and `audit/verify` are
        // signed documents only.
        // Config lives at `/v1/admin/config` on canonical `config/{show,patch}`.
        // The pre-MVP `GET, PATCH /v1/config` surface is gone (#710): every field
        // it carried has a canonical owner — `vtc_did` / `vtc_name` /
        // `vtc_description` on `vtc/community/profile/{show,update}`,
        // `public_url` in the config-store overlay reached through
        // `config/{show,patch}`.
        // The ACL has no route: the five `acl/*` tasks are signed documents
        // served only at `POST /v1/trust-tasks` (`trust_tasks::acl_tasks`),
        // on every transport.
        //
        // Community profile. The read (`vtc/community/profile/show/0.1`) and the
        // edit (`vtc/community/profile/update/0.1`) are signed documents only.
        // Public read of the community profile. Trust-Task-exempt and
        // unauthenticated — visitors landing on the default public
        // website need the community's name + description + DIDs to
        // render before any session exists. Curated subset only (no
        // extensions, no registry status).
        .routes(routes!(community::profile::get_public_profile))
        // The community DID as a QR code, for a wallet to scan off the landing
        // page. Public for the same reason as the profile: it is the same DID.
        .routes(routes!(community::did_qr::get_did_qr))
        // Community branding (`vtc/community/branding/{show,update}/0.1`) and
        // what the community asks an applicant to tell it about themselves
        // (`vtc/community/requested-attributes/{show,update}/0.1`), both
        // published on `join-requests/manifest/0.2`, are signed documents
        // only now (`trust_tasks::surface_tasks`) — their admin-only bearer
        // REST mounts had no caller left once `vtc-client` and the admin
        // console signed them instead.
        // Whether the join manifest answers a caller this community cannot
        // identify (`vtc/community/join-discovery/{show,update}/0.1`) — not a
        // member of the profile, whose `show` response is a published schema
        // that permits no new ones. A signed document only, with no route.
        // The runtime configuration (`config/{show,patch,reload,restart}/0.1`)
        // has no route: each is a signed document served by the spine
        // (`trust_tasks::admin_tasks`) on every transport.
        // Export / import (`vtc/config/{export,import}/0.1`) have no route
        // here: both declare `proof` REQUIRED and are served only as signed
        // documents at `POST /v1/trust-tasks` (#1641 phase 2, batch 3). Their
        // bearer routes were removed rather than kept transitional, because no
        // client called them.
        // Install claim (`vtc/install/claim/{start,finish}/0.2`) and admin
        // bootstrap (`vtc/admin/bootstrap/0.1`, M0.6.2 — closes the install
        // carve-out and writes the first admin ACL entry) are signed
        // documents only now (`trust_tasks::install_tasks`): each verb's own
        // bearer artifact (the install JWT, the `registrationId` `start`
        // minted, the setup-session JWT) is the credential, so no proof is
        // required and no REST route binds them. Their dedicated,
        // `Trust-Task`-header-gated REST mounts had no caller once the admin
        // console signed the documents instead.
        // An administrator's own passkeys (M0.6.3) — `auth/passkey/list/0.1`,
        // `enroll/{start,finish}/0.2`, `revoke/{start,finish}/0.1` — have no
        // route: each declares `proof` REQUIRED and is served only as a signed
        // document by the spine (`trust_tasks::admin_passkey_tasks`), on every
        // transport. The `/v1/admin/passkeys/*` bearer routes were removed
        // with #1641's last batch, once the console signed them.
        // Members' step-up passkeys (`crate::step_up_passkey`) have no route
        // here: issuing, redeeming, revoking and an administrator's listing
        // (`auth/passkey/admin-list/0.1`) are Trust Tasks served only by the
        // spine (`trust_tasks::step_up_passkey_tasks`), on every transport.
        // The console's signing keys are `auth/signing-key/*` on the spine
        // (`trust_tasks::signing_key_tasks`), and have no route.
        // Admin invites (`vtc/admin/invites/{list,create,revoke}/0.1`) have no
        // route: each is a signed document served by the spine
        // (`trust_tasks::admin_tasks`) on every transport.
        // A self-hosted community's own DID log (Keyring VTI-35) is the signed
        // `did-management/did/register/0.1` document only — see
        // `admin::did_register`.
        // Directory ceremony (read-only field projection via the
        // ceremony decision pipeline).
        // Ceremony registry — the admin-UI renders its flow + simulator
        // from these manifests (purpose / fields / facts template).
        // Members (Phase 1 M1.4–M1.6). The roster (`vtc/members/list/0.1`) is a
        // signed document only.
        // Departed (tombstoned/historical) members. The literal `/removed`
        // must precede the `/{did}` catchall so axum's path-trie doesn't route
        // "removed" as a DID. The purge (`vtc/members/purge/0.1`) and a
        // member's own departure (`vtc/members/self-remove/0.1`) have no
        // route: both are signed documents only.
        // Renewal (M2.13) is a signed document only
        // (`vtc/members/renew/0.1`, `trust_tasks::member_tasks`); the bearer
        // REST route it once also served had no caller once the spine
        // dispatched it (#1809) and was removed.
        //
        // DID rotation (M2.15.1) is likewise a signed document only —
        // `vtc/members/rotate-challenge/0.1` opens the two-step ceremony,
        // `vtc/members/rotate/0.1` applies the co-signed swap atomically —
        // for the same reason.
        // Reciprocal-VMC request — ask an active member to issue + send the
        // member → community half of the membership pair. The member replies
        // asynchronously over the `members/vmc/1.0` DIDComm surface.
        // Phase 4 M4.3 + M4.4 — personhood lifecycle. The challenge, the
        // assertion and the revoke are all signed documents only
        // (`vtc/members/personhood/{challenge,assert,revoke}/0.1`); the
        // revoke's bearer REST route had no caller once the spine dispatched
        // it (#1809) and was removed.
        // Phase 4 M4.6 — the VRC trust-graph member list
        // (`vtc/relationships/list/0.2`) is a signed document only; its
        // bearer REST route lost its last caller (the admin console) when
        // that console moved onto the signed door and was removed.
        // #1215 — the membership pair's bodies for one member
        // (`vtc/members/credentials/0.1`) is a signed document only.
        // Admin connections-graph view — the member-relationship (VRC) graph.
        //
        // Relationship revoke (`vtc/relationships/{revoke/0.1,revoke/0.2}`) is
        // a signed document only. `0.1` kept a bearer REST route because it
        // could reach only two of the three capacities the bearer route
        // authorized — the edge's own issuer, or an administrator — and not
        // the third: a `VrcRevokeAuthorization` proving control of a pairwise
        // relationship DID. `0.2` (trustoverip/dtgwg-trust-tasks-tf#689) adds
        // that as an optional `pop` bound to the document's own `id` rather
        // than to a REST session, so the signed door now reaches all three
        // and the REST route was removed (see `trust_tasks::member_tasks`'s
        // `handle_relationships_revoke_v0_2`).
        // Suspend and restore (#1079) are `vtc/relationships/{suspend,restore}`
        // on the spine (`trust_tasks::surface_tasks`), and have no route.
        // The persona annotation (VPC, #1067) has no route: it had no Trust
        // Task specification and no caller, and how a VPC binds to an edge is
        // still open upstream (trustoverip/dtgwg-cred-spec#9). Stored edges
        // keep their `persona` field; see
        // `docs/05-design-notes/vpc-persona-annotation.md`.
        // Phase 4 M4.8.1 — operator-uploaded endorsement type registry. The
        // listing is REST; `register` and `delete` are signed documents only.
        // The community schema store (Issues + Accepts registry) is
        // `vtc/schemas/*` on the spine, and has no route.
        // Phase 4 M4.8.2-4 — custom endorsement issuance, retrieval and
        // revocation (`vtc/endorsements/{issue,list,show,revoke}/0.1`) are
        // all signed documents only now: each bearer REST route had no
        // caller once the spine dispatched it and was removed (issuance,
        // #1809; retrieval and revocation, tt-tf#689).
        // Invitation Credential (VIC) issuance + listing — the operator side of
        // the VIC auto-join ceremony. Admin / Moderator / Issuer. POST + GET on
        // /invitations share the `issue/1.0` mount; the standalone `list/1.0`
        // task is declared on disk for the soft-gate surface.
        // Split per method, as with admin/invites above: issuance returns a
        // bearer credential, listing must never re-disclose one. The old
        // shared `issue/1.0` mount could not state both contracts.
        // Revoke an outstanding invitation (flips its revocation bit).
        // Get an issued invitation to the DID it admits: push an offer to it,
        // or return the offer for a QR code (Keyring VTI-21 / VTI-32).
        // Recognition (trust-graph) lookup — admin window into TRQP recognise.
        // Naming vetters (`vtc/vetting/vetters/grant/0.1`, OpenVTC vetting
        // design §10) is a signed document only, withdrawn through
        // endorsements/revoke.
        // The vetter registry's admin surface. Resend
        // (`vtc/vetting/vetters/resend/{0.1,0.2}`) is a signed document only:
        // `0.1` enforces the task a vetter sends for themselves, `0.2` adds
        // the `memberDid` an administrator names to resend on a vetter's
        // behalf (tt-tf#689) — what let the admin-only REST resend route
        // retire. The grant listing (`vtc/vetting/vetters/grants/list/0.1`),
        // the automatic-grant configuration
        // (`vtc/vetting/auto-grant/{show,update}/0.1`) and the withdrawal
        // notices (`vtc/vetting/revocations/list/0.1`) are signed documents
        // only too now (`trust_tasks::surface_tasks`) — their admin-only
        // bearer REST mounts had no caller left once `vtc-client` and the
        // admin console signed them instead.
        // The public listing (`vtc/vetting/vetters/list/0.1`) has no route:
        // the console sends the same signed document an applicant does.
        // The by-DID lookup the listing cannot answer (`vetters/show/0.1`,
        // #1651) is a signed document only.
        ;
    // Hidden-vetter admission (development branch `zkp-pcs`): derive this
    // community's PCS keys and publish them on a criterion. Was admin REST
    // here; it is `vtc/vetting/hidden/publish/0.1` now, on the spine like
    // every other admin verb, behind the same `vetting-pcs` feature.
    // A member's update and removal (`vtc/members/{update,admin-remove}/0.1`)
    // are signed documents only.
    // Join requests (Phase 1 M1.7–M1.10). The admin queue
    // (`vtc/join-requests/list/0.1`) and its decision
    // (`vtc/join-requests/decide/0.1`) are signed documents only.
    // The vetting facts a request was decided on — admin REST with no Trust
    // Task of its own.
    // The join manifest has no route: the console reads it as the signed
    // `vtc/join-requests/manifest/0.2` document applicants send.
    // (Manifest discovery moved to the single `POST /v1/trust-tasks`
    // document endpoint — `join-requests/manifest/1.0` is now a Trust
    // Task verb, no longer a bespoke GET.)
    // The administrator's credential query (`vtc/join-requests/query`),
    // a join request's vetting facts (`vtc/join-requests/vetting/show`) and
    // the host's rooms (`vtc/rooms/list`) are served on the spine, and
    // have no route.
    // Policies: every verb is a signed document only
    // (`trust_tasks::policy_tasks`).

    // Phase 5 M5.5 — public-website management. Every verb is a signed
    // document now (`trust_tasks::website_tasks`): the chunked upload,
    // deploy and ranged reads already were; the listing, delete, generation
    // history and rollback verbs join them here. None has a REST route —
    // their dedicated, `Trust-Task`-header-gated mounts had no caller once
    // the admin console signed the documents instead, and nothing in this
    // repository (no admin-ui page, no `cnm`/`vtc-client` command) called
    // them either.

    // P3.9 — encrypted backup / restore has no route: a backup is the
    // `vtc/backup/export` + `backup/*` Trust Tasks, over TSP or DIDComm only.

    // The member portal's session-gated routes (`MemberAuth`): off the
    // governor, like every other authenticated route — the session is the gate.
    let api = api
        .route("/member/me", get(member_portal::me))
        .route("/member/passkeys", get(member_portal::list_passkeys))
        .route(
            "/member/passkeys/register/start",
            post(member_portal::register_start),
        )
        .route(
            "/member/passkeys/register/finish",
            post(member_portal::register_finish),
        )
        .route(
            "/member/passkeys/{credential_id}",
            delete(member_portal::remove_passkey),
        );

    let api = api
        // §14.4 — every authenticated API route inherits the 1 MiB
        // global body cap. The per-route overrides above for
        // `/v1/website/*` apply first; this layer
        // is the default for everything else.
        .layer(DefaultBodyLimit::max(MAX_BODY_SIZE));

    // Unauthenticated routes — tighter body cap + per-IP governor.
    let unauth = build_unauth_routes(trust_xff_cidrs);
    api.merge(unauth)
}

/// Build the unauthenticated sub-router: POST routes that drive expensive
/// crypto against attacker-controlled bytes, plus the single Trust Task
/// document endpoint (`POST /trust-tasks`), which now also carries
/// `auth/challenge`, `auth/authenticate` and pre-session install/recognise
/// verbs that used to have dedicated mounts here.
///
/// - `POST /auth/refresh` (also the admin console's cookie session renewal)
/// - `POST /trust-tasks`
///
/// Layers:
/// - [`UNAUTH_BODY_SIZE`] body cap (tighter than the 1 MiB main
///   API cap — generous enough for a JWE / sealed-transfer
///   envelope, small enough to reject blob floods).
/// - Per-IP `tower-governor` via [`TrustedProxyKeyExtractor`] (or the peer
///   address when `trust_xff_cidrs` is empty): a burst of 10, then one
///   request every 5 s. `per_second(5)` is a replenishment *interval*, not a
///   rate — a bigger number is a tighter limit. A refusal is a `429` in the
///   shape [`crate::routing::rate_limit`] defines, reporting limiter `unauth`.
fn build_unauth_routes(trust_xff_cidrs: &[IpNetwork]) -> OpenApiRouter<AppState> {
    // Canonical cross-cutting auth tasks from trusttasks-tf.
    //
    // Bearer→cookie bridge: `vtc/auth/admin-session/0.1`, now a signed
    // document only (`trust_tasks::admin_session_tasks`, #1641). The fused
    // `/v1/auth/admin-login` that authenticated and set cookies in one call
    // was removed in #710, since it re-implemented `POST /v1/auth/` to add a
    // side-effect this task already provides.
    // Browser-friendly passkey login — one canonical spec serves both
    // initial login and AAL step-up, selected by the start payload's
    // `purpose` field (0.2's camelCase `stepUp`). Login is genuinely
    // unauthenticated; step-up authenticates the caller inside the
    // handler, which is why both live on this chain.
    // Phase 3 M3.10 — cross-community session mint. Sits in the
    // unauth chain (not the main API chain) so the tower-governor
    // + 64 KB body cap apply: the handler runs DID resolution,
    // outbound HTTP fetch of the foreign `statusListCredential`
    // URL, Rego policy eval, and a session JWT mint, all driven by
    // attacker-supplied JSON. Behind the rate limit, a sustained
    // SSRF / CPU-amplification probe is throttled to one request
    // every 5 s per source IP after a burst of 10.
    // Step 1 of the recognise flow — issues the single-use challenge nonce the
    // holder binds into the VP presented to `/auth/recognise`. Same unauth
    // chain (governor + body cap) as the other challenge endpoints.
    // P0.5 — the unauthenticated join-request POSTs (submit / status, plus
    // the member-facing `members/vmc` delivery that closes an approved join)
    // do the same attacker-driven crypto as recognise (Ed25519 holder-binding
    // verify, reciprocal-VC counter-sign verify, Rego eval) but were left on
    // the 1 MiB / no-limiter main chain. Move them here so the governor + 64
    // KiB cap apply. The admin GET list + show + POST decide and the
    // public GET manifest stay on the `api` chain.

    // `TrustedProxyKeyExtractor` needs `ConnectInfo` to identify the peer. In
    // production the `axum::serve` call in `server.rs` wires
    // `into_make_service_with_connect_info` so it's always present; in
    // integration tests built on `Router::oneshot` it is not, and the
    // extractor refuses every request. A synthetic-`ConnectInfo` middleware
    // inserts a `127.0.0.1` placeholder **only when missing** so those calls
    // take the peer-IP path.
    //
    // It is layered **only when nothing is trusted**, and that condition is
    // the whole safety argument: a synthesised peer is a manufactured anchor,
    // and with a non-empty trust list it could land *inside* a trusted CIDR —
    // at which point a request with no peer at all would be allowed to name
    // its own rate-limit bucket through `X-Forwarded-For`. With an empty list
    // no address is trusted, so the placeholder can only ever be keyed on
    // directly. A configured VTC therefore keeps the fail-closed behaviour
    // (no peer → refused), which is what production sees anyway.
    let synth_connect_info = trust_xff_cidrs.is_empty().then(|| {
        axum::middleware::from_fn(vti_common::rate_limit::insert_default_connect_info_if_missing)
    });

    let unauth_router = OpenApiRouter::<AppState>::new()
        // Redeem a credential offer over HTTPS — for a holder with no messaging
        // service, such as an invitee who scanned a delivered offer. The
        // key-binding proof inside is the authority; the governor and body cap
        // bound what an unauthenticated caller can make it verify.
        .routes(tt(
            routes!(credential_exchange::request),
            <trust_tasks_rs::specs::credential_exchange::request::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI,
        ))
        // `auth/challenge/0.1` and `auth/authenticate/0.1`'s dedicated,
        // `Trust-Task`-header-gated REST mounts had no caller left once
        // `vta_sdk::auth_light` (shared by the VTA client and `cnm
        // vetting`'s bearer-session login, `VtcClient::connect`) switched to
        // signing `auth/challenge/0.1` / `authenticate/0.2` documents against
        // `POST /v1/trust-tasks` instead (#1858) — the same door
        // `trust_tasks::auth_tasks` already dispatched `0.2`/`0.3` on. See
        // that module's doc for the pre-session dispatch this family gets.
        // VTA-wallet login surface. The browser wallet extension drives
        // the SIOPv2 round-trip itself and posts to `<base>/auth/challenge`
        // + `<base>/auth/` with **no** `Trust-Task` header (the op `type`
        // rides in the body). These header-exempt aliases reuse the same
        // `challenge` / `authenticate` handlers — the latter's SIOP branch
        // handles the wallet's `id_token` envelope. The admin-UI points the
        // wallet at `<origin>/v1/wallet` so it lands here, leaving the
        // Trust-Task-gated `/auth/*` routes above untouched for DIDComm and
        // CLI clients. Mirrors did-hosting-control's header-less auth.
        .route("/wallet/auth/challenge", post(auth::challenge))
        .route("/wallet/auth/", post(auth::authenticate))
        // Completes the wallet's loop. Without this alias the extension could
        // log in header-free above but had to send a `Trust-Task` header (or,
        // before the REST fast-path, an authcrypt DIDComm envelope) to spend
        // the refresh token it was just handed — so it re-ran the whole SIOP
        // round-trip on every access-token expiry instead. Same handler; the
        // op `type` rides in the body exactly as it does on `/wallet/auth/`.
        .route("/wallet/auth/refresh", post(auth::refresh))
        // `auth/refresh/0.1`'s dedicated REST mount stays: it is also the
        // admin console's own **cookie**-bound session renewal
        // (`vtc_admin_refresh`), which has no signed-document equivalent — a
        // browser cookie carries no DID key to sign with. `auth/refresh/0.2`
        // (`trust_tasks::auth_tasks`) adds the signed-document path beside
        // it for TSP/DIDComm/HTTPS callers that hold a key; see that
        // module's doc for the full argument.
        .routes(tt(
            routes!(auth::refresh),
            "https://trusttasks.org/spec/auth/refresh/0.1",
        ))
        // Sign-out ends the browser's cookie session: it clears the cookie
        // pair `auth/admin-session` set, which no Trust Task describes. It had
        // borrowed `revoke-session`, whose request names a session or a
        // subject and whose response counts them; sign-out takes neither and
        // answers `204`. It carries no binding, and goes when the cookie
        // session does. It lives here, behind the governor, because a caller
        // whose session already ended gets `204` too — a console left open
        // past its access token still holds a refresh cookie only this
        // response can clear.
        .routes(routes!(auth::sign_out))
        // The member portal's sign-in surface (`crate::member_portal`): the
        // wallet's header-less SIOP round-trip at `<origin>/v1/member/wallet`,
        // the portal passkey ceremony, and the cookie session. Unbound for the
        // reason the console's wallet aliases and sign-out are — browser
        // plumbing, documented in `member_portal`'s route module — and here,
        // behind the governor, because none of them requires a session.
        .route(
            "/member/wallet/auth/challenge",
            post(member_portal::wallet_challenge),
        )
        .route(
            "/member/wallet/auth/",
            post(member_portal::wallet_authenticate),
        )
        .route(
            "/member/wallet/auth/refresh",
            post(member_portal::wallet_refresh),
        )
        .route(
            "/member/sign-in/config",
            get(member_portal::sign_in_config),
        )
        .route("/member/session", post(member_portal::session))
        .route("/member/auth/refresh", post(member_portal::cookie_refresh))
        .route("/member/sign-out", post(member_portal::sign_out))
        .route(
            "/member/passkey-login/start",
            post(member_portal::passkey_login_start),
        )
        .route(
            "/member/passkey-login/finish",
            post(member_portal::passkey_login_finish),
        )
        // A wallet's two OpenID4VP calls for a wallet session (#1988): fetch
        // the signed request object, post the encrypted response. A declared
        // foreign-protocol exception (VTI-OPS-095) — the wallet speaks
        // OpenID4VP and signs no Trust Task; the session is opened and read
        // through `vtc/join-requests/wallet-session/*`. Unbound, and behind
        // the governor. The response reads its own body against
        // `[oid4vp] max_response_bytes`, not this chain's cap.
        .routes(routes!(oid4vp::request_object))
        .routes(routes!(oid4vp::response))
        // The bearer → cookie bridge (`vtc/auth/admin-session/0.1`) has no
        // route: its specification declares `proof` REQUIRED, so it is served
        // only as a signed document on `POST /v1/trust-tasks`
        // (`trust_tasks::admin_session_tasks`), signed by the access token's
        // subject, which sets the cookies there (#1641).
        .routes(tt(
            routes!(auth::passkey_login_start),
            "https://trusttasks.org/spec/auth/passkey/login/start/0.2",
        ))
        .routes(tt(
            routes!(auth::passkey_login_finish),
            "https://trusttasks.org/spec/auth/passkey/login/finish/0.2",
        ))
        // Install claim (`vtc/install/claim/{start,finish}/0.2`) and
        // cross-community recognition (`vtc/auth/recognise/{challenge/0.1,
        // 0.2}`) are signed documents only now (`trust_tasks::install_tasks`,
        // `trust_tasks::recognise_tasks`); their dedicated, `Trust-Task`-
        // header-gated REST mounts had no caller left (recognise never had one
        // in this workspace; the admin console signs install/claim + bootstrap
        // instead).
        //
        // `vtc/relationships/publish/0.2` (publishing a relationship edge,
        // authenticated by the Trust Task document's own proof rather than a
        // bearer token, #1084) was already served on the spine
        // (`trust_tasks::member_tasks::handle_relationships_publish`) —
        // `vtc-client` has signed it since #1845, and its bearer route
        // (`routes::relationships::publish`, the twin `publish_inner` also
        // calls) had no caller left either.
        // The single Trust Task document endpoint (P0.5: governed unauth
        // chain). The holder-facing join ceremony verbs (submit/request,
        // manifest, status) all arrive here as Trust Task documents
        // and are routed internally by document `type`; the holder is
        // authenticated by the document's `eddsa-jcs-2022` proof. No
        // `Trust-Task` header gate — the document's own `type` is the
        // identity.
        //
        // Admin verbs **are** routed here since #1641 phase 2, starting with
        // the member ones. "Unauthenticated" is a property of the transport,
        // not of the authority: the spine holds a document to the proof,
        // recipient, freshness and replay rules its specification declares, and
        // the verb reads the *verified signer's* ACL entry. See
        // `trust_tasks::admin_signer`.
        //
        // Its body cap is not `UNAUTH_BODY_SIZE`: each Trust Task type declares
        // its own largest document (`crate::trust_tasks::size`, 64 KiB unless
        // its specification needs more, and only while it is served), and the
        // spine refuses a document over its type's limit before parsing it.
        // The route admits the largest any served type accepts, so that check
        // is the one that decides.
        .layer(DefaultBodyLimit::max(UNAUTH_BODY_SIZE));

    // One extractor covers both cases: with an empty CIDR list
    // `TrustedProxyKeyExtractor` trusts nothing and so keys on the socket
    // peer, which is exactly `PeerIpKeyExtractor`. Branching on the list only
    // to pick between two behaviours that already coincide is a chance to get
    // the arms the wrong way round for no gain.
    let cfg = Arc::new(
        GovernorConfigBuilder::default()
            .per_second(5)
            .burst_size(10)
            .key_extractor(TrustedProxyKeyExtractor::new(trust_xff_cidrs.to_vec()))
            .finish()
            .expect("governor config values are static and non-zero"),
    );
    // `/trust-tasks` is not behind the tower layer: it charges a document to
    // one of several budgets depending on who signed it
    // (`routing::trust_task_admission`). Anonymous documents still pay this
    // governor's own limiter state, so an address has one anonymous budget
    // across the whole chain, not one here and another on `/trust-tasks`.
    let limits = crate::routing::trust_task_admission::TrustTaskLimits::new(
        cfg.limiter().clone(),
        TrustedProxyKeyExtractor::new(trust_xff_cidrs.to_vec()),
    );
    let unauth_router = unauth_router
        .layer(
            GovernorLayer::new(cfg)
                .error_handler(crate::routing::rate_limit::governor_error_response),
        )
        .merge(
            OpenApiRouter::<AppState>::new()
                .routes(routes!(trust_tasks::dispatch))
                .layer(axum::middleware::from_fn_with_state(
                    limits,
                    crate::routing::trust_task_admission::client_address,
                ))
                .layer(DefaultBodyLimit::max(
                    crate::trust_tasks::size::largest_max_document_bytes(),
                )),
        );
    match synth_connect_info {
        Some(layer) => unauth_router.layer(layer),
        None => unauth_router,
    }
}

/// Build the public router from the API sub-router + placeholder
/// admin/website surfaces. Extracted so unit tests can exercise
/// nest behaviour without rebuilding the full TrustTaskRouter.
///
/// Only used by the no-`website`-feature build path; the
/// feature build always flows through [`assemble_with_website`].
#[cfg_attr(feature = "website", allow(dead_code))]
fn assemble(routing: &RoutingConfig, api: Router<AppState>) -> Router<AppState> {
    use axum::middleware::from_fn;

    use crate::routing::security_headers::security_headers;

    // Admin UX + website sub-routers serve HTML/JS to a browser;
    // both get the default CSP + `X-Content-Type-Options: nosniff`
    // layer (Phase 5 M5.3.2). The API sub-router is a JSON wire
    // surface and is intentionally excluded — CSP is browser-only.
    let admin_placeholder: Router<AppState> = Router::new()
        .fallback(any(placeholder_503))
        .layer(from_fn(security_headers));
    let website_placeholder: Router<AppState> = Router::new()
        .fallback(any(placeholder_503))
        .layer(from_fn(security_headers));

    let spec = openapi_spec();
    let mut app: Router<AppState> = Router::new()
        // `/health` is the single Trust-Task-exempt endpoint;
        // attached at the parent-router root so monitoring works
        // identically across path mode and subdomain mode (the
        // operator just curls `/health` on whichever host the
        // daemon is reachable on).
        .route("/health", get(health::health))
        // Machine-readable API description for black-box conformance / fuzz
        // tooling. Unauthenticated (API shape, not secrets); served at the
        // parent root like `/health`.
        .route("/openapi.json", get(move || serve_openapi(spec.clone())))
        // `did:webvh` log publication. Mounted at the parent root
        // (above the `/v1` nest) because a serverless VTC's DID,
        // `did:webvh:<scid>:<host>`, resolves to
        // `https://<host>/.well-known/did.jsonl` by the did:webvh
        // convention — the log has to live at that exact URL for the
        // VTC's own DID to be resolvable. The VTC hosts exactly one
        // DID, its own. See `tasks/vtc-mvp/vta-driven-keys.md` §10.
        .route("/.well-known/did.jsonl", get(did_log::did_log))
        // The logs of the room identities this community minted serverless
        // (`rooms::community`): a room it owns resolves to
        // `https://<host>/room-dids/<id>/did.jsonl`. Public, like the line above.
        .route(
            "/room-dids/{id}/did.jsonl",
            get(crate::rooms::community::serve_room_did_log),
        )
        // API surface — existing TrustTaskRouter result nested at
        // the configured mount.
        .nest(&routing.api.mount, api);

    // Admin UX surface. The cookie-scope guard in
    // `validate_routing` already refuses admin_ui at `/`; here we
    // just trust the prior validation.
    app = app.nest(&routing.admin_ui.mount, admin_placeholder);

    // Website surface. axum 0.8 refuses `nest("/", ...)`; when the
    // mount is the root, merge instead so the placeholder's
    // fallback (with security headers attached) becomes the
    // parent's fallback. Non-root mounts use the regular nest path.
    if routing.website.mount == "/" {
        app = app.merge(website_placeholder);
    } else {
        app = app.nest(&routing.website.mount, website_placeholder);
    }

    app
}

/// Production assembly: same as [`assemble`] but **replaces** the
/// website 503 placeholder with the real static handler when a
/// [`crate::website::WebsiteState`] is provided.
///
/// Mirrors the no-state path's nest/merge logic exactly so the
/// route-priority semantics don't drift between the two builds.
#[cfg(feature = "website")]
pub fn assemble_with_website(
    routing: &RoutingConfig,
    api: Router<AppState>,
    website_state: Option<crate::website::WebsiteState>,
) -> Router<AppState> {
    use axum::middleware::from_fn;

    use crate::routing::security_headers::security_headers;

    // Admin UX sub-router. Phase 5 M5.7 ships the real handler
    // when `admin-ui` is on AND `admin_ui.mode = "embedded"`.
    // External mode + the no-feature build fall back to the 503
    // placeholder.
    //
    // We use explicit `route("/")` + `route("/{*path}")` rather
    // than `Router::fallback`, because axum 0.8 doesn't propagate
    // the nested router's fallback through `Router::merge` of a
    // sibling router (the website surface) — the website
    // fallback ends up intercepting requests to `/admin/*`. Two
    // wildcard routes cover every reachable path.
    #[cfg(feature = "admin-ui")]
    let admin: Router<AppState> = Router::new()
        .route("/build-info.json", get(admin_ui::build_info))
        .route("/plugins.json", get(admin_ui::plugins_manifest))
        .route("/plugins/{id}/{*rel_path}", get(admin_ui::plugin_asset))
        .route("/", get(admin_ui::serve_spa))
        .route("/{*path}", get(admin_ui::serve_spa))
        .layer(from_fn(security_headers));
    #[cfg(not(feature = "admin-ui"))]
    let admin: Router<AppState> = Router::new()
        .route("/", any(placeholder_503))
        .route("/{*path}", any(placeholder_503))
        .layer(from_fn(security_headers));

    // Website sub-router. Two dispatch paths, same rationale for
    // explicit wildcard routes as the admin block above.
    //
    // - Operator configured `website.root_dir` → serve from the
    //   filesystem via the M5.4 handler. `website_state` is
    //   `Some`.
    // - No `root_dir` → serve the in-tree default landing page
    //   from `vtc-service/website-default/`. `website_state` is
    //   `None`. This is the freshly-installed-daemon
    //   out-of-the-box experience.
    //
    // Both paths share the security-headers layer so the default
    // CSP applies uniformly.
    // Built as `Router<()>` (state baked in via `with_state` for
    // the operator-config branch; the default-site branch is
    // state-less) so the parent `Router<AppState>` can mount it
    // via `fallback_service` / `nest_service`. axum 0.8's `merge`
    // doesn't preserve nested-router precedence when the merged
    // router has a wildcard `route("/{*path}")` — the website's
    // wildcard scores higher than the admin nest, so `/admin/*`
    // ends up routed to the website. The service-level mount
    // sidesteps that.
    let website: axum::Router<()> = match website_state {
        Some(state) => Router::new()
            .route("/", get(crate::website::serve))
            .route("/{*path}", get(crate::website::serve))
            .layer(from_fn(security_headers))
            .with_state(state),
        None => Router::new()
            .route("/", get(crate::website::default_site::serve))
            .route("/{*path}", get(crate::website::default_site::serve))
            .layer(from_fn(security_headers)),
    };

    let spec = openapi_spec();
    let mut app: Router<AppState> = Router::new()
        .route("/health", get(health::health))
        // Machine-readable API description — see the matching comment in
        // `assemble`.
        .route("/openapi.json", get(move || serve_openapi(spec.clone())))
        // `did:webvh` log publication — see the matching comment in
        // `assemble`. Parent-root mount so a serverless VTC's
        // `did:webvh:<scid>:<host>` resolves to
        // `https://<host>/.well-known/did.jsonl`, the URL we serve.
        .route("/.well-known/did.jsonl", get(did_log::did_log))
        .route(
            "/room-dids/{id}/did.jsonl",
            get(crate::rooms::community::serve_room_did_log),
        )
        .nest(&routing.api.mount, api);
    app = app.nest(&routing.admin_ui.mount, admin);
    // axum 0.8's `nest("/admin", inner)` registers `/admin` (bare)
    // and `/admin/{*rest}` (sub-paths). Neither matches `/admin/`
    // exactly — that path has no characters after the slash, so the
    // wildcard fails — and the request falls through to the website
    // fallback. Operators routinely paste `/admin/` into a browser,
    // so we register the trailing-slash form explicitly to point at
    // the same SPA handler.
    let admin_slash = format!("{}/", routing.admin_ui.mount.trim_end_matches('/'));
    #[cfg(feature = "admin-ui")]
    {
        app = app.route(admin_slash.as_str(), get(admin_ui::serve_spa));
    }
    // The member portal (`/members/*`): its own bundle beside the console's,
    // under the same security headers. Fixed path — the public site links to
    // it — and registered before the website fallback, so it takes precedence
    // over an operator site's own `/members` path. `/members/` is registered
    // explicitly for the reason `/admin/` is above.
    #[cfg(feature = "admin-ui")]
    {
        let members: Router<AppState> = Router::new()
            .route("/", get(admin_ui::serve_members_spa))
            .route("/{*path}", get(admin_ui::serve_members_spa))
            .layer(from_fn(security_headers));
        app = app.nest("/members", members);
        app = app.route("/members/", get(admin_ui::serve_members_spa));
    }
    #[cfg(not(feature = "admin-ui"))]
    {
        app = app.route(admin_slash.as_str(), any(placeholder_503));
    }
    if routing.website.mount == "/" {
        app = app.fallback_service(website);
    } else {
        app = app.nest_service(&routing.website.mount, website);
    }
    app
}

/// Placeholder 503 handler used by the admin sub-router when the
/// `admin-ui` feature is off, and by the website sub-router in
/// the no-`website`-feature build.
#[cfg_attr(all(feature = "website", feature = "admin-ui"), allow(dead_code))]
async fn placeholder_503() -> impl IntoResponse {
    (
        StatusCode::SERVICE_UNAVAILABLE,
        "surface not yet implemented",
    )
}

#[cfg(test)]
mod openapi_tests {
    use super::*;

    #[test]
    fn openapi_spec_documents_the_bearer_scheme_and_the_signed_verbs_shapes() {
        let spec = openapi_spec();
        assert_eq!(spec.info.title, "Verifiable Trust Community (VTC) API");
        // The bearer scheme is present, and the response schema of a verb
        // served only as a signed document is still published: the console's
        // wire types are generated from it.
        let components = spec.components.as_ref().expect("components present");
        assert!(components.security_schemes.contains_key("bearer_jwt"));
        assert!(
            components.schemas.contains_key("DiagnosticsResponse"),
            "DiagnosticsResponse schema must be emitted"
        );
    }

    /// REST routes that are not public, health or WebAuthn-ceremony routes,
    /// paired with the reason they still have a dedicated REST mount instead
    /// of being Trust-Task-document only.
    ///
    /// This, together with [`is_public_health_or_webauthn`], replaces a path
    /// floor (`paths.len() >= N`) and an ever-growing "must not exist" list of
    /// every verb that has ever migrated off REST. Both needed an edit on
    /// every migration even though nothing broke; this needs one only when a
    /// route is *added* (or a REST-only reason changes), which is exactly the
    /// moment a conscious decision should be recorded.
    const REST_EXCEPTIONS: &[(&str, &str, &str)] = &[
        (
            "POST",
            "/v1/auth/refresh",
            "the admin console's cookie-bound session renewal; it has no signed-document equivalent",
        ),
        (
            "POST",
            "/v1/auth/sign-out",
            "clears the console's cookie session; a browser action, not a protocol verb",
        ),
        (
            "POST",
            "/v1/credential-exchange/request",
            "redeems a credential offer; the key-binding proof is the authority, kept on its own              governed mount alongside the generic Trust Task door",
        ),
        (
            "POST",
            "/v1/trust-tasks",
            "the generic Trust Task document endpoint itself",
        ),
        (
            "GET",
            "/v1/oid4vp/request/{session}",
            "OpenID4VP request_uri: a swiyu / EUDI wallet fetches the signed request object; the              wallet signs no Trust Task (VTI-OPS-095). Its twin is              vtc/join-requests/wallet-session/start/0.1",
        ),
        (
            "POST",
            "/v1/oid4vp/response/{session}",
            "OpenID4VP response_uri: the wallet posts its encrypted direct_post.jwt answer              (VTI-OPS-095). Its twin is vtc/join-requests/wallet-session/status/0.1",
        ),
    ];

    /// A WebAuthn ceremony step: the browser's `navigator.credentials` API
    /// round-trips through `fetch`, so these can never be signed documents —
    /// there is no key to sign with until the ceremony mints or proves one.
    fn is_public_health_or_webauthn(method: &str, path: &str) -> bool {
        const PUBLIC: &[(&str, &str)] = &[
            ("GET", "/v1/community/public-profile"),
            ("GET", "/v1/community/did-qr.svg"),
            ("GET", "/v1/status-lists/{purpose}"),
        ];
        PUBLIC.contains(&(method, path))
            || path.starts_with("/v1/health")
            || path.contains("passkey")
    }

    /// Every REST route this service mounts is either a deliberate, reasoned
    /// exception ([`REST_EXCEPTIONS`]) or self-evidently fine
    /// ([`is_public_health_or_webauthn`]). A route that is neither — a new
    /// one nobody reasoned about, or an old one regressing back from
    /// Trust-Task-only — fails here by name.
    #[test]
    fn every_mounted_rest_route_is_justified() {
        let spec = openapi_spec();
        let mut unjustified = Vec::new();
        for (path, item) in &spec.paths.paths {
            for (method, op) in [
                ("GET", &item.get),
                ("POST", &item.post),
                ("PATCH", &item.patch),
                ("DELETE", &item.delete),
                ("PUT", &item.put),
            ] {
                if op.is_none() {
                    continue;
                }
                if is_public_health_or_webauthn(method, path) {
                    continue;
                }
                if REST_EXCEPTIONS
                    .iter()
                    .any(|(m, p, _)| *m == method && *p == path)
                {
                    continue;
                }
                unjustified.push(format!("{method} {path}"));
            }
        }
        unjustified.sort();
        assert!(
            unjustified.is_empty(),
            "these REST routes are mounted but not reasoned about — add each to              REST_EXCEPTIONS with the reason it still needs a dedicated REST mount, or confirm              it belongs in `is_public_health_or_webauthn` and extend that instead:\n  {}",
            unjustified.join("\n  ")
        );

        let stale: Vec<&str> = REST_EXCEPTIONS
            .iter()
            .filter(|(m, p, _)| {
                !spec
                    .paths
                    .paths
                    .get(*p)
                    .is_some_and(|item| op_for(item, m).is_some())
            })
            .map(|(_, p, _)| *p)
            .collect();
        assert!(
            stale.is_empty(),
            "REST_EXCEPTIONS entries for routes that are no longer mounted — remove them:              {stale:?}"
        );
    }

    fn op_for<'a>(
        item: &'a utoipa::openapi::path::PathItem,
        method: &str,
    ) -> Option<&'a utoipa::openapi::path::Operation> {
        match method {
            "GET" => item.get.as_ref(),
            "POST" => item.post.as_ref(),
            "PATCH" => item.patch.as_ref(),
            "DELETE" => item.delete.as_ref(),
            "PUT" => item.put.as_ref(),
            _ => None,
        }
    }

    // ── Route-posture backstop (P2.6) ──────────────────────────────────────
    //
    // The router is assembled across two chains (`build_api_chain`,
    // `build_unauth_routes`) and auth posture is enforced by per-handler
    // extractors, so whether a route is authenticated — and, if not, whether it
    // sits behind the rate-limiter — isn't locally legible at any one site. That
    // is exactly how the P0.5 misplacement slipped in (attacker-driven crypto
    // POSTs left on the unauthenticated 1 MiB / no-limiter main chain).
    //
    // These tests turn the OpenAPI spec (the route inventory + each op's
    // `security` requirement) into a posture assertion: **every** unauthenticated
    // operation must be explicitly classified as either governed (the
    // rate-limited, 64 KiB `build_unauth_routes` chain) or an approved public
    // exception. A new unauthenticated route fails the suite until it is
    // classified, and a route that flips its auth gate breaks the matching
    // allowlist — making the P0.5 regression class impossible to land silently.

    /// Unauthenticated operations that ride the governed chain
    /// (`build_unauth_routes`): tower-governor rate limit + [`UNAUTH_BODY_SIZE`]
    /// body cap. Attacker-driven crypto / IO belongs here.
    const GOVERNED_UNAUTH: &[(&str, &str)] = &[
        // `auth/refresh/0.1` keeps its dedicated mount: it is also the admin
        // console's cookie-bound session renewal, which has no
        // signed-document equivalent. `auth/challenge/0.1` and
        // `auth/authenticate/0.1` lost theirs — `vta_sdk::auth_light` (and
        // `cnm vetting`'s bearer-session login through it) now signs
        // `auth/challenge/0.1` / `authenticate/0.2` documents against the
        // single Trust Task endpoint below instead (#1858). First-admin
        // onboarding (`install/claim/*`, `admin/bootstrap`) and
        // cross-community recognition (`auth/recognise/*`) moved there too —
        // neither has a dedicated REST mount any more.
        ("POST", "/v1/auth/refresh"),
        ("POST", "/v1/auth/passkey-login/start"),
        ("POST", "/v1/auth/passkey-login/finish"),
        // The single Trust Task document endpoint — the holder-facing join
        // ceremony (submit/accept/manifest/status) dispatches internally by
        // document `type`, and so, since this batch, does every pre-session
        // install/bootstrap/recognise verb (plus `auth/authenticate/{0.2,0.3}`
        // and `auth/refresh/0.2`, alongside the `auth/*` REST mounts above).
        ("POST", "/v1/trust-tasks"),
        // Redeem a credential offer over HTTPS (`credential-exchange/request`):
        // the key-binding proof is the authority, and the governor + body cap
        // bound what an unauthenticated caller can make it verify.
        ("POST", "/v1/credential-exchange/request"),
        // A wallet's OpenID4VP calls for a wallet session: unauthenticated by
        // the protocol, so behind the governor. The session id is unguessable
        // and single-use, and the response decrypts only under its key.
        ("GET", "/v1/oid4vp/request/{session}"),
        ("POST", "/v1/oid4vp/response/{session}"),
    ];

    /// Unauthenticated operations intentionally left OFF the governed chain
    /// (public reads). Each is a deliberate decision recorded here so a *new*
    /// unauthenticated route can't quietly join this set.
    const PUBLIC_UNGOVERNED: &[(&str, &str)] = &[
        // Public, cacheable community metadata — no secrets, cheap to serve.
        ("GET", "/v1/community/public-profile"),
        // The community DID as an SVG QR code — the same public DID, drawn.
        ("GET", "/v1/community/did-qr.svg"),
        // (The join manifest is now the `join-requests/manifest/1.0` Trust
        // Task verb on `POST /v1/trust-tasks`, not a bespoke public GET.)
        // Verifier-facing status list — public by the W3C BitstringStatusList model.
        ("GET", "/v1/status-lists/{purpose}"),
    ];

    /// Collect every documented operation as `(METHOD, path, secured)` where
    /// `secured` reflects the op's OpenAPI `security` requirement (bearer JWT).
    fn documented_ops() -> Vec<(&'static str, String, bool)> {
        let spec = openapi_spec();
        let mut ops = Vec::new();
        for (path, item) in &spec.paths.paths {
            for (method, op) in [
                ("GET", &item.get),
                ("POST", &item.post),
                ("PATCH", &item.patch),
                ("DELETE", &item.delete),
                ("PUT", &item.put),
            ] {
                if let Some(op) = op {
                    let secured = op.security.as_ref().map(|s| !s.is_empty()).unwrap_or(false);
                    ops.push((method, path.clone(), secured));
                }
            }
        }
        ops
    }

    fn in_allowlist(list: &[(&str, &str)], method: &str, path: &str) -> bool {
        list.iter().any(|(m, p)| *m == method && *p == path)
    }

    /// The core P0.5 backstop: every unauthenticated operation is classified,
    /// and every authenticated operation stays off the governed unauth chain.
    #[test]
    fn every_unauthenticated_route_is_classified() {
        for (method, path, secured) in documented_ops() {
            let governed = in_allowlist(GOVERNED_UNAUTH, method, &path);
            let public = in_allowlist(PUBLIC_UNGOVERNED, method, &path);
            if secured {
                assert!(
                    !governed,
                    "{method} {path} requires a bearer JWT but is listed on the unauthenticated \
                     governed chain — an authenticated route must not sit in GOVERNED_UNAUTH"
                );
            } else {
                assert!(
                    governed || public,
                    "{method} {path} is UNAUTHENTICATED but unclassified — add it to the governed \
                     unauth chain (GOVERNED_UNAUTH) or, if it is a deliberate public endpoint, to \
                     PUBLIC_UNGOVERNED. (This is the P0.5 backstop: an unauth route must never \
                     silently land on the 1 MiB no-limiter main chain.)"
                );
                assert!(
                    !(governed && public),
                    "{method} {path} is in both GOVERNED_UNAUTH and PUBLIC_UNGOVERNED — pick one"
                );
            }
        }
    }

    /// The allowlists can't drift: every entry must still be a documented,
    /// unauthenticated operation (so a removed/renamed/now-authenticated route
    /// can't leave a stale exception behind).
    #[test]
    fn posture_allowlists_have_no_stale_entries() {
        let ops = documented_ops();
        let is_unauth_op = |method: &str, path: &str| {
            ops.iter()
                .any(|(m, p, secured)| *m == method && p == path && !secured)
        };
        for (method, path) in GOVERNED_UNAUTH.iter().chain(PUBLIC_UNGOVERNED) {
            assert!(
                is_unauth_op(method, path),
                "posture allowlist entry {method} {path} is not a documented unauthenticated \
                 operation — remove it or fix the path/method"
            );
        }
    }
}