OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / e58f166

0.9.97: recovery from the CLI, over the socket the fleet already uses
⛔ MY FIRST VERSION WAS WEAKER THAN THE PATTERN. Recovery was a POST on the web surface gated on the request's remote address being loopback. Right intent, weaker gate: loopback is reachable by ANY process or user on the machine, while a unix socket is reachable only by one that can open its path. Both mean "you are on this box"; only one means "you are the operator". pgv3 settled this already — it carries `ctx.transport` through hyper-svc and its handlers self-gate ("Block HTTP (anonymous callers). UDS (local operator) … allowed") — and hyper-proto-peer's UDS surface states the principle: "a UDS connection is owner-gated by the filesystem … so the local surface is TRUSTED WITHOUT the writer-allowlist". lib/admin-control.js is that, and the HTTP door is REMOVED rather than kept beside it, because leaving both would simply leave the weaker one open. ⛔ WHAT IT EXISTS FOR. Losing every operator browser — cleared storage, a dead laptop — shuts the web surface for good: the bootstrap token gates the FIRST claim only, and browsers.js never reopens the instance, not even when the claimant is revoked. The alternative recovery was deleting the identity store, which throws away every operator grant in order to get one back. ⛔ IT ISSUES AN INVITE, NEVER RE-ARMS TOFU. Re-arming would reopen the instance to whoever reached the port first, on a surface bound to 0.0.0.0 — the hole the bootstrap token exists to close. Asserted: after a recovery the spent bootstrap token still admits nobody, and the invite admits exactly one new browser. `ap-bridge-admin status|invite|revoke` is the CLI. The token goes to stdout and the advice to stderr, so `invite | pbcopy` pipes the token alone. ⚠ A control socket that will not bind is logged and NOT fatal. A bridge that refuses to serve the fediverse because its recovery aid could not start has turned recovery into an outage. 1041/1041 pass, 12 of them on the gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e58f166c9c6ae29d4a72ca02da527466d041577c
by plankatron <fleeky@gmail.com> · 2026-09-12T00:16:55.000Z

Changed files (7) +316 -98

.pgh-lock.json +93 -93
……@@ -1,9 +1,9 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.95",
3 "version": "0.9.96",
44 "runtime": "bare",
5 "rootTreeHash": "09bafc3164c08bc4374e18078791139c4dcfe80210f43b5ae38ed0ee77437479",
6 "publishedAt": "2026-09-11T23:53:28.457Z",
5 "rootTreeHash": "3db8ca6d8191db17fd7047baf444777c3efa9b793110fc9fef1857bc8b9ec30c",
6 "publishedAt": "2026-09-11T23:59:28.794Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -4004,8 +4004,8 @@
40044004 "tree": "38f44af48b2d59b46f1b5db31b81d02f3ca8b8bad6bc0fb9fffff698241f5e37"
40054005 }
40064006 ],
4007 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4008 "acceptedAt": "2026-09-11T23:53:28.481Z"
4007 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4008 "acceptedAt": "2026-09-11T23:59:28.817Z"
40094009 },
40104010 {
40114011 "type": "VERSION_CONFLICT",
……@@ -4019,8 +4019,8 @@
40194019 "tree": "ee91b6bd77101e3404ca571b2e5adf0f47029d174398a05f50294fc207e39d99"
40204020 }
40214021 ],
4022 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4023 "acceptedAt": "2026-09-11T23:53:28.481Z"
4022 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4023 "acceptedAt": "2026-09-11T23:59:28.817Z"
40244024 },
40254025 {
40264026 "type": "VERSION_CONFLICT",
……@@ -4034,8 +4034,8 @@
40344034 "tree": "3bb78d8f06263a549bd1077f37a7df257b1d557e44608631435af625f05a9c11"
40354035 }
40364036 ],
4037 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4038 "acceptedAt": "2026-09-11T23:53:28.481Z"
4037 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4038 "acceptedAt": "2026-09-11T23:59:28.817Z"
40394039 },
40404040 {
40414041 "type": "VERSION_CONFLICT",
……@@ -4054,8 +4054,8 @@
40544054 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
40554055 }
40564056 ],
4057 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4058 "acceptedAt": "2026-09-11T23:53:28.481Z"
4057 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4058 "acceptedAt": "2026-09-11T23:59:28.817Z"
40594059 },
40604060 {
40614061 "type": "VERSION_CONFLICT",
……@@ -4069,8 +4069,8 @@
40694069 "tree": "da4c6b2654e9d66f10b0faa989311cf182878c24ab51b1c41458cfbf37ad29cd"
40704070 }
40714071 ],
4072 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4073 "acceptedAt": "2026-09-11T23:53:28.481Z"
4072 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4073 "acceptedAt": "2026-09-11T23:59:28.817Z"
40744074 },
40754075 {
40764076 "type": "VERSION_CONFLICT",
……@@ -4084,8 +4084,8 @@
40844084 "tree": "40be8f559a13b54ebf0689e63e6c70f0d218035359e17c497ed7bc8a85c3cc95"
40854085 }
40864086 ],
4087 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4088 "acceptedAt": "2026-09-11T23:53:28.481Z"
4087 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4088 "acceptedAt": "2026-09-11T23:59:28.817Z"
40894089 },
40904090 {
40914091 "type": "VERSION_CONFLICT",
……@@ -4099,8 +4099,8 @@
40994099 "tree": "3eae85efce82299769898c8151b5ed0a94e5a5ebdbcdee2105b815ca733a61cb"
41004100 }
41014101 ],
4102 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4103 "acceptedAt": "2026-09-11T23:53:28.481Z"
4102 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4103 "acceptedAt": "2026-09-11T23:59:28.817Z"
41044104 },
41054105 {
41064106 "type": "VERSION_CONFLICT",
……@@ -4114,8 +4114,8 @@
41144114 "tree": "8d9e01d443a127781773598d2e77c3f372334fad09e20623a819dabe49b22e1a"
41154115 }
41164116 ],
4117 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4118 "acceptedAt": "2026-09-11T23:53:28.481Z"
4117 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4118 "acceptedAt": "2026-09-11T23:59:28.817Z"
41194119 },
41204120 {
41214121 "type": "VERSION_CONFLICT",
……@@ -4129,8 +4129,8 @@
41294129 "tree": "dc1ad6c999fe90d946ba0ef34698ea9af451b639be2ad1c5b61eabb08337a6ca"
41304130 }
41314131 ],
4132 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4133 "acceptedAt": "2026-09-11T23:53:28.481Z"
4132 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4133 "acceptedAt": "2026-09-11T23:59:28.817Z"
41344134 },
41354135 {
41364136 "type": "VERSION_CONFLICT",
……@@ -4144,8 +4144,8 @@
41444144 "tree": "88b4779570e5113aa21b204989ac018d1628b387681f7e53428a7c99114fb701"
41454145 }
41464146 ],
4147 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4148 "acceptedAt": "2026-09-11T23:53:28.481Z"
4147 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4148 "acceptedAt": "2026-09-11T23:59:28.817Z"
41494149 },
41504150 {
41514151 "type": "VERSION_CONFLICT",
……@@ -4159,8 +4159,8 @@
41594159 "tree": "720b867ec4300a3caa2325eb1449499f9940bfb88341ecceb5992fa0fdf5f37d"
41604160 }
41614161 ],
4162 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4163 "acceptedAt": "2026-09-11T23:53:28.481Z"
4162 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4163 "acceptedAt": "2026-09-11T23:59:28.817Z"
41644164 },
41654165 {
41664166 "type": "VERSION_CONFLICT",
……@@ -4174,8 +4174,8 @@
41744174 "tree": "b697fe174482a708b49d0764cd4786291845f0034a31e0699381ca7e7b269d04"
41754175 }
41764176 ],
4177 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4178 "acceptedAt": "2026-09-11T23:53:28.481Z"
4177 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4178 "acceptedAt": "2026-09-11T23:59:28.817Z"
41794179 },
41804180 {
41814181 "type": "VERSION_CONFLICT",
……@@ -4189,8 +4189,8 @@
41894189 "tree": "767f07242277ae80bd395de5a4969fcdbf956c216158ae517fb2b562558ac1a4"
41904190 }
41914191 ],
4192 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4193 "acceptedAt": "2026-09-11T23:53:28.481Z"
4192 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4193 "acceptedAt": "2026-09-11T23:59:28.817Z"
41944194 },
41954195 {
41964196 "type": "VERSION_CONFLICT",
……@@ -4204,8 +4204,8 @@
42044204 "tree": "de7deed21f587ffc8bde4971abe00fca77bb6c71c4e7effb31ad34ee330815df"
42054205 }
42064206 ],
4207 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4208 "acceptedAt": "2026-09-11T23:53:28.481Z"
4207 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4208 "acceptedAt": "2026-09-11T23:59:28.817Z"
42094209 },
42104210 {
42114211 "type": "VERSION_CONFLICT",
……@@ -4219,8 +4219,8 @@
42194219 "tree": "2df42517ebf73cd9c11f2f652f75e87401fd4ed7b856c0adc15c33baf1d4f1ad"
42204220 }
42214221 ],
4222 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4223 "acceptedAt": "2026-09-11T23:53:28.481Z"
4222 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4223 "acceptedAt": "2026-09-11T23:59:28.817Z"
42244224 },
42254225 {
42264226 "type": "VERSION_CONFLICT",
……@@ -4234,8 +4234,8 @@
42344234 "tree": "325aca171cbff560487177584078c8066133e91176639ca47c98d70e195aa92e"
42354235 }
42364236 ],
4237 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4238 "acceptedAt": "2026-09-11T23:53:28.481Z"
4237 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4238 "acceptedAt": "2026-09-11T23:59:28.817Z"
42394239 },
42404240 {
42414241 "type": "VERSION_CONFLICT",
……@@ -4264,8 +4264,8 @@
42644264 "tree": "89a40400106bf4fef95fb5c44a66ee29aada6f76c7740bfba09d8aeeb841f39c"
42654265 }
42664266 ],
4267 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4268 "acceptedAt": "2026-09-11T23:53:28.481Z"
4267 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4268 "acceptedAt": "2026-09-11T23:59:28.817Z"
42694269 },
42704270 {
42714271 "type": "VERSION_CONFLICT",
……@@ -4279,8 +4279,8 @@
42794279 "tree": "1bd0eb382789c62cd4622d47bffb60984fdd766260c79f73a2fc7a136f3edbe9"
42804280 }
42814281 ],
4282 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4283 "acceptedAt": "2026-09-11T23:53:28.481Z"
4282 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4283 "acceptedAt": "2026-09-11T23:59:28.817Z"
42844284 },
42854285 {
42864286 "type": "VERSION_CONFLICT",
……@@ -4294,8 +4294,8 @@
42944294 "tree": "4526fe9dc5468264c7171ec16e9303726674b7b44ca0b3e09cb8b9a6a1be6169"
42954295 }
42964296 ],
4297 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4298 "acceptedAt": "2026-09-11T23:53:28.481Z"
4297 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4298 "acceptedAt": "2026-09-11T23:59:28.817Z"
42994299 },
43004300 {
43014301 "type": "VERSION_CONFLICT",
……@@ -4309,8 +4309,8 @@
43094309 "tree": "32b291cd756e142da18a6ed01c351e024fcedc3e2b1ecfbfb85f3106ada4419b"
43104310 }
43114311 ],
4312 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4313 "acceptedAt": "2026-09-11T23:53:28.481Z"
4312 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4313 "acceptedAt": "2026-09-11T23:59:28.817Z"
43144314 },
43154315 {
43164316 "type": "VERSION_CONFLICT",
……@@ -4324,8 +4324,8 @@
43244324 "tree": "4a8cd3b0093092b33088617dc0415e8179882d7f2f13b7649962ca20b08beeed"
43254325 }
43264326 ],
4327 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4328 "acceptedAt": "2026-09-11T23:53:28.481Z"
4327 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4328 "acceptedAt": "2026-09-11T23:59:28.817Z"
43294329 },
43304330 {
43314331 "type": "VERSION_CONFLICT",
……@@ -4339,8 +4339,8 @@
43394339 "tree": "4f63f165ee6a581f689b40c4a40df4df01ab6fba17d46bd29e40ef5bbdd9884f"
43404340 }
43414341 ],
4342 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4343 "acceptedAt": "2026-09-11T23:53:28.481Z"
4342 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4343 "acceptedAt": "2026-09-11T23:59:28.817Z"
43444344 },
43454345 {
43464346 "type": "VERSION_CONFLICT",
……@@ -4354,8 +4354,8 @@
43544354 "tree": "735109c736c0bd3413ab47414fd8b6ea9a99c401fe8e6d608459c79293806754"
43554355 }
43564356 ],
4357 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4358 "acceptedAt": "2026-09-11T23:53:28.481Z"
4357 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4358 "acceptedAt": "2026-09-11T23:59:28.817Z"
43594359 },
43604360 {
43614361 "type": "VERSION_CONFLICT",
……@@ -4369,8 +4369,8 @@
43694369 "tree": "b32b75bf1b2acdd18ed0cc370d01d477fba42ba09fd1f2f78af3ebad925e72d5"
43704370 }
43714371 ],
4372 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4373 "acceptedAt": "2026-09-11T23:53:28.481Z"
4372 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4373 "acceptedAt": "2026-09-11T23:59:28.817Z"
43744374 },
43754375 {
43764376 "type": "VERSION_CONFLICT",
……@@ -4389,8 +4389,8 @@
43894389 "tree": "189397178121e9a18430943345dab820691648a324229071ceb980bbb77a7393"
43904390 }
43914391 ],
4392 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4393 "acceptedAt": "2026-09-11T23:53:28.481Z"
4392 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4393 "acceptedAt": "2026-09-11T23:59:28.817Z"
43944394 },
43954395 {
43964396 "type": "VERSION_CONFLICT",
……@@ -4404,8 +4404,8 @@
44044404 "tree": "3bedca5287a9041fc2fd5ec5ace4df1fa86240e6afec0a105ba67398bafc09a5"
44054405 }
44064406 ],
4407 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4408 "acceptedAt": "2026-09-11T23:53:28.481Z"
4407 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4408 "acceptedAt": "2026-09-11T23:59:28.817Z"
44094409 },
44104410 {
44114411 "type": "VERSION_CONFLICT",
……@@ -4419,8 +4419,8 @@
44194419 "tree": "b9ca9b272008bd67672a1b252390e8b527095c46b46cf6b6725a7a187eb038b6"
44204420 }
44214421 ],
4422 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4423 "acceptedAt": "2026-09-11T23:53:28.481Z"
4422 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4423 "acceptedAt": "2026-09-11T23:59:28.817Z"
44244424 },
44254425 {
44264426 "type": "VERSION_CONFLICT",
……@@ -4434,8 +4434,8 @@
44344434 "tree": "7793645d7d4248d6d6533e3cbcd4bf40bed4f38c678b0ca0606c15640ba5e7b9"
44354435 }
44364436 ],
4437 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4438 "acceptedAt": "2026-09-11T23:53:28.481Z"
4437 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4438 "acceptedAt": "2026-09-11T23:59:28.817Z"
44394439 },
44404440 {
44414441 "type": "VERSION_CONFLICT",
……@@ -4449,8 +4449,8 @@
44494449 "tree": "79447513d4ac0357c8d60bdc7b81b3c5143d761b26700340cba266c4d1cb77b0"
44504450 }
44514451 ],
4452 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4453 "acceptedAt": "2026-09-11T23:53:28.481Z"
4452 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4453 "acceptedAt": "2026-09-11T23:59:28.817Z"
44544454 },
44554455 {
44564456 "type": "VERSION_CONFLICT",
……@@ -4464,8 +4464,8 @@
44644464 "tree": "279d4e0447eb79fd75816864ba7f41f2df03080c9da69ea4603fe2900c8d9e53"
44654465 }
44664466 ],
4467 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4468 "acceptedAt": "2026-09-11T23:53:28.481Z"
4467 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4468 "acceptedAt": "2026-09-11T23:59:28.817Z"
44694469 },
44704470 {
44714471 "type": "VERSION_CONFLICT",
……@@ -4479,8 +4479,8 @@
44794479 "tree": "ed4e0b480417610e195ff0b59e5b9a453abbf2db6d6ce72d7cd522269f835e9a"
44804480 }
44814481 ],
4482 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4483 "acceptedAt": "2026-09-11T23:53:28.481Z"
4482 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4483 "acceptedAt": "2026-09-11T23:59:28.817Z"
44844484 },
44854485 {
44864486 "type": "VERSION_CONFLICT",
……@@ -4499,8 +4499,8 @@
44994499 "tree": "a9e07d54e526c838dfe5e27df33936682aefb218f61a45b6c15289243999f648"
45004500 }
45014501 ],
4502 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4503 "acceptedAt": "2026-09-11T23:53:28.481Z"
4502 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4503 "acceptedAt": "2026-09-11T23:59:28.817Z"
45044504 },
45054505 {
45064506 "type": "VERSION_CONFLICT",
……@@ -4514,8 +4514,8 @@
45144514 "tree": "9d84c86441fe138b227c4fd01d1e948aa83bf8d13cfb1aa957838feea05fbb57"
45154515 }
45164516 ],
4517 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4518 "acceptedAt": "2026-09-11T23:53:28.481Z"
4517 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4518 "acceptedAt": "2026-09-11T23:59:28.817Z"
45194519 },
45204520 {
45214521 "type": "VERSION_CONFLICT",
……@@ -4529,8 +4529,8 @@
45294529 "tree": "4a3afbe2fc7a882e24454eb2e7e7cfe6816811ee5d73ae0f92e8b9ea9cfb8c78"
45304530 }
45314531 ],
4532 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4533 "acceptedAt": "2026-09-11T23:53:28.481Z"
4532 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4533 "acceptedAt": "2026-09-11T23:59:28.817Z"
45344534 },
45354535 {
45364536 "type": "VERSION_CONFLICT",
……@@ -4544,8 +4544,8 @@
45444544 "tree": "1e5c5810d8971a3c27fde7fb0e4d633dcabbe2cf98d8df86b473f8d67eead8b9"
45454545 }
45464546 ],
4547 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4548 "acceptedAt": "2026-09-11T23:53:28.481Z"
4547 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4548 "acceptedAt": "2026-09-11T23:59:28.817Z"
45494549 },
45504550 {
45514551 "type": "VERSION_CONFLICT",
……@@ -4559,8 +4559,8 @@
45594559 "tree": "e3478f50e85ba0ac288efed83e84c992483904897dc39ccf243d20a4ea25d972"
45604560 }
45614561 ],
4562 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4563 "acceptedAt": "2026-09-11T23:53:28.481Z"
4562 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4563 "acceptedAt": "2026-09-11T23:59:28.817Z"
45644564 },
45654565 {
45664566 "type": "VERSION_CONFLICT",
……@@ -4574,8 +4574,8 @@
45744574 "tree": "c05d8bf7eab5024ea98140f78c84d12fb494b4b7b10c7eedeb50b2be842fa519"
45754575 }
45764576 ],
4577 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4578 "acceptedAt": "2026-09-11T23:53:28.481Z"
4577 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4578 "acceptedAt": "2026-09-11T23:59:28.817Z"
45794579 },
45804580 {
45814581 "type": "VERSION_CONFLICT",
……@@ -4589,8 +4589,8 @@
45894589 "tree": "0697bb338bbcec517353cffcdb2345a8232b9b90cc627a549a306f0323576390"
45904590 }
45914591 ],
4592 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4593 "acceptedAt": "2026-09-11T23:53:28.481Z"
4592 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4593 "acceptedAt": "2026-09-11T23:59:28.817Z"
45944594 },
45954595 {
45964596 "type": "VERSION_CONFLICT",
……@@ -4604,8 +4604,8 @@
46044604 "tree": "a366a73e74f8d80f106465058083b62bb70b833c721047124e99c89880e5c706"
46054605 }
46064606 ],
4607 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4608 "acceptedAt": "2026-09-11T23:53:28.481Z"
4607 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4608 "acceptedAt": "2026-09-11T23:59:28.817Z"
46094609 },
46104610 {
46114611 "type": "VERSION_CONFLICT",
……@@ -4619,8 +4619,8 @@
46194619 "tree": "7912e790c9e87e9829a6daa037c5f5da0cc4355214f51d334f96e57678dfddeb"
46204620 }
46214621 ],
4622 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4623 "acceptedAt": "2026-09-11T23:53:28.481Z"
4622 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4623 "acceptedAt": "2026-09-11T23:59:28.817Z"
46244624 },
46254625 {
46264626 "type": "VERSION_CONFLICT",
……@@ -4634,8 +4634,8 @@
46344634 "tree": "003ac841449fcdda1abc3c87fee291a5d9596d245925a7298bd8ef5f8e423eae"
46354635 }
46364636 ],
4637 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4638 "acceptedAt": "2026-09-11T23:53:28.481Z"
4637 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4638 "acceptedAt": "2026-09-11T23:59:28.817Z"
46394639 },
46404640 {
46414641 "type": "VERSION_CONFLICT",
……@@ -4654,8 +4654,8 @@
46544654 "tree": "7b3f314cf92558edbc90295c573885276a6add925318998c829c03d335edc765"
46554655 }
46564656 ],
4657 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4658 "acceptedAt": "2026-09-11T23:53:28.481Z"
4657 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4658 "acceptedAt": "2026-09-11T23:59:28.817Z"
46594659 },
46604660 {
46614661 "type": "VERSION_CONFLICT",
……@@ -4669,8 +4669,8 @@
46694669 "tree": "c486fe73805bf013e9c5de91b5780d1aa5d2b2295306f411f7863757aaa05e60"
46704670 }
46714671 ],
4672 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4673 "acceptedAt": "2026-09-11T23:53:28.481Z"
4672 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4673 "acceptedAt": "2026-09-11T23:59:28.817Z"
46744674 },
46754675 {
46764676 "type": "VERSION_CONFLICT",
……@@ -4684,8 +4684,8 @@
46844684 "tree": "10193a86e2c6f80c2d4377379a0aae4a959f8a40fd9e852b85fee081bfe6675a"
46854685 }
46864686 ],
4687 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4688 "acceptedAt": "2026-09-11T23:53:28.481Z"
4687 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4688 "acceptedAt": "2026-09-11T23:59:28.817Z"
46894689 },
46904690 {
46914691 "type": "VERSION_CONFLICT",
……@@ -4699,8 +4699,8 @@
46994699 "tree": "20b1ecd2937f3e35102892288234b86a8ec6deb3711016d96ff3509474ac465b"
47004700 }
47014701 ],
4702 "reason": "ap-bridge 0.9.95 — LAN-reachable authenticated operator surface",
4703 "acceptedAt": "2026-09-11T23:53:28.481Z"
4702 "reason": "ap-bridge 0.9.96 — operators page, invites, primary actor",
4703 "acceptedAt": "2026-09-11T23:59:28.817Z"
47044704 }
47054705 ]
47064706}
bin/ap-bridge-admin.js +62 -0
……@@ -0,0 +1,62 @@
1#!/usr/bin/env bare
2// ap-bridge-admin — the operator's way back in, from the machine running the bridge.
3//
4// ap-bridge-admin status who operates this bridge
5// ap-bridge-admin invite mint a single-use invite for another browser
6// ap-bridge-admin revoke <pubkey> retire an operator browser
7//
8// ⛔ OVER THE UNIX SOCKET, which is the gate. The filesystem decides who may call this: only a local
9// process that can open the socket path connects. That is the fleet's pattern — pgv3's handlers gate on
10// `ctx.transport` and hyper-proto-peer's UDS surface is "TRUSTED WITHOUT the writer-allowlist" for the
11// same reason — and it is strictly stronger than gating on a loopback address, which any process or
12// user on the box can present.
13//
14// ⛔ WHY IT EXISTS. Lose every operator browser — cleared storage, a dead laptop — and the web surface
15// is shut for good: the bootstrap token gates the FIRST claim only, and the instance never reopens, not
16// even when the claimant is revoked. The alternative recovery is deleting the identity store, which
17// throws away every operator grant in order to get one back.
18import '../lib/bare-globals.js'
19import process from 'bare-process'
20import os from 'bare-os'
21import { join } from 'bare-path'
22import { Client } from 'hyper-svc'
23
24const argv = process.argv.slice(2)
25const cmd = argv[0] || 'status'
26const dataDir = process.env.AP_DATA_DIR
27 ? process.env.AP_DATA_DIR.replace(/^~/, process.env.HOME || '').replace(/\$\{HOME\}/g, process.env.HOME || '')
28 : join(os.homedir(), '.config', 'ap-bridge')
29const uds = process.env.AP_ADMIN_SOCKET || join(dataDir, 'admin.sock')
30
31function die (msg) { console.error(msg); process.exit(1) }
32
33const client = await Client.discover({ uds, prefer: 'uds' }).catch(() => null)
34if (!client) {
35 die('no control socket at ' + uds + '\n' +
36 'Is the bridge running, and is AP_ADMIN_PORT set? (the control socket comes up with the admin surface)')
37}
38
39try {
40 if (cmd === 'status') {
41 const s = await client.call('admin-status', {})
42 console.log(s.unclaimed ? 'UNCLAIMED — claim it with the bootstrap token from the service log' : 'claimed')
43 if (!s.operators.length) console.log(' no operator browsers')
44 for (const o of s.operators) console.log(' ' + o.publicKey + (o.revoked ? ' (revoked)' : ''))
45 } else if (cmd === 'invite') {
46 const { token, expiresAt } = await client.call('admin-invite', {})
47 console.log(token)
48 // ⚠ On stderr so `ap-bridge-admin invite | pbcopy` pipes the token alone.
49 console.error('single-use' + (expiresAt ? ', expires ' + new Date(expiresAt).toISOString() : '') +
50 ' — paste it into the Invite field on the sign-in page')
51 } else if (cmd === 'revoke') {
52 if (!argv[1]) die('usage: ap-bridge-admin revoke <publicKey>')
53 await client.call('admin-revoke', { publicKey: argv[1] })
54 console.log('revoked ' + argv[1])
55 } else {
56 die('usage: ap-bridge-admin [status|invite|revoke <publicKey>]')
57 }
58} catch (err) {
59 die((err && err.message) || String(err))
60} finally {
61 try { await client.close() } catch {}
62}
index.js +25 -2
……@@ -511,10 +511,18 @@ async function main () {
511511 log({ msg: 'admin surface UNCLAIMED — claim it with this bootstrap token, then it closes for good', bootstrap })
512512 }
513513
514 const adminCtx = { browsers, sessions: new Sessions(), registrations, inbound, config, root: new URL('.', import.meta.url).pathname }
514 const adminCtx = { browsers, sessions: new Sessions(), registrations, inbound, config, log, root: new URL('.', import.meta.url).pathname }
515515 const adminServer = http.createServer((req, res) => {
516516 parseForm(req).then((form) => serveAdmin(
517 { method: req.method, path: (req.url || '/').split('?')[0], form, headers: req.headers || {} }, adminCtx))
517 {
518 method: req.method,
519 path: (req.url || '/').split('?')[0],
520 form,
521 headers: req.headers || {},
522 // ⛔ FROM THE SOCKET. The recovery route gates on being on this machine, and a header would
523 // let a caller assert that rather than demonstrate it.
524 remoteAddress: (req.socket && req.socket.remoteAddress) || null
525 }, adminCtx))
518526 .then((out) => { res.writeHead(out.status, out.headers); res.end(out.body) })
519527 .catch((err) => { res.writeHead(500, { 'content-type': 'text/plain' }); res.end(String((err && err.message) || err)) })
520528 })
……@@ -531,6 +539,21 @@ async function main () {
531539 // HTTP this would serve a login page whose crypto is undefined in the browser that loaded it.
532540 // Publish it through the proxy that already terminates TLS for this host.
533541 log({ msg: 'admin surface (authenticated)', bind: (config.adminBind || '127.0.0.1') + ':' + config.adminPort })
542
543 // ⛔ THE LOCAL CONTROL SOCKET — the door that cannot be locked out. Losing every operator browser
544 // otherwise shuts the bridge for good: the bootstrap token gates the FIRST claim only and the
545 // instance never reopens. Owner-gated by the filesystem, which is the fleet's pattern for exactly
546 // this (pgv3 gates on ctx.transport; hpp's UDS surface is "TRUSTED WITHOUT the writer-allowlist"
547 // because only a local process that can open the path connects).
548 try {
549 const { serveAdminControl } = await import('./lib/admin-control.js')
550 const control = await serveAdminControl({ browsers, uds: join(dataDir, 'admin.sock'), log })
551 log({ msg: 'admin control socket (operator recovery)', uds: control.uds })
552 } catch (err) {
553 // ⚠ NOT FATAL, and said out loud. A bridge that refuses to serve the fediverse because its
554 // recovery socket would not bind has turned a recovery aid into an outage.
555 log({ msg: 'admin control socket unavailable — recovery from the CLI will not work', error: (err && err.message) || String(err) })
556 }
534557 }
535558
536559 // ★ The first pass picks up whatever the last run left owed — which is the entire point of the
lib/admin-auth.js +21 -1
……@@ -45,6 +45,21 @@ export class Sessions {
4545 get size () { return this._byId.size }
4646}
4747
48
49/**
50 * Did this request arrive on the loopback interface?
51 *
52 * ⛔ THE SOCKET, NEVER A HEADER. X-Forwarded-For and friends are supplied by the caller; trusting one
53 * here would turn "you must be on this machine" into "you must claim to be", which is not a gate.
54 *
55 * ⚠ IPv6-mapped IPv4 (`::ffff:127.0.0.1`) is what a dual-stack listener actually reports for a v4
56 * loopback connection, so matching only '127.0.0.1' refuses the ordinary case.
57 */
58export function isLoopback (address) {
59 const a = String(address || '')
60 return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1' || a.startsWith('127.')
61}
62
4863export function parseCookies (header) {
4964 const out = {}
5065 for (const part of String(header || '').split(';')) {
……@@ -265,6 +280,11 @@ export async function authRoutes (req, ctx) {
265280 return { status: 303, headers: { location: '/admin/profile' }, body: '' }
266281 }
267282
283 // ⛔ RECOVERY IS NOT HERE — it is lib/admin-control.js, on a unix socket. An earlier version put it
284 // on this surface gated by the socket's remote address, which is the right intent and a weaker gate:
285 // loopback is reachable by ANY process or user on the machine, a unix socket only by one that can
286 // open its path. Both mean "you are on this box"; only one means "you are the operator". Leaving the
287 // HTTP door in place beside the socket would simply leave the weaker one open.
268288 if (req.method === 'POST' && path === '/admin/logout') {
269289 sessions.destroy(parseCookies(req.headers && req.headers.cookie)[COOKIE])
270290 return { status: 303, headers: { location: '/admin/login', 'set-cookie': COOKIE + '=; Max-Age=0; Path=/' }, body: '' }
……@@ -273,4 +293,4 @@ export async function authRoutes (req, ctx) {
273293 return null
274294}
275295
276export default { Sessions, authRoutes, currentAdmin, loginPage, profilePage, parseCookies, COOKIE }
296export default { Sessions, authRoutes, currentAdmin, loginPage, profilePage, parseCookies, isLoopback, COOKIE }
lib/admin-control.js +63 -0
……@@ -0,0 +1,63 @@
1// The operator's LOCAL control surface — recovery that cannot be locked out.
2//
3// ⛔ A UNIX SOCKET, BECAUSE THE FILESYSTEM IS THE GATE. This is the fleet's pattern, not a new one:
4// pgv3 carries `ctx.transport` through hyper-svc and its handlers self-gate on it ("Block HTTP
5// (anonymous callers). UDS (local operator) … allowed"), and hyper-proto-peer's own UDS surface says
6// why — "a UDS connection is owner-gated by the filesystem (only a local process with access to the
7// socket path can connect), so the local surface is TRUSTED WITHOUT the writer-allowlist".
8//
9// ⚠ AND IT IS STRICTLY STRONGER THAN THE LOOPBACK HTTP ROUTE IT REPLACES. That route gated on the
10// socket's remote address, which is the right intent and a weaker gate: loopback is reachable by ANY
11// process or user on the machine, while a unix socket is reachable only by one that can open its path.
12// Both mean "you are on this box"; only one means "you are the operator".
13//
14// ⛔ WHAT IT EXISTS FOR. Losing every operator browser — cleared storage, a dead laptop — otherwise
15// shuts the bridge for good: the bootstrap token gates the FIRST claim only, and browsers.js never
16// reopens the instance, not even when the claimant is revoked. Without this the recovery is deleting
17// the identity store, which throws away every operator grant in order to get one back.
18//
19// ⛔ IT ISSUES AN INVITE, NEVER RE-ARMS TOFU. Re-arming would reopen the instance to whoever reached
20// the port first, on a surface bound to 0.0.0.0 — the hole the bootstrap token exists to close.
21
22import { Service } from 'hyper-svc'
23
24export function adminControlHandlers ({ browsers, log = () => {} }) {
25 return {
26 /** Is anyone operating this bridge yet, and who? The read a CLI needs before it acts. */
27 'admin-status': async () => ({
28 unclaimed: await browsers.unclaimed(),
29 operators: await browsers.list()
30 }),
31
32 /**
33 * Mint a single-use invite for another browser.
34 *
35 * ⚠ LOUD. An invite minted by a local process with nobody watching is the one action here worth an
36 * audit line, precisely because it needs no session.
37 */
38 'admin-invite': async () => {
39 const { token, expiresAt } = await browsers.invite()
40 log({ msg: 'ADMIN RECOVERY — invite issued over the local control socket', expiresAt })
41 return { token, expiresAt }
42 },
43
44 /** Retire an operator browser. The counterpart to invite, for a key that should no longer hold. */
45 'admin-revoke': async (ctx, { publicKey } = {}) => {
46 if (!publicKey) throw new Error('admin-revoke: publicKey required')
47 await browsers.revoke(publicKey)
48 log({ msg: 'ADMIN RECOVERY — operator revoked over the local control socket', publicKey })
49 return { revoked: publicKey }
50 }
51 }
52}
53
54export async function serveAdminControl ({ browsers, uds, log = () => {} }) {
55 if (!uds) throw new Error('serveAdminControl: a uds path is required')
56 const svc = new Service({ name: 'ap-bridge-admin', uds })
57 const handlers = adminControlHandlers({ browsers, log })
58 for (const [name, fn] of Object.entries(handlers)) svc.handle(name, fn)
59 await svc.listen()
60 return { uds, close: async () => { try { await svc.close() } catch {} } }
61}
62
63export default { serveAdminControl, adminControlHandlers }
package.json +3 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.96",
3 "version": "0.9.97",
44 "description": "ActivityPub membrane — a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
……@@ -39,7 +39,8 @@
3939 },
4040 "license": "MIT",
4141 "bin": {
42 "ap-bridge": "index.js"
42 "ap-bridge": "index.js",
43 "ap-bridge-admin": "bin/ap-bridge-admin.js"
4344 },
4445 "pgh": {
4546 "bin": "ap-bridge",
test/admin-auth.test.js +49 -0
……@@ -162,3 +162,52 @@ test('★★★ an invite is how the second operator gets in, not a second boots
162162 t.is(ok.status, 200, 'the invite does')
163163 t.ok(ok.headers['set-cookie'], 'and hands out a session')
164164})
165
166test('★★★ recovery is NOT on the web surface — it is on the control socket', async (t) => {
167 // ⛔ An earlier version gated recovery on the request's remote address. Right intent, weaker gate:
168 // loopback is reachable by ANY process or user on the box, a unix socket only by one that can open
169 // its path. Keeping both would have left the weaker door open beside the stronger one.
170 const c = await ctx()
171 for (const addr of ['127.0.0.1', '::1', '192.168.1.50']) {
172 const res = await serveAdmin(req('POST', '/admin/recover', { remoteAddress: addr }), c)
173 t.is(res.status, 303, 'no recovery route on the web surface from ' + addr)
174 }
175})
176
177test('★★★ the control socket recovers a bridge whose operators are all lost', async (t) => {
178 // The lockout: the bootstrap token gates the FIRST claim only, and browsers.js never reopens the
179 // instance — not even when the claimant is revoked. Without this the recovery is deleting the
180 // identity store, which throws away every operator grant in order to get one back.
181 const c = await ctx()
182 const first = await browser()
183 await serveAdmin(req('POST', '/admin/session', { form: { ...(await first.proof(c)), bootstrap: TOKEN } }), c)
184
185 const { adminControlHandlers } = await import('../lib/admin-control.js')
186 const h = adminControlHandlers({ browsers: c.browsers })
187
188 const status = await h['admin-status']()
189 t.absent(status.unclaimed, 'the bridge is claimed')
190 t.is(status.operators.length, 1, 'and has exactly one operator')
191
192 const { token } = await h['admin-invite']()
193 t.ok(token, 'the operator on the machine can mint an invite with no session at all')
194
195 const fresh = await browser()
196 const back = await serveAdmin(req('POST', '/admin/session', { form: { ...(await fresh.proof(c)), invite: token } }), c)
197 t.is(back.status, 200, 'and a new browser is back in')
198})
199
200test('★★★ recovery issues an invite — it does NOT re-arm the bootstrap token', async (t) => {
201 const c = await ctx()
202 const first = await browser()
203 await serveAdmin(req('POST', '/admin/session', { form: { ...(await first.proof(c)), bootstrap: TOKEN } }), c)
204
205 const { adminControlHandlers } = await import('../lib/admin-control.js')
206 await adminControlHandlers({ browsers: c.browsers })['admin-invite']()
207
208 // ⛔ Re-arming would reopen the instance to whoever reached the port first, on a surface bound to
209 // 0.0.0.0 — the hole the bootstrap token exists to close. Recovery must not punch it.
210 const stranger = await browser()
211 const refused = await serveAdmin(req('POST', '/admin/session', { form: { ...(await stranger.proof(c)), bootstrap: TOKEN } }), c)
212 t.is(refused.status, 403, 'the spent bootstrap token still admits nobody')
213})