kagi-vault 0.1.5

Encrypted secrets and environment variable manager for teams — a secure, team-ready dotenv alternative with per-service isolation
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
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
# Kagi Remote Sync Server

This document is the implementation spec for Kagi's self-hosted remote sync
server.

The main goal is simple: a user can sync encrypted Kagi state without committing
`.kagi/` to Git. The server stores and transfers encrypted project state, but it
must never be able to decrypt env values.

## Decisions

- Server: built into this Rust binary as `kagi serve`.
- Packaging: keep one `kagi` binary for the first version; split CLI and server
  by internal modules, not by repo or product.
- HTTP framework: Axum on Tokio.
- Database: SQLite through SQLx.
- HTTP client: Reqwest with rustls.
- Transport: every non-public request and response is encrypted by Kagi with
  age, even when the URL is plain `http://`.
- Auth: project tokens for project-scoped operations; admin tokens for server
  management and project approval.
- Admin token is generated automatically on first server startup and printed to
  stdout. The admin stores it via `kagi remote login` or in `KAGI_ADMIN_TOKEN`
  for CLI operations. After login, the remote URL is saved locally so admin
  commands can omit `--remote`.
- There is no admission token. New projects go through a pending-request
  workflow: a user sends a `remote register` request, and an admin approves it.
- Project tokens are generated by the server and contain `remote`, `project_id`,
  `server_fingerprint`, `token_id`, and capabilities.
- The server stores token hashes only. It never stores token plaintext.
- `.kagi/` is committed in Git mode and ignored in server mode.
- Rate limiting: per-IP rate limiting using `tower_governor` protects all
  endpoints from brute-force attacks.
- Server code is gated behind a `server` Cargo feature. Users who only need the
  CLI can compile without it, omitting Axum, SQLx, and all server handlers.
- Deployment mode: explicitly single-tenant. One server instance serves one team.
  Do not use a single instance for unrelated tenants without additional isolation.
- Storage limits: each project is capped at 1000 files and 50 MB of encrypted
  content to prevent a single project from exhausting disk space.

## Feature Flags

```text
server (default)   — include the Axum server, SQLx migrations, and rate limiting
--no-default-features  — CLI only: no serve, push, pull, status, project, or remote commands
```

Install CLI only (faster compile, smaller binary):

```bash
cargo install kagi-vault --no-default-features
```

Install full binary with server:

```bash
cargo install kagi-vault
```

Server-only dependencies (`axum`, `tower`, `tower-http`, `tower_governor`,
`sqlx`, `subtle`) are marked `optional = true` and pulled in only when the
`server` feature is enabled. `hmac` and `sha2` are shared because the CLI also
verifies response MACs.

## Recommended Rust Stack

Use current, popular Rust crates. Add them through `cargo add` during
implementation so the lockfile resolves the newest compatible patches.

```bash
cargo add tokio --features rt-multi-thread,macros,signal,net,time
cargo add axum
cargo add tower tower-http --features tower-http/trace,tower-http/timeout,tower-http/limit
cargo add tower_governor
cargo add sqlx --no-default-features --features runtime-tokio,sqlite,migrate,macros,json,time
cargo add reqwest --no-default-features --features json,rustls-tls
cargo add tracing tracing-subscriber
cargo add time url hmac sha2 subtle
```

Version targets verified on 2026-05-27:

- `axum = "0.8"` for HTTP routing and JSON handlers.
- `tokio = "1"` for async runtime.
- `sqlx = "0.9"` with SQLite and migrations.
- `reqwest = "0.13"` with `json` and `rustls-tls`.
- `tower_governor = "0.8"` for per-IP rate limiting.

Do not add a separate Node/Bun server. Do not add Postgres for the first
version. SQLite is enough for a lightweight self-hosted server and is easier to
backup.

## Binary Boundary

The first version should ship one command:

```bash
kagi
```

`kagi serve` starts the server, while commands such as `kagi init`, `kagi remote push`,
and `kagi remote pull` remain normal CLI commands. Do not create a separate user-facing
`kagi-server` package for the first version.

This keeps install and version management simple:

```bash
cargo install kagi-vault          # full binary with server
cargo install kagi-vault --no-default-features  # CLI only
kagi serve
kagi remote register --remote http://127.0.0.1:8787
```

The `server` Cargo feature gates all server-only code. When disabled, the
following are completely omitted from compilation:

- `kagi-server` crate and all Axum routes
- `kagi-sync` domain module (envelope, project state, token, remote config)
- `kagi-sync` infrastructure (`remote_client.rs`, `remote_envelope.rs`, `remote_local.rs`)
- `kagi-server/sqlite_remote.rs` (server-side repository)
- `kagi serve`, `push`, `pull`, `status`, `project`, `remote` CLI commands
- `axum`, `tower`, `tower-http`, `tower_governor`, `sqlx`, `subtle`

The code still needs a hard internal boundary:

- `kagi-app` owns Clap arguments, terminal output, and command dispatch.
- `kagi-server` owns Axum routes, HTTP errors, server startup, request limits, and rate
  limiting.
- `kagi-app` must not call `kagi_server::routes` or depend on Axum types.
- `kagi-server` must not depend on `kagi-app` formatting or Clap types.
- Shared request/response structs live in `kagi-sync` domain.
- CLI-side HTTP code lives in `kagi-sync/src/infrastructure/remote_client.rs`.
- Server-side SQLite code lives in `kagi-server/src/sqlite_remote.rs`.

If the server later becomes a larger product, add a second binary while keeping
shared code in the same crate workspace:

```text
crates/kagi-app/src/main.rs
crates/kagi-server/src/main.rs
```

Do not start there. The first implementation should prefer one installable
binary and clear crate boundaries.

## Module Layout

Keep the existing clean-architecture split across crates.

```text
crates/kagi-sync/src/domain/
  envelope.rs          request/response envelope structs
  project_state.rs     ProjectState, ProjectFile, path validation
  project_token.rs     token payload, capabilities, ids
  remote_config.rs     sync settings and local metadata structs

crates/kagi-app/src/application/
  remote_sync/
    push.rs
    pull.rs
    status.rs
    join.rs
    tokens.rs

crates/kagi-sync/src/infrastructure/
  remote_client.rs     reqwest client and envelope exchange
  remote_envelope.rs   age transport encryption
  remote_local.rs      local token/revision/fingerprint storage

crates/kagi-server/src/
  sqlite_remote.rs     SQLx-backed server repository
  server/
    mod.rs             server startup, rate limiting, body limits
    routes.rs          Axum handlers and router
    state.rs           AppState, token hashing, key management
    errors.rs          server error types and responses
```

The server may depend on infrastructure and application services. Domain structs
must not depend on Axum, SQLx, or filesystem paths.

## Commands

### Start Server

Requires the `server` feature (enabled by default).

```bash
kagi serve --db ./kagi.db --key-file ./server.key.json --bind 127.0.0.1:8787
```

Defaults:

- `--bind 127.0.0.1:8787`
- `--db $KAGI_HOME/server/kagi.db`
- `--key-file $KAGI_HOME/server/server.key.json`
- `--max-body 10mb`

`kagi serve` creates the database, runs migrations, creates the server transport
key if missing, and prints:

```text
kagi: server key fingerprint kgs_...
kagi: generated admin token: kagi_admin_v1_...
kagi: store this in KAGI_ADMIN_TOKEN env var or run:
kagi:   kagi remote login --remote http://127.0.0.1:8787 --token <token>
kagi: listening on http://127.0.0.1:8787
```

On the first startup with a fresh database, the server generates a single admin
token, hashes it, stores the hash in `admin_tokens`, and prints the plaintext
token exactly once. The admin must save this token securely. On subsequent
starts, the server checks for an existing admin token and does not generate a
new one.

If `--bind 0.0.0.0:...` is used, print a warning when HTTPS is not configured.
Application-layer encryption still protects payloads, but HTTPS should still be
recommended for metadata and operational safety.

### Admin Token

The admin token is a bearer token with `capabilities: ["admin"]`.

Shape:

```text
kagi_admin_v1_<base64url-json-payload>.<base64url-secret>
```

Decoded payload:

```json
{
  "version": 1,
  "remote": "admin",
  "project_id": "admin",
  "token_id": "kat_x",
  "server_fingerprint": "kgs_x",
  "capabilities": ["admin"]
}
```

Rules:

- The server stores only a keyed hash of the full admin token string.
- The admin token is printed once on first startup and never again.
- The CLI **must** store the admin token in the **OS keychain** (macOS
  Keychain, Windows Credential Manager, Linux GNOME/KDE Wallet).
- There is **no file fallback** for admin tokens. If the OS keychain is
  unavailable, `kagi remote login` fails with a clear error.
- `KAGI_ADMIN_TOKEN` env var can be used as an override, but interactive login
  should prefer the keychain.
- Admin tokens are hashed with the same `HMAC-SHA256(server_token_pepper, ...)`
  mechanism as project tokens.

### Save Admin Token

```bash
kagi remote login --remote http://127.0.0.1:8787 --token kagi_admin_v1_...
```

Flow:

1. CLI fetches `GET /v1/server-key` to verify the remote and obtain its
   fingerprint.
2. CLI writes the admin token to the OS keychain under the service
   `dev.kagi.kagi` and account `admin:{fingerprint}`.
3. CLI prints confirmation.

After login, the remote URL is saved to `$KAGI_HOME/admins/{fingerprint}/remote.json`
so admin commands (`kagi remote projects`, `kagi remote approve`, `kagi remote remove`)
can omit `--remote`.
The admin token itself is read from the OS keychain automatically.

### Initialize Local Project

```bash
kagi init --nested --envs
```

This creates a local `.kagi/` directory with `kagi.json`, `access.json`, and
empty env stores. It does not interact with the server.

Server mode `.gitignore` entries:

```gitignore
.kagi/
.env
.env.*
!.env.example
```

### Request Project Registration

```bash
kagi remote register --remote http://127.0.0.1:8787
```

Flow:

1. CLI fetches `GET /v1/server-key`.
2. CLI pins the expected server fingerprint.
3. CLI creates local member identity if needed.
4. CLI sends encrypted `create_project_request` to `/v1/projects/requests`.
5. Server stores the request in `project_requests` with `status = 'pending'`.
6. CLI saves sync config to `kagi.json` (`mode: "server"`, `remote: url`).
7. CLI prints: `requested project kgp_xxx, waiting for admin approval`.

The project does not exist on the server until an admin approves it.

### List and Manage Projects (Admin)

```bash
# Save admin token to OS keychain (one-time setup)
kagi remote login --remote http://127.0.0.1:8787 --token kagi_admin_v1_...

# List pending and active projects (remote is optional after login)
kagi remote projects
kagi remote projects --remote http://127.0.0.1:8787

# Approve a pending project request (remote is optional after login)
kagi remote approve kgp_xxx
kagi remote approve kgp_xxx --remote http://127.0.0.1:8787

# Delete a project (remote is optional after login)
kagi remote remove kgp_xxx
kagi remote remove kgp_xxx --remote http://127.0.0.1:8787
```

`kagi remote approve` flow:

1. CLI sends encrypted request to `/v1/projects/requests/{id}/approve`.
2. Server authenticates the admin token.
3. Server looks up the pending request.
4. Server creates the project in `projects` table.
5. Server inserts the requester into `project_members` with `role = 'admin'`.
6. Server generates a full-capability project token for the requester.
7. Server stores the token hash in `project_tokens`.
8. Server deletes the request from `project_requests`.
9. Server returns the plaintext project token inside the encrypted response.
10. CLI prints success with the project token. The admin gives that token to the
    requester once.
11. The requester runs `kagi remote pull <project-token>` to store the token locally
    and bootstrap remote sync.

`kagi remote remove` can be called by either a server admin or the project's
admin (any member with `role = 'admin'` in `project_members`). Deleting a
project cascades to tokens, files, and members.

### Daily Sync

```bash
kagi remote push
kagi remote pull
kagi remote status
```

- `kagi remote push` uploads local encrypted project state.
- `kagi remote pull` downloads encrypted project state and writes `.kagi/` atomically.
- `kagi remote status` compares local and remote revisions.

A user can also pull with only a project token:

```bash
kagi remote pull <project-token>
```

The token contains the remote URL, project id, token id, server fingerprint, and
capabilities. No separate project id argument is needed.

Pulling encrypted state does not mean the user can decrypt env values. If the
local device is not an approved member, Kagi should print a short message asking
the user to run:

```bash
kagi member request --name alice-laptop
```

### Member Management

```bash
kagi member list
kagi member request --name alice-laptop
kagi member approve kgm_alice
kagi member remove kgm_alice
```

`kagi member request` creates a local age identity and sends a `/join` request.
`kagi member approve` issues a token for the pending member.
`kagi member remove` rotates the project key and revokes the member's token.

## IDs

Use NanoID-style random ids with stable prefixes:

```text
kgp_...   project id
kgm_...   member id
kgt_...   project token id
kgr_...   request id
kgs_...   server key id / fingerprint
kat_...   admin token id
```

`project_id` is public. It locates the project but does not grant access.

## Project Token

Token shape:

```text
kagi_proj_v1_<base64url-json-payload>.<base64url-secret>
```

Decoded payload:

```json
{
  "version": 1,
  "remote": "http://kagi.internal:8787",
  "project_id": "kgp_x",
  "token_id": "kgt_x",
  "server_fingerprint": "kgs_x",
  "capabilities": ["pull", "join"]
}
```

Rules:

- The secret part is 32 random bytes encoded as base64url without padding.
- The server generates project tokens.
- The server returns token plaintext only inside encrypted responses.
- The server stores only a keyed hash of the full token string.
- The server-side token pepper is stored in `server.key.json`, not SQLite.
- The payload capabilities are informational for the CLI. The server always
  checks capabilities from SQLite.
- If the payload is edited, the full-token hash will not match.
- `KAGI_PROJECT_TOKEN` and `KAGI_PROJECT_TOKEN_FILE` are allowed for CI.

Capabilities:

```text
pull    read encrypted ProjectState
join    create or replace own pending member request
push    upload ProjectState when base_revision matches
rotate  issue/revoke project tokens and approve/remove members
```

Recommended token types:

```text
owner/member token: pull, join, push, rotate
onboarding token:  pull, join
CI token:          pull
```

## Local-only State

Do not store these in `.kagi/`:

- project token
- admin token
- local project key cache
- age identity private key
- pinned server fingerprint
- local revision
- pending remote token grants
- pending remote token revocations
- server private transport key

Store local sync metadata under the existing local Kagi data directory:

```text
$KAGI_HOME/projects/<project_id>/remote.json
```

Shape:

```json
{
  "version": 1,
  "project_id": "kgp_x",
  "remote": "http://kagi.internal:8787",
  "server_key_id": "kgs_x",
  "server_fingerprint": "kgs_x",
  "local_revision": 12,
  "last_pulled_at": "2026-05-27T10:00:00Z",
  "last_pushed_at": "2026-05-27T10:01:00Z"
}
```

Store admin remote config (saved automatically on `kagi remote login`):

```text
$KAGI_HOME/admins/<server_fingerprint>/remote.json
```

Shape:

```json
{
  "version": 1,
  "remote": "http://kagi.internal:8787",
  "server_fingerprint": "kgs_x"
}
```

Store project token plaintext in the OS keychain when possible. On Linux without
a usable keychain, use the existing trusted-device local store and make it clear
that it is local to that machine.

Admin tokens are **never** stored in local files. They live exclusively in the
OS keychain under the account `admin:{server_fingerprint}`.

Local cleanup:

- When a project token is revoked locally or a project is removed, clear
  project-local secrets and metadata with `RemoteLocalStore::clear_project_data`
  and `KeyManager::clear_cached_project_key` so stale key material does not
  remain on disk.

## `.kagi` State

Use a new schema version. Backward compatibility is not required.

`.kagi/kagi.json`:

```json
{
  "version": "3",
  "project_id": "kgp_x",
  "services": {
    "api/development": {
      "file": "secrets/api/development.enc"
    }
  },
  "settings": {
    "nested": true,
    "envs": ["development", "test", "production"],
    "default_env": "development",
    "sync": {
      "mode": "server",
      "remote": "http://kagi.internal:8787"
    }
  }
}
```

`.kagi/access.json`:

```json
{
  "version": "3",
  "members": [
    {
      "member_id": "kgm_owner",
      "name": "alice",
      "recipient": "age1...",
      "status": "active",
      "token_id": "kgt_owner",
      "wrapped_key": "base64-age-message",
      "wrapped_project_token": "base64-age-message"
    }
  ]
}
```

`wrapped_project_token` is optional in Git mode. In server mode it should be
present for active members except legacy local-only owner bootstrap during the
first push.

## ProjectState

The server sync unit is `ProjectState`.

```json
{
  "project_id": "kgp_x",
  "revision": 12,
  "kagi_json": "{...}",
  "access_json": "{...}",
  "files": [
    {
      "path": "secrets/api/development.enc",
      "content": "{...}",
      "sha256": "hex..."
    }
  ]
}
```

Rules:

- `revision` is the server revision returned by the last successful pull or push.
- `kagi_json`, `access_json`, and `files[*].content` are JSON text blobs.
- The server stores these blobs but does not decrypt env values.
- The CLI validates full config/access/encrypted-store shape after pull.
- The server validates `project_id`, token, revision, body size, and path safety.
- File paths must be relative, must start with `secrets/`, must end with `.enc`,
  and must not contain `..`, empty segments, backslashes, or absolute paths.
- `sha256` is optional for MVP but useful for integrity checks and status output.

## Application-layer Transport Encryption

HTTPS is recommended, but Kagi does not rely on HTTPS for request/response body
confidentiality. Every remote operation except `GET /v1/server-key` uses an age
envelope.

Server key file:

```json
{
  "version": 1,
  "server_key_id": "kgs_x",
  "age_identity": "AGE-SECRET-KEY-...",
  "token_pepper": "base64url-32-random-bytes",
  "created_at": "2026-05-27T10:00:00Z"
}
```

Rules:

- Store the key file with `0600` permissions on Unix.
- Do not store raw `age_identity` or `token_pepper` in SQLite.
- Server fingerprint is derived from the public age recipient.
- CLI pins the expected fingerprint outside `.kagi/`.
- Non-local HTTP requires the fingerprint from the project token or
  `--server-fingerprint`.
- Interactive trust-on-first-use is allowed only for localhost development.
- Noninteractive first use must provide an expected fingerprint.

Request envelope:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "server_key_id": "kgs_x",
  "response_recipient": "age1...",
  "ciphertext": "base64-age-message"
}
```

Request plaintext:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "issued_at": "2026-05-27T10:00:00Z",
  "operation": "push",
  "method": "POST",
  "path": "/v1/projects/kgp_x/push",
  "project_id": "kgp_x",
  "token": "kagi_proj_v1_...",
  "response_recipient": "age1..."
}
```

Response envelope:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "mac": "base64url-hmac",
  "ciphertext": "base64-age-message"
}
```

Success response plaintext:

```json
{
  "ok": true,
  "request_id": "kgr_x",
  "data": {}
}
```

Error response plaintext:

```json
{
  "ok": false,
  "request_id": "kgr_x",
  "error": {
    "code": "conflict",
    "message": "remote revision changed; run kagi remote pull first"
  }
}
```

Checks:

- Server checks `request_id` in envelope and plaintext match.
- Server checks `method`, `path`, `operation`, and `project_id` after decrypting.
- Server checks `response_recipient` in the encrypted plaintext matches the
  envelope before encrypting a response to it.
- Server rejects `issued_at` outside a small clock window, for example 5 minutes.
- For retry-safe state updates, `push`, `join`, and `token_issue` treat repeated
  requests with the same `{project_id, request_id, event_type}` as duplicates
  and return `409 conflict`.
- CLI rejects responses where `request_id` does not match the request.
- CLI verifies the response `mac` before trusting decrypted data for all
  token-authenticated operations.
- CLI verifies the decrypted response `request_id` before using `data`.
- CLI rejects pulled revisions older than local revision unless a reset command is
  added later.
- Token plaintext is never placed in HTTP headers, query strings, or unencrypted
  bodies.

Public errors are allowed only before decryption, for example malformed JSON,
unknown `server_key_id`, or body too large.

Response MAC input:

```text
HMAC-SHA256(project_or_admin_token, "kagi-response-v1" || request_id || ciphertext)
```

## Token Hashing

Project tokens and admin tokens are random high-entropy bearer tokens, so use a
keyed hash instead of an expensive password hash.

Hash input:

```text
HMAC-SHA256(server_token_pepper, full_token_string)
```

Store:

```text
kh1:<base64url-hmac>
```

Rules:

- Compare hashes with constant-time equality from `subtle`.
- Never log token plaintext.
- Never include token plaintext in errors.
- Rotate `server_token_pepper` only with a migration plan, because existing token
  hashes depend on it.

## Rate Limiting

The server uses `tower_governor` with per-IP rate limiting to protect against
brute-force attacks on all endpoints.

Configuration:

```text
per_second: 2
burst_size: 30
```

This allows bursts of up to 30 requests and then replenishes one element every
500ms, based on peer IP address. In testing, a separate generous configuration
is used to avoid interfering with test execution.

When rate limited, the server returns `429 Too Many Requests` with optional
`x-ratelimit-*` headers.

## SQLite Storage

Use SQLx migrations under `crates/kagi-server/migrations/`.

Connection setup:

```sql
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA busy_timeout = 5000;
```

Use a small pool:

```text
max_connections = 5
min_connections = 1
acquire_timeout = 5s
```

Schema:

```sql
create table schema_migrations (
  version integer primary key,
  applied_at text not null
);

create table server_keys (
  server_key_id text primary key,
  public_recipient text not null,
  fingerprint text not null,
  active integer not null,
  created_at text not null
);

create table projects (
  project_id text primary key,
  revision integer not null,
  state_hash text,
  created_at text not null,
  updated_at text not null
);

create table project_tokens (
  project_id text not null,
  token_id text not null,
  token_hash text not null,
  capabilities_json text not null,
  member_id text,
  status text not null,
  created_at text not null,
  activated_at text,
  revoked_at text,
  last_used_at text,
  primary key (project_id, token_id),
  foreign key (project_id) references projects(project_id) on delete cascade
);

create table project_files (
  project_id text not null,
  path text not null,
  content text not null,
  sha256 text,
  updated_at text not null,
  primary key (project_id, path),
  foreign key (project_id) references projects(project_id) on delete cascade
);

create table join_requests (
  project_id text not null,
  member_id text not null,
  request_token_id text not null,
  name text not null,
  normalized_name text not null,
  recipient text not null,
  status text not null,
  created_at text not null,
  updated_at text not null,
  primary key (project_id, member_id),
  foreign key (project_id) references projects(project_id) on delete cascade
);

create unique index join_requests_pending_name_unique
  on join_requests(project_id, normalized_name)
  where status = 'pending';

create table admin_tokens (
  token_id text primary key,
  token_hash text not null,
  capabilities_json text not null,
  status text not null default 'active',
  created_at text not null,
  last_used_at text
);

create table project_requests (
  project_id text primary key,
  requester_member_id text not null,
  requester_name text not null,
  requester_recipient text not null,
  kagi_json text,
  status text not null default 'pending',
  created_at text not null,
  updated_at text not null
);

create index idx_project_requests_status on project_requests(status);

create table project_members (
  project_id text not null,
  member_id text not null,
  name text not null,
  role text not null default 'member',
  status text not null,
  recipient text,
  created_at text not null,
  updated_at text not null,
  primary key (project_id, member_id),
  foreign key (project_id) references projects(project_id) on delete cascade
);
```

Allowed token statuses:

```text
active
pending_activation
revoked
```

Allowed member request statuses:

```text
pending
accepted
cancelled
```

Database backup safety:

- SQLite backups contain encrypted project state, public member metadata, public
  member requests, token hashes, and server public key metadata.
- SQLite backups must not contain env plaintext, project keys, token plaintext,
  member private identities, raw server private identity, or token pepper.

## Backup and Restore

### What must be backed up

1. **SQLite database file** (`kagi.db` or the path passed to `--db`)
2. **SQLite WAL and SHM files** (`kagi.db-wal`, `kagi.db-shm`) when the database
   is in WAL mode. Kagi enables WAL by default (`PRAGMA journal_mode = WAL`).
3. **Server key file** (`server.key.json` or the path passed to `--key-file`)

The server key file contains the age identity and the token pepper. Without it,
all existing token hashes become unverifiable and the server cannot decrypt
incoming envelopes or generate responses.

### What must NOT be shared publicly

- Server key file (`server.key.json`)
- Database backups (`.db`, `.db-wal`, `.db-shm`)
- Server logs containing request IDs, member metadata, or IP addresses
- Admin token plaintext
- Project token plaintext

### Recommended backup commands

For a running server, use SQLite's online backup API to create a consistent snapshot
without stopping the server:

```bash
# Online backup (safe while server is running)
sqlite3 kagi.db ".backup to /backup/kagi-$(date +%Y%m%d-%H%M%S).db"

# Or copy the database file while the server is stopped
systemctl stop kagi
cp kagi.db kagi.db-wal kagi.db-shm /backup/
cp server.key.json /backup/
systemctl start kagi
```

Automated backup example (daily cron):

```bash
#!/bin/bash
BACKUP_DIR="/backup/kagi/$(date +%Y%m%d)"
mkdir -p "$BACKUP_DIR"
sqlite3 /var/lib/kagi/kagi.db ".backup to $BACKUP_DIR/kagi.db"
cp /etc/kagi/server.key.json "$BACKUP_DIR/"
chmod -R 700 "$BACKUP_DIR"
```

### Restore flow

1. Stop the server:
   ```bash
   systemctl stop kagi
   ```

2. Restore the database and WAL files:
   ```bash
   cp /backup/kagi.db /var/lib/kagi/kagi.db
   cp /backup/kagi.db-wal /var/lib/kagi/kagi.db-wal
   cp /backup/kagi.db-shm /var/lib/kagi/kagi.db-shm
   ```

3. Restore the server key file:
   ```bash
   cp /backup/server.key.json /etc/kagi/server.key.json
   chmod 600 /etc/kagi/server.key.json
   ```

4. Start the server:
   ```bash
   systemctl start kagi
   ```

5. Run a health check:
   ```bash
   curl http://127.0.0.1:8787/
   ```

6. Verify from a client:
   ```bash
   kagi remote status
   kagi remote pull
   ```

### Server key rotation impact

If the server key file is lost or compromised, you must generate a new one.
Regenerating the server key changes:

- Server fingerprint (`kgs_...`)
- Token pepper

**Impact on existing tokens:**

All existing admin and project tokens are pinned to the old server fingerprint.
After key regeneration, the server cannot verify old token hashes because the
pepper changed. Every admin and project member must obtain a new token.

Recovery steps after key loss:

1. Stop the server.
2. Delete the old server key file and start the server to generate a new one.
3. The server will print a new admin token on first startup.
4. Re-run `kagi remote login` with the new admin token.
5. Re-create all projects and re-issue all project tokens.
6. Distribute new project tokens to all members.

Because of this impact, keep the server key file in a secure, backed-up
location with strict file permissions (`0600` on Unix).

## Database Operations

All write operations use one SQL transaction.

For `push`, use an immediate transaction to serialize writes:

```sql
BEGIN IMMEDIATE;
```

Push transaction steps:

1. Load project row.
2. Authenticate token and check `push`.
3. Check `base_revision == projects.revision`.
4. Validate incoming `ProjectState`.
5. Replace all `project_files` rows for the project.
6. Update `projects.revision = revision + 1`.
7. Apply token activations.
8. Apply token revocations.
9. Mark accepted member requests.
10. Commit.

If any step fails, rollback the whole transaction.

Pull transaction steps:

1. Authenticate token and check `pull`.
2. Read project revision and files.
3. If token has `push` or `rotate`, include pending member requests.
4. Update `last_used_at`.

Join transaction steps:

1. Authenticate token and check `join`.
2. Normalize requested member name.
3. Reject duplicate pending normalized name.
4. Upsert the caller's request only when `(project_id, member_id)` belongs to
   the same `request_token_id`.
5. Return pending request metadata.

Token issue transaction steps:

1. Authenticate token and check `rotate`.
2. Generate `token_id` and token secret.
3. Store token hash with `pending_activation` unless this is an onboarding token.
4. Return plaintext token once inside encrypted response.

Token revoke transaction steps:

1. Authenticate token and check `rotate`.
2. Mark token ids as `revoked`.
3. Return revoked token ids.

Member removal should revoke tokens in the same push that uploads the rotated
project key. Prefer `push.token_revocations` for normal member removal. Use
`/tokens/revoke` only for emergency server-side token revocation where project
state does not change.

Project request creation steps:

1. Decrypt envelope.
2. Extract `project_id`, `requester_member_id`, `requester_name`,
   `requester_recipient`, `kagi_json` from plaintext payload.
3. Insert into `project_requests` with `status = 'pending'`.
4. Return `{"project_id": ..., "status": "pending"}` encrypted.

Project request approval steps:

1. Decrypt envelope.
2. Authenticate admin token via `authenticate_admin`.
3. Verify `capabilities` contains `"admin"`.
4. Look up the request by `project_id`.
5. Create the project in `projects` table.
6. Insert the requester into `project_members` with `role = 'admin'`.
7. Generate a full-capability project token for the requester.
8. Store the token hash in `project_tokens`.
9. Delete the request from `project_requests`.
10. Return `{"project_id": ..., "project_token": "kgt_...", "status": "active"}`
    encrypted.

Project deletion steps:

1. Decrypt envelope.
2. Authenticate token (either admin or project admin).
3. If project admin: check `project_members` table for `role = 'admin'`.
4. Delete project (CASCADE handles tokens, files, members, join_requests).
5. Return success.

## API

All endpoints return JSON. Only `GET /v1/server-key` is public plaintext.
Everything else takes and returns encrypted envelopes.

```text
GET  /v1/server-key
POST /v1/projects/requests
POST /v1/projects/requests/list
POST /v1/projects/requests/{project_id}/approve
POST /v1/projects/list
POST /v1/projects/{project_id}/push
POST /v1/projects/{project_id}/pull
POST /v1/projects/{project_id}/status
POST /v1/projects/{project_id}/join
POST /v1/projects/{project_id}/tokens/issue
POST /v1/projects/{project_id}/tokens/revoke
POST /v1/projects/{project_id}/delete
```

### GET /v1/server-key

Plain response:

```json
{
  "version": 1,
  "server_key_id": "kgs_x",
  "recipient": "age1...",
  "fingerprint": "kgs_x"
}
```

### POST /v1/projects/requests

Create a pending project registration request.

Encrypted plaintext request:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "issued_at": "2026-05-27T10:00:00Z",
  "operation": "create_project_request",
  "method": "POST",
  "path": "/v1/projects/requests",
  "project_id": "kgp_x",
  "token": null,
  "payload": {
    "requester_member_id": "kgm_x",
    "requester_name": "alice",
    "requester_recipient": "age1...",
    "kagi_json": "{...}"
  }
}
```

Encrypted success data:

```json
{
  "project_id": "kgp_x",
  "status": "pending"
}
```

### POST /v1/projects/requests/list

List pending project registration requests. Admin only.

Encrypted plaintext request:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "issued_at": "2026-05-27T10:00:00Z",
  "operation": "list_project_requests",
  "method": "POST",
  "path": "/v1/projects/requests/list",
  "token": "kagi_admin_v1_..."
}
```

Encrypted success data:

```json
{
  "requests": [
    {
      "project_id": "kgp_x",
      "requester_member_id": "kgm_x",
      "requester_name": "alice",
      "status": "pending",
      "created_at": "2026-05-27T10:00:00Z"
    }
  ]
}
```

### POST /v1/projects/requests/{project_id}/approve

Approve a pending project request and create the project. Admin only.

Encrypted plaintext request:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "issued_at": "2026-05-27T10:00:00Z",
  "operation": "approve_project_request",
  "method": "POST",
  "path": "/v1/projects/requests/kgp_x/approve",
  "token": "kagi_admin_v1_...",
  "remote": "http://127.0.0.1:8787"
}
```

Encrypted success data:

```json
{
  "project_id": "kgp_x",
  "project_token": "kagi_proj_v1_...",
  "status": "active"
}
```

### POST /v1/projects/list

List all active projects. Admin only.

Encrypted plaintext request:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "issued_at": "2026-05-27T10:00:00Z",
  "operation": "list_projects",
  "method": "POST",
  "path": "/v1/projects/list",
  "token": "kagi_admin_v1_..."
}
```

Encrypted success data:

```json
{
  "projects": [
    {
      "project_id": "kgp_x",
      "revision": 12,
      "created_at": "2026-05-27T10:00:00Z"
    }
  ]
}
```

### POST /v1/projects/{project_id}/push

Encrypted plaintext request:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "issued_at": "2026-05-27T10:00:00Z",
  "operation": "push",
  "method": "POST",
  "path": "/v1/projects/kgp_x/push",
  "project_id": "kgp_x",
  "token": "kagi_proj_v1_...",
  "base_revision": 12,
  "state": {
    "project_id": "kgp_x",
    "revision": 12,
    "kagi_json": "{...}",
    "access_json": "{...}",
    "files": []
  },
  "activate_token_ids": ["kgt_new_member"],
  "revoke_token_ids": ["kgt_removed_member"],
  "accepted_join_member_ids": ["kgm_new_member"]
}
```

Encrypted success data:

```json
{
  "revision": 13,
  "state_hash": "hex..."
}
```

Conflict error:

```json
{
  "code": "conflict",
  "message": "remote revision changed; run kagi remote pull first",
  "details": {
    "remote_revision": 13,
    "base_revision": 12
  }
}
```

### POST /v1/projects/{project_id}/pull

Encrypted plaintext request:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "issued_at": "2026-05-27T10:00:00Z",
  "operation": "pull",
  "method": "POST",
  "path": "/v1/projects/kgp_x/pull",
  "project_id": "kgp_x",
  "token": "kagi_proj_v1_...",
  "known_revision": 12
}
```

Encrypted success data:

```json
{
  "revision": 13,
  "state": {
    "project_id": "kgp_x",
    "revision": 13,
    "kagi_json": "{...}",
    "access_json": "{...}",
    "files": []
  },
  "join_requests": [
    {
      "member_id": "kgm_x",
      "name": "alice-laptop",
      "recipient": "age1...",
      "created_at": "2026-05-27T10:00:00Z"
    }
  ]
}
```

`join_requests` is returned only when the token has `push` or `rotate`.
Onboarding tokens should receive an empty list or no field.

### POST /v1/projects/{project_id}/status

Encrypted plaintext request:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "issued_at": "2026-05-27T10:00:00Z",
  "operation": "status",
  "method": "POST",
  "path": "/v1/projects/kgp_x/status",
  "project_id": "kgp_x",
  "token": "kagi_proj_v1_...",
  "local_revision": 12
}
```

Encrypted success data:

```json
{
  "remote_revision": 13,
  "local_revision": 12,
  "state": "behind",
  "pending_join_count": 1
}
```

Allowed `state` values:

```text
equal
ahead
behind
diverged
unknown
```

### POST /v1/projects/{project_id}/join

Encrypted plaintext request:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "issued_at": "2026-05-27T10:00:00Z",
  "operation": "join",
  "method": "POST",
  "path": "/v1/projects/kgp_x/join",
  "project_id": "kgp_x",
  "token": "kagi_proj_v1_...",
  "join_request": {
    "member_id": "kgm_x",
    "name": "alice-laptop",
    "recipient": "age1..."
  }
}
```

Encrypted success data:

```json
{
  "member_id": "kgm_x",
  "status": "pending"
}
```

### POST /v1/projects/{project_id}/tokens/issue

Used by `member invite` and `member approve`.

Encrypted plaintext request:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "issued_at": "2026-05-27T10:00:00Z",
  "operation": "token_issue",
  "method": "POST",
  "path": "/v1/projects/kgp_x/tokens/issue",
  "project_id": "kgp_x",
  "token": "kagi_proj_v1_...",
  "member_id": "kgm_x",
  "capabilities": ["pull", "join", "push", "rotate"],
  "status": "pending_activation"
}
```

Encrypted success data:

```json
{
  "token_id": "kgt_x",
  "project_token": "kagi_proj_v1_...",
  "status": "pending_activation"
}
```

For onboarding tokens, request:

```json
{
  "member_id": null,
  "capabilities": ["pull", "join"],
  "status": "active"
}
```

### POST /v1/projects/{project_id}/tokens/revoke

Emergency token revocation.

Encrypted plaintext request:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "issued_at": "2026-05-27T10:00:00Z",
  "operation": "token_revoke",
  "method": "POST",
  "path": "/v1/projects/kgp_x/tokens/revoke",
  "project_id": "kgp_x",
  "token": "kagi_proj_v1_...",
  "token_ids": ["kgt_x"]
}
```

Encrypted success data:

```json
{
  "revoked_token_ids": ["kgt_x"]
}
```

### POST /v1/projects/{project_id}/delete

Delete a project. Allowed for server admins or project admins.

Encrypted plaintext request:

```json
{
  "version": 1,
  "request_id": "kgr_x",
  "issued_at": "2026-05-27T10:00:00Z",
  "operation": "delete_project",
  "method": "POST",
  "path": "/v1/projects/kgp_x/delete",
  "project_id": "kgp_x",
  "token": "kagi_proj_v1_..."
}
```

Encrypted success data:

```json
{
  "deleted": true
}
```

## Join Flow

### Request Access

```bash
kagi remote pull <project-token>
kagi member request --name alice-laptop
```

The CLI creates a local age identity and member id, then sends `/join`.

Pending join state is local-only:

```json
{
  "version": 1,
  "project_id": "kgp_x",
  "remote": "http://kagi.internal:8787",
  "member_id": "kgm_x",
  "name": "alice-laptop",
  "recipient": "age1...",
  "server_fingerprint": "kgs_x"
}
```

### Approve Access

```bash
kagi remote pull
kagi member approve kgm_x
kagi remote push
```

`member approve` should:

1. Validate active plus pending member name uniqueness locally.
2. Call `/tokens/issue` for a member token with `pending_activation`.
3. Wrap project key to the pending member recipient.
4. Wrap the issued project token to the pending member recipient.
5. Mark the member active in `access.json`.
6. Add the issued token id to local pending activation state.

The next `kagi remote push` sends `activate_token_ids` and
`accepted_join_member_ids` atomically with the updated `access.json`.

### Complete Join

```bash
kagi remote pull
```

If the CLI has local join state but no project key, `pull` checks
`access.json`. Once approved, it decrypts:

- wrapped project key
- wrapped project token

Then it stores both locally and performs a normal authenticated pull.

## Member Name Rules

`member_id` is the canonical id used by commands. `name` is a human identifier
and must be unique within active and pending members.

Normalize before uniqueness checks:

- Trim leading and trailing whitespace.
- Collapse repeated whitespace into one space.
- Compare case-insensitively.

Rules:

- Active plus pending member names must be unique.
- Removed member names may be reused.
- Duplicate names in pulled `access.json` cause validation failure.
- Duplicate pending names on the server return encrypted `409 conflict`.
- Error messages should suggest a unique name, such as `alice-laptop` or
  `alice-zhang`.

## Error Codes

Use stable machine-readable codes:

```text
bad_request
bad_envelope
decrypt_failed
auth_failed
forbidden
not_found
conflict
payload_too_large
invalid_path
invalid_revision
invalid_token
invalid_project_state
server_key_mismatch
internal
```

CLI output should stay short and actionable. Do not print token values or env
secret values.

## Implementation Phases

1. Add domain structs for project token, envelope, ProjectState, remote config,
   and API request/response payloads.
2. Add local storage for project token, admin token, pinned fingerprint, local
   revision, and pending token activation/revocation state.
3. Add age transport envelope encryption.
4. Add Reqwest remote client.
5. Add SQLx migrations and SQLite repository.
6. Add Axum server routes, rate limiting, and `kagi serve`.
7. Add admin token generation on first startup.
8. Add `kagi remote register`, `projects`, `approve`, and `remove`.
9. Add `kagi remote push`, `kagi remote pull`, and `kagi remote status`.
10. Add server-mode `member request` and `member approve`.
11. Add token issue/revoke support.
12. Add member removal with project-key rotation plus token revocation.
13. Update README with Git mode and server mode examples.

## Tests

CLI integration tests:

- `init` creates `.kagi/`, `.env`, and `.env.*` entries in `.gitignore`.
- `remote register --remote` sends a pending request and stores remote config.
- `push -> pull -> run` works between two temp directories.
- `pull <project-token>` works without a project id argument.
- A user with only onboarding token can pull encrypted state but cannot decrypt.
- `member request -> member approve -> remote push -> remote pull` completes a new member.
- Removing a member rotates project key and revokes that member token.
- Stale `push` returns encrypted `409 conflict`.
- Duplicate active or pending member names are rejected.
- Noninteractive first connection without pinned fingerprint fails.
- Changed server fingerprint fails.

Server tests:

- SQLite restart preserves state.
- Server key file is not stored in SQLite.
- Admin token is generated on first startup and not on restart.
- Project creation stores token hash only.
- Invalid paths are rejected.
- Push writes files, token activations, token revocations, and accepted member requests in
  one transaction.
- Pull returns member requests only for tokens with `push` or `rotate`.
- Wrong token returns `401`.
- Wrong capability returns `403`.
- Admin-only endpoints reject non-admin tokens.
- Rate limiting returns `429` after burst is exceeded.
- Oversized body returns `413`.
- Malformed JSON returns `400`.

Security tests:

- Captured HTTP request body does not contain project token plaintext.
- Captured HTTP response body does not contain project token plaintext.
- Captured HTTP response body is encrypted.
- Tampering with envelope `response_recipient` is rejected.
- Tampering with response ciphertext fails MAC verification.
- A response encrypted by someone other than the server is rejected before data
  is trusted.
- Server DB does not contain env plaintext.
- Server DB does not contain project key.
- Server DB does not contain token plaintext.
- Server DB does not contain raw server private identity.
- Server DB does not contain token pepper.

## Deployment Packaging

### Docker

A `Dockerfile` is included in the repository. It builds a multi-stage image using Debian Bookworm as the base.

Build:

```bash
docker build -t kagi-server:latest .
```

Run with persistent volumes:

```bash
docker run -d \
  --name kagi-server \
  -p 127.0.0.1:8787:8787 \
  -v kagi-data:/home/kagi/data \
  -v kagi-server-key:/home/kagi/server \
  kagi-server:latest
```

On first startup, the container prints the admin token to the logs. Retrieve it with:

```bash
docker logs kagi-server
```

### Docker Compose

A `docker-compose.yml` example is included with persistent volume mounts for the database and server key.

```bash
docker compose up -d
```

The compose file binds `127.0.0.1:8787` by default. Change the port mapping to expose the server publicly, and place a reverse proxy in front for TLS.

### systemd

A `kagi-server.service` unit file is included. Install it on a Debian/Ubuntu or RHEL-compatible system:

```bash
# Create user and directories
sudo useradd -r -s /bin/false -d /var/lib/kagi kagi
sudo mkdir -p /var/lib/kagi /etc/kagi
sudo chown kagi:kagi /var/lib/kagi
sudo chmod 700 /var/lib/kagi

# Install the binary
sudo cp target/release/kagi /usr/local/bin/kagi
sudo chmod +x /usr/local/bin/kagi

# Install the service file
sudo cp kagi-server.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable kagi-server
sudo systemctl start kagi-server
```

On first startup, the server creates the database and server key, then prints the admin token. Check the logs:

```bash
sudo journalctl -u kagi-server -n 50
```

### Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `KAGI_HOME` | platform-specific | Base directory for local data and server state |
| `KAGI_ALLOW_INSECURE_HTTP` | unset | Set to `1` to allow non-localhost `http://` remotes |
| `RUST_LOG` | unset | Standard `tracing` log filter, e.g. `info` or `debug` |

Server command-line flags:

| Flag | Default | Description |
|------|---------|-------------|
| `--bind` | `127.0.0.1:8787` | Address and port to listen on |
| `--db` | `$KAGI_HOME/server/kagi.db` | SQLite database path |
| `--key-file` | `$KAGI_HOME/server/server.key.json` | Server key file path |
| `--max-body` | `10mb` | Maximum request body size |

### Reverse Proxy

For production, run the Kagi server behind a reverse proxy that handles TLS termination.

Recommended setup with Caddy:

```
kagi.example.com {
    reverse_proxy 127.0.0.1:8787
}
```

Recommended setup with nginx:

```nginx
server {
    listen 443 ssl http2;
    server_name kagi.example.com;

    ssl_certificate /etc/letsencrypt/live/kagi.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/kagi.example.com/privkey.pem;

    client_max_body_size 20m;
    access_log /var/log/nginx/kagi.access.log;

    location / {
        proxy_pass http://127.0.0.1:8787;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

Keep the Kagi server bound to `127.0.0.1` when a reverse proxy is present. The reverse proxy should pass the real client IP so the Kagi rate limiter applies per-client limits correctly.