OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 6fee49f

0.9.99: a paged inbound timeline
50 at a time, newest first, with "load 50 more" and a way back. ⛔ THIS IS THE INBOUND SIDE, and it has to be. Outbound posts live in the peer's own feed — the bridge PROJECTS them from a feed it holds rather than keeping a timeline of them — and this surface has no handle on that feed. A page claiming to be "the bridge's posts" would either be empty or quietly be this one. ⛔ OFFSET, NOT A CURSOR. The store is newest-first and BOUNDED: eviction happens at the head, so an offset can drift only by however many arrived while an operator was reading, which shows up as a repeated row rather than a lost one. A cursor is the right answer for a log that grows without limit; this one is capped by construction. ⛔ "LOAD 50 MORE" APPEARS ONLY WHEN THERE IS MORE. A control that returns an empty page is the page lying about the data behind it. Asserted on the last page, along with the count line, the tail being reachable, and the second page continuing exactly where the first stopped. ⛔ EVERY ROW WAS WRITTEN BY A STRANGER. It is signature-verified at the inbox, but the CONTENT is theirs — `<img src=x onerror=…>` from a remote server must not reach the operator's page intact, and a test drives exactly that. Rendered as a flat list rather than threads, because inbound-store.js is explicit that it "does not index, thread, or render". The query string is parsed once at the entry, so no handler sees a raw URL — a page control reading its own query string is where a surface starts reimplementing a URL parser. 1056/1056 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6fee49fa933c811e9802ea40e738ebbbece458a1
by plankatron <fleeky@gmail.com> · 2026-09-12T00:28:06.000Z

Changed files (6) +230 -98

.pgh-lock.json +93 -93
……@@ -1,9 +1,9 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.97",
3 "version": "0.9.98",
44 "runtime": "bare",
5 "rootTreeHash": "ca0bba4ddbe4fbc98dea11b202aa9dee4d63b1f90c646a3a705772968b3432d0",
6 "publishedAt": "2026-09-12T00:16:58.238Z",
5 "rootTreeHash": "80795269da91cedadd2a9d3398996d884aaa1e5345e6d8b14bbdf87dde10d70f",
6 "publishedAt": "2026-09-12T00:25:34.585Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -4004,8 +4004,8 @@
40044004 "tree": "38f44af48b2d59b46f1b5db31b81d02f3ca8b8bad6bc0fb9fffff698241f5e37"
40054005 }
40064006 ],
4007 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4008 "acceptedAt": "2026-09-12T00:16:58.261Z"
4007 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4008 "acceptedAt": "2026-09-12T00:25:34.631Z"
40094009 },
40104010 {
40114011 "type": "VERSION_CONFLICT",
……@@ -4019,8 +4019,8 @@
40194019 "tree": "ee91b6bd77101e3404ca571b2e5adf0f47029d174398a05f50294fc207e39d99"
40204020 }
40214021 ],
4022 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4023 "acceptedAt": "2026-09-12T00:16:58.261Z"
4022 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4023 "acceptedAt": "2026-09-12T00:25:34.631Z"
40244024 },
40254025 {
40264026 "type": "VERSION_CONFLICT",
……@@ -4034,8 +4034,8 @@
40344034 "tree": "3bb78d8f06263a549bd1077f37a7df257b1d557e44608631435af625f05a9c11"
40354035 }
40364036 ],
4037 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4038 "acceptedAt": "2026-09-12T00:16:58.261Z"
4037 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4038 "acceptedAt": "2026-09-12T00:25:34.631Z"
40394039 },
40404040 {
40414041 "type": "VERSION_CONFLICT",
……@@ -4054,8 +4054,8 @@
40544054 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
40554055 }
40564056 ],
4057 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4058 "acceptedAt": "2026-09-12T00:16:58.261Z"
4057 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4058 "acceptedAt": "2026-09-12T00:25:34.631Z"
40594059 },
40604060 {
40614061 "type": "VERSION_CONFLICT",
……@@ -4069,8 +4069,8 @@
40694069 "tree": "da4c6b2654e9d66f10b0faa989311cf182878c24ab51b1c41458cfbf37ad29cd"
40704070 }
40714071 ],
4072 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4073 "acceptedAt": "2026-09-12T00:16:58.261Z"
4072 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4073 "acceptedAt": "2026-09-12T00:25:34.631Z"
40744074 },
40754075 {
40764076 "type": "VERSION_CONFLICT",
……@@ -4084,8 +4084,8 @@
40844084 "tree": "40be8f559a13b54ebf0689e63e6c70f0d218035359e17c497ed7bc8a85c3cc95"
40854085 }
40864086 ],
4087 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4088 "acceptedAt": "2026-09-12T00:16:58.261Z"
4087 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4088 "acceptedAt": "2026-09-12T00:25:34.631Z"
40894089 },
40904090 {
40914091 "type": "VERSION_CONFLICT",
……@@ -4099,8 +4099,8 @@
40994099 "tree": "3eae85efce82299769898c8151b5ed0a94e5a5ebdbcdee2105b815ca733a61cb"
41004100 }
41014101 ],
4102 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4103 "acceptedAt": "2026-09-12T00:16:58.261Z"
4102 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4103 "acceptedAt": "2026-09-12T00:25:34.631Z"
41044104 },
41054105 {
41064106 "type": "VERSION_CONFLICT",
……@@ -4114,8 +4114,8 @@
41144114 "tree": "8d9e01d443a127781773598d2e77c3f372334fad09e20623a819dabe49b22e1a"
41154115 }
41164116 ],
4117 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4118 "acceptedAt": "2026-09-12T00:16:58.261Z"
4117 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4118 "acceptedAt": "2026-09-12T00:25:34.631Z"
41194119 },
41204120 {
41214121 "type": "VERSION_CONFLICT",
……@@ -4129,8 +4129,8 @@
41294129 "tree": "dc1ad6c999fe90d946ba0ef34698ea9af451b639be2ad1c5b61eabb08337a6ca"
41304130 }
41314131 ],
4132 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4133 "acceptedAt": "2026-09-12T00:16:58.262Z"
4132 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4133 "acceptedAt": "2026-09-12T00:25:34.631Z"
41344134 },
41354135 {
41364136 "type": "VERSION_CONFLICT",
……@@ -4144,8 +4144,8 @@
41444144 "tree": "88b4779570e5113aa21b204989ac018d1628b387681f7e53428a7c99114fb701"
41454145 }
41464146 ],
4147 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4148 "acceptedAt": "2026-09-12T00:16:58.262Z"
4147 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4148 "acceptedAt": "2026-09-12T00:25:34.631Z"
41494149 },
41504150 {
41514151 "type": "VERSION_CONFLICT",
……@@ -4159,8 +4159,8 @@
41594159 "tree": "720b867ec4300a3caa2325eb1449499f9940bfb88341ecceb5992fa0fdf5f37d"
41604160 }
41614161 ],
4162 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4163 "acceptedAt": "2026-09-12T00:16:58.262Z"
4162 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4163 "acceptedAt": "2026-09-12T00:25:34.631Z"
41644164 },
41654165 {
41664166 "type": "VERSION_CONFLICT",
……@@ -4174,8 +4174,8 @@
41744174 "tree": "b697fe174482a708b49d0764cd4786291845f0034a31e0699381ca7e7b269d04"
41754175 }
41764176 ],
4177 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4178 "acceptedAt": "2026-09-12T00:16:58.262Z"
4177 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4178 "acceptedAt": "2026-09-12T00:25:34.631Z"
41794179 },
41804180 {
41814181 "type": "VERSION_CONFLICT",
……@@ -4189,8 +4189,8 @@
41894189 "tree": "767f07242277ae80bd395de5a4969fcdbf956c216158ae517fb2b562558ac1a4"
41904190 }
41914191 ],
4192 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4193 "acceptedAt": "2026-09-12T00:16:58.262Z"
4192 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4193 "acceptedAt": "2026-09-12T00:25:34.631Z"
41944194 },
41954195 {
41964196 "type": "VERSION_CONFLICT",
……@@ -4204,8 +4204,8 @@
42044204 "tree": "de7deed21f587ffc8bde4971abe00fca77bb6c71c4e7effb31ad34ee330815df"
42054205 }
42064206 ],
4207 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4208 "acceptedAt": "2026-09-12T00:16:58.262Z"
4207 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4208 "acceptedAt": "2026-09-12T00:25:34.631Z"
42094209 },
42104210 {
42114211 "type": "VERSION_CONFLICT",
……@@ -4219,8 +4219,8 @@
42194219 "tree": "2df42517ebf73cd9c11f2f652f75e87401fd4ed7b856c0adc15c33baf1d4f1ad"
42204220 }
42214221 ],
4222 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4223 "acceptedAt": "2026-09-12T00:16:58.262Z"
4222 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4223 "acceptedAt": "2026-09-12T00:25:34.631Z"
42244224 },
42254225 {
42264226 "type": "VERSION_CONFLICT",
……@@ -4234,8 +4234,8 @@
42344234 "tree": "325aca171cbff560487177584078c8066133e91176639ca47c98d70e195aa92e"
42354235 }
42364236 ],
4237 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4238 "acceptedAt": "2026-09-12T00:16:58.262Z"
4237 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4238 "acceptedAt": "2026-09-12T00:25:34.631Z"
42394239 },
42404240 {
42414241 "type": "VERSION_CONFLICT",
……@@ -4264,8 +4264,8 @@
42644264 "tree": "89a40400106bf4fef95fb5c44a66ee29aada6f76c7740bfba09d8aeeb841f39c"
42654265 }
42664266 ],
4267 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4268 "acceptedAt": "2026-09-12T00:16:58.262Z"
4267 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4268 "acceptedAt": "2026-09-12T00:25:34.631Z"
42694269 },
42704270 {
42714271 "type": "VERSION_CONFLICT",
……@@ -4279,8 +4279,8 @@
42794279 "tree": "1bd0eb382789c62cd4622d47bffb60984fdd766260c79f73a2fc7a136f3edbe9"
42804280 }
42814281 ],
4282 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4283 "acceptedAt": "2026-09-12T00:16:58.262Z"
4282 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4283 "acceptedAt": "2026-09-12T00:25:34.631Z"
42844284 },
42854285 {
42864286 "type": "VERSION_CONFLICT",
……@@ -4294,8 +4294,8 @@
42944294 "tree": "4526fe9dc5468264c7171ec16e9303726674b7b44ca0b3e09cb8b9a6a1be6169"
42954295 }
42964296 ],
4297 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4298 "acceptedAt": "2026-09-12T00:16:58.262Z"
4297 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4298 "acceptedAt": "2026-09-12T00:25:34.631Z"
42994299 },
43004300 {
43014301 "type": "VERSION_CONFLICT",
……@@ -4309,8 +4309,8 @@
43094309 "tree": "32b291cd756e142da18a6ed01c351e024fcedc3e2b1ecfbfb85f3106ada4419b"
43104310 }
43114311 ],
4312 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4313 "acceptedAt": "2026-09-12T00:16:58.262Z"
4312 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4313 "acceptedAt": "2026-09-12T00:25:34.631Z"
43144314 },
43154315 {
43164316 "type": "VERSION_CONFLICT",
……@@ -4324,8 +4324,8 @@
43244324 "tree": "4a8cd3b0093092b33088617dc0415e8179882d7f2f13b7649962ca20b08beeed"
43254325 }
43264326 ],
4327 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4328 "acceptedAt": "2026-09-12T00:16:58.262Z"
4327 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4328 "acceptedAt": "2026-09-12T00:25:34.631Z"
43294329 },
43304330 {
43314331 "type": "VERSION_CONFLICT",
……@@ -4339,8 +4339,8 @@
43394339 "tree": "4f63f165ee6a581f689b40c4a40df4df01ab6fba17d46bd29e40ef5bbdd9884f"
43404340 }
43414341 ],
4342 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4343 "acceptedAt": "2026-09-12T00:16:58.262Z"
4342 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4343 "acceptedAt": "2026-09-12T00:25:34.631Z"
43444344 },
43454345 {
43464346 "type": "VERSION_CONFLICT",
……@@ -4354,8 +4354,8 @@
43544354 "tree": "735109c736c0bd3413ab47414fd8b6ea9a99c401fe8e6d608459c79293806754"
43554355 }
43564356 ],
4357 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4358 "acceptedAt": "2026-09-12T00:16:58.262Z"
4357 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4358 "acceptedAt": "2026-09-12T00:25:34.631Z"
43594359 },
43604360 {
43614361 "type": "VERSION_CONFLICT",
……@@ -4369,8 +4369,8 @@
43694369 "tree": "b32b75bf1b2acdd18ed0cc370d01d477fba42ba09fd1f2f78af3ebad925e72d5"
43704370 }
43714371 ],
4372 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4373 "acceptedAt": "2026-09-12T00:16:58.262Z"
4372 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4373 "acceptedAt": "2026-09-12T00:25:34.631Z"
43744374 },
43754375 {
43764376 "type": "VERSION_CONFLICT",
……@@ -4389,8 +4389,8 @@
43894389 "tree": "189397178121e9a18430943345dab820691648a324229071ceb980bbb77a7393"
43904390 }
43914391 ],
4392 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4393 "acceptedAt": "2026-09-12T00:16:58.262Z"
4392 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4393 "acceptedAt": "2026-09-12T00:25:34.631Z"
43944394 },
43954395 {
43964396 "type": "VERSION_CONFLICT",
……@@ -4404,8 +4404,8 @@
44044404 "tree": "3bedca5287a9041fc2fd5ec5ace4df1fa86240e6afec0a105ba67398bafc09a5"
44054405 }
44064406 ],
4407 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4408 "acceptedAt": "2026-09-12T00:16:58.262Z"
4407 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4408 "acceptedAt": "2026-09-12T00:25:34.631Z"
44094409 },
44104410 {
44114411 "type": "VERSION_CONFLICT",
……@@ -4419,8 +4419,8 @@
44194419 "tree": "b9ca9b272008bd67672a1b252390e8b527095c46b46cf6b6725a7a187eb038b6"
44204420 }
44214421 ],
4422 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4423 "acceptedAt": "2026-09-12T00:16:58.262Z"
4422 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4423 "acceptedAt": "2026-09-12T00:25:34.631Z"
44244424 },
44254425 {
44264426 "type": "VERSION_CONFLICT",
……@@ -4434,8 +4434,8 @@
44344434 "tree": "7793645d7d4248d6d6533e3cbcd4bf40bed4f38c678b0ca0606c15640ba5e7b9"
44354435 }
44364436 ],
4437 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4438 "acceptedAt": "2026-09-12T00:16:58.262Z"
4437 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4438 "acceptedAt": "2026-09-12T00:25:34.631Z"
44394439 },
44404440 {
44414441 "type": "VERSION_CONFLICT",
……@@ -4449,8 +4449,8 @@
44494449 "tree": "79447513d4ac0357c8d60bdc7b81b3c5143d761b26700340cba266c4d1cb77b0"
44504450 }
44514451 ],
4452 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4453 "acceptedAt": "2026-09-12T00:16:58.262Z"
4452 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4453 "acceptedAt": "2026-09-12T00:25:34.631Z"
44544454 },
44554455 {
44564456 "type": "VERSION_CONFLICT",
……@@ -4464,8 +4464,8 @@
44644464 "tree": "279d4e0447eb79fd75816864ba7f41f2df03080c9da69ea4603fe2900c8d9e53"
44654465 }
44664466 ],
4467 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4468 "acceptedAt": "2026-09-12T00:16:58.262Z"
4467 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4468 "acceptedAt": "2026-09-12T00:25:34.631Z"
44694469 },
44704470 {
44714471 "type": "VERSION_CONFLICT",
……@@ -4479,8 +4479,8 @@
44794479 "tree": "ed4e0b480417610e195ff0b59e5b9a453abbf2db6d6ce72d7cd522269f835e9a"
44804480 }
44814481 ],
4482 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4483 "acceptedAt": "2026-09-12T00:16:58.262Z"
4482 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4483 "acceptedAt": "2026-09-12T00:25:34.631Z"
44844484 },
44854485 {
44864486 "type": "VERSION_CONFLICT",
……@@ -4499,8 +4499,8 @@
44994499 "tree": "a9e07d54e526c838dfe5e27df33936682aefb218f61a45b6c15289243999f648"
45004500 }
45014501 ],
4502 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4503 "acceptedAt": "2026-09-12T00:16:58.262Z"
4502 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4503 "acceptedAt": "2026-09-12T00:25:34.631Z"
45044504 },
45054505 {
45064506 "type": "VERSION_CONFLICT",
……@@ -4514,8 +4514,8 @@
45144514 "tree": "9d84c86441fe138b227c4fd01d1e948aa83bf8d13cfb1aa957838feea05fbb57"
45154515 }
45164516 ],
4517 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4518 "acceptedAt": "2026-09-12T00:16:58.262Z"
4517 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4518 "acceptedAt": "2026-09-12T00:25:34.631Z"
45194519 },
45204520 {
45214521 "type": "VERSION_CONFLICT",
……@@ -4529,8 +4529,8 @@
45294529 "tree": "4a3afbe2fc7a882e24454eb2e7e7cfe6816811ee5d73ae0f92e8b9ea9cfb8c78"
45304530 }
45314531 ],
4532 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4533 "acceptedAt": "2026-09-12T00:16:58.262Z"
4532 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4533 "acceptedAt": "2026-09-12T00:25:34.631Z"
45344534 },
45354535 {
45364536 "type": "VERSION_CONFLICT",
……@@ -4544,8 +4544,8 @@
45444544 "tree": "1e5c5810d8971a3c27fde7fb0e4d633dcabbe2cf98d8df86b473f8d67eead8b9"
45454545 }
45464546 ],
4547 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4548 "acceptedAt": "2026-09-12T00:16:58.262Z"
4547 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4548 "acceptedAt": "2026-09-12T00:25:34.631Z"
45494549 },
45504550 {
45514551 "type": "VERSION_CONFLICT",
……@@ -4559,8 +4559,8 @@
45594559 "tree": "e3478f50e85ba0ac288efed83e84c992483904897dc39ccf243d20a4ea25d972"
45604560 }
45614561 ],
4562 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4563 "acceptedAt": "2026-09-12T00:16:58.262Z"
4562 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4563 "acceptedAt": "2026-09-12T00:25:34.631Z"
45644564 },
45654565 {
45664566 "type": "VERSION_CONFLICT",
……@@ -4574,8 +4574,8 @@
45744574 "tree": "c05d8bf7eab5024ea98140f78c84d12fb494b4b7b10c7eedeb50b2be842fa519"
45754575 }
45764576 ],
4577 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4578 "acceptedAt": "2026-09-12T00:16:58.262Z"
4577 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4578 "acceptedAt": "2026-09-12T00:25:34.631Z"
45794579 },
45804580 {
45814581 "type": "VERSION_CONFLICT",
……@@ -4589,8 +4589,8 @@
45894589 "tree": "0697bb338bbcec517353cffcdb2345a8232b9b90cc627a549a306f0323576390"
45904590 }
45914591 ],
4592 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4593 "acceptedAt": "2026-09-12T00:16:58.262Z"
4592 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4593 "acceptedAt": "2026-09-12T00:25:34.631Z"
45944594 },
45954595 {
45964596 "type": "VERSION_CONFLICT",
……@@ -4604,8 +4604,8 @@
46044604 "tree": "a366a73e74f8d80f106465058083b62bb70b833c721047124e99c89880e5c706"
46054605 }
46064606 ],
4607 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4608 "acceptedAt": "2026-09-12T00:16:58.262Z"
4607 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4608 "acceptedAt": "2026-09-12T00:25:34.631Z"
46094609 },
46104610 {
46114611 "type": "VERSION_CONFLICT",
……@@ -4619,8 +4619,8 @@
46194619 "tree": "7912e790c9e87e9829a6daa037c5f5da0cc4355214f51d334f96e57678dfddeb"
46204620 }
46214621 ],
4622 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4623 "acceptedAt": "2026-09-12T00:16:58.262Z"
4622 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4623 "acceptedAt": "2026-09-12T00:25:34.631Z"
46244624 },
46254625 {
46264626 "type": "VERSION_CONFLICT",
……@@ -4634,8 +4634,8 @@
46344634 "tree": "003ac841449fcdda1abc3c87fee291a5d9596d245925a7298bd8ef5f8e423eae"
46354635 }
46364636 ],
4637 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4638 "acceptedAt": "2026-09-12T00:16:58.262Z"
4637 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4638 "acceptedAt": "2026-09-12T00:25:34.631Z"
46394639 },
46404640 {
46414641 "type": "VERSION_CONFLICT",
……@@ -4654,8 +4654,8 @@
46544654 "tree": "7b3f314cf92558edbc90295c573885276a6add925318998c829c03d335edc765"
46554655 }
46564656 ],
4657 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4658 "acceptedAt": "2026-09-12T00:16:58.262Z"
4657 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4658 "acceptedAt": "2026-09-12T00:25:34.631Z"
46594659 },
46604660 {
46614661 "type": "VERSION_CONFLICT",
……@@ -4669,8 +4669,8 @@
46694669 "tree": "c486fe73805bf013e9c5de91b5780d1aa5d2b2295306f411f7863757aaa05e60"
46704670 }
46714671 ],
4672 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4673 "acceptedAt": "2026-09-12T00:16:58.262Z"
4672 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4673 "acceptedAt": "2026-09-12T00:25:34.631Z"
46744674 },
46754675 {
46764676 "type": "VERSION_CONFLICT",
……@@ -4684,8 +4684,8 @@
46844684 "tree": "10193a86e2c6f80c2d4377379a0aae4a959f8a40fd9e852b85fee081bfe6675a"
46854685 }
46864686 ],
4687 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4688 "acceptedAt": "2026-09-12T00:16:58.262Z"
4687 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4688 "acceptedAt": "2026-09-12T00:25:34.631Z"
46894689 },
46904690 {
46914691 "type": "VERSION_CONFLICT",
……@@ -4699,8 +4699,8 @@
46994699 "tree": "20b1ecd2937f3e35102892288234b86a8ec6deb3711016d96ff3509474ac465b"
47004700 }
47014701 ],
4702 "reason": "ap-bridge 0.9.97 — operator recovery over the local control socket",
4703 "acceptedAt": "2026-09-12T00:16:58.262Z"
4702 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4703 "acceptedAt": "2026-09-12T00:25:34.631Z"
47044704 }
47054705 ]
47064706}
index.js +4 -0
……@@ -537,6 +537,10 @@ async function main () {
537537 {
538538 method: req.method,
539539 path: (req.url || '/').split('?')[0],
540 // ⚠ Parsed here rather than in the routes, so a handler never sees a raw URL. The timeline
541 // pages on `offset`, and a page control that reads its own query string is the one place a
542 // surface starts re-implementing a URL parser.
543 query: Object.fromEntries(new URLSearchParams((req.url || '').split('?')[1] || '')),
540544 form,
541545 headers: req.headers || {},
542546 // ⛔ FROM THE SOCKET. The recovery route gates on being on this machine, and a header would
lib/admin.js +62 -1
……@@ -96,7 +96,8 @@ export function renderOverview ({ host, registrations = [], burned = [], inbound
9696 '<p class="who">Only meaningful on an invite-only bridge. An invite is single-use and is consumed ' +
9797 'before the claim can fail on anything else.</p>' +
9898 '<h2>Released names</h2>' + dead +
99 '<h2>Inbound from the fediverse</h2>' + capNote + feed)
99 '<h2>Inbound from the fediverse</h2>' + capNote + feed +
100 '<p><a href="/admin/timeline">the full timeline →</a></p>')
100101}
101102
102103export function renderInvite (token, host) {
……@@ -106,6 +107,53 @@ export function renderInvite (token, host) {
106107 '<p><a href="/admin">back</a></p>')
107108}
108109
110
111/**
112 * What the fediverse has sent this bridge, a page at a time.
113 *
114 * ⛔ THIS IS THE INBOUND SIDE. Outbound posts live in the peer's own feed, which this surface has no
115 * handle on — the bridge PROJECTS them from a feed it holds rather than storing a timeline of them. So
116 * a page claiming to be "the bridge's posts" would either be empty or quietly be this.
117 *
118 * ⚠ AND EVERY ROW HERE IS SOMETHING A STRANGER SENT. It is signature-verified at the inbox, but the
119 * CONTENT is theirs, so it is escaped like any other untrusted text and rendered as a flat list rather
120 * than as threads — inbound-store.js is explicit that it "does not index, thread, or render".
121 */
122export function renderTimeline ({ items = [], offset = 0, limit = 50, total = 0, cap = null } = {}) {
123 const rows = items.length
124 ? '<table><tr><th>#</th><th>type</th><th>from</th><th>content</th><th>at</th></tr>' +
125 items.map((i, n) => {
126 const body = String(i.content || i.summary || '').replace(/<[^>]*>/g, ' ').trim()
127 return '<tr><td class="who">' + (offset + n + 1) + '</td>' +
128 '<td>' + esc(i.type || '?') + '</td>' +
129 '<td><code>' + esc(i.actor || i.actorUri || '—') + '</code></td>' +
130 '<td>' + esc(body.length > 160 ? body.slice(0, 160) + '…' : body) + '</td>' +
131 '<td class="who">' + esc(when(i.receivedAt)) + '</td></tr>'
132 }).join('') + '</table>'
133 : '<p class="none">Nothing on this page.</p>'
134
135 const next = offset + items.length
136 // ⛔ OFFERED ONLY WHEN THERE IS MORE. A "load 50 more" that returns an empty page is the control
137 // lying about the data behind it.
138 const more = next < total
139 ? '<p><a href="/admin/timeline?offset=' + next + '">load ' + Math.min(limit, total - next) + ' more →</a></p>'
140 : '<p class="who">That is everything this bridge is holding.</p>'
141 const back = offset > 0
142 ? '<p><a href="/admin/timeline?offset=' + Math.max(0, offset - limit) + '">← previous</a></p>'
143 : ''
144
145 return page('inbound timeline',
146 '<p class="who"><a href="/admin">← the bridge</a></p>' +
147 '<p>Showing <strong>' + (items.length ? (offset + 1) + '–' + next : '0') + '</strong> of ' +
148 '<strong>' + total + '</strong> held' +
149 (cap ? ' <span class="who">(cap ' + esc(cap) + ')</span>' : '') + '.</p>' +
150 rows + more + back +
151 // ⚠ Said here as well as on the overview, because this is the page where somebody notices the
152 // oldest row and wonders where the rest went.
153 '<p class="who">Newest first, and bounded: the oldest is evicted once the cap is reached. ' +
154 '<span class="warn">The fediverse has no backlink index, so an evicted item cannot be backfilled.</span></p>')
155}
156
109157/**
110158 * The whole admin surface as one function.
111159 *
……@@ -130,6 +178,19 @@ export async function adminRoutes (req, { registrations, inbound, config }) {
130178 }))
131179 }
132180
181 if (req.method === 'GET' && path === '/admin/timeline') {
182 const limit = 50
183 const offset = Math.max(0, Number(req.query && req.query.offset) || 0)
184 if (!inbound) return html(200, renderTimeline({ items: [], total: 0, offset, limit }))
185 return html(200, renderTimeline({
186 items: await inbound.list({ limit, offset }),
187 total: typeof inbound.count === 'function' ? await inbound.count() : 0,
188 offset,
189 limit,
190 cap: config.inboundCap
191 }))
192 }
193
133194 if (req.method === 'POST' && path === '/admin/invite') {
134195 const token = await registrations.invite()
135196 return html(200, renderInvite(typeof token === 'string' ? token : (token && token.invite) || String(token), config.host))
lib/inbound-store.js +20 -3
……@@ -96,9 +96,21 @@ export class InboundStore {
9696 return before - this._items.length
9797 }
9898
99 async list ({ limit = 50 } = {}) {
100 return this._load().slice(0, limit)
99 /**
100 * A page of what arrived, newest first.
101 *
102 * ⛔ OFFSET, NOT A CURSOR, and deliberately. The store is newest-first and BOUNDED — eviction happens
103 * at the head, so an offset can only ever drift by however many arrived while an operator was
104 * reading, which shows up as a repeated row rather than a lost one. A cursor would be the right
105 * answer for a log that grows without limit; this one is capped by construction.
106 */
107 async list ({ limit = 50, offset = 0 } = {}) {
108 const from = Math.max(0, Number(offset) || 0)
109 return this._load().slice(from, from + limit)
101110 }
111
112 /** How many are held, so a page can say whether there is more without fetching it. */
113 async count () { return this._load().length }
102114}
103115
104116// The test double, matching MemoryFollowerStore / MemoryState.
……@@ -117,5 +129,10 @@ export class MemoryInboundStore {
117129 return before - this.items.length
118130 }
119131
120 async list ({ limit = 50 } = {}) { return this.items.slice(0, limit) }
132 async list ({ limit = 50, offset = 0 } = {}) {
133 const from = Math.max(0, Number(offset) || 0)
134 return this.items.slice(from, from + limit)
135 }
136
137 async count () { return this.items.length }
121138}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.98",
3 "version": "0.9.99",
44 "description": "ActivityPub membrane — a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/admin-auth.test.js +50 -0
……@@ -211,3 +211,53 @@ test('★★★ recovery issues an invite — it does NOT re-arm the bootstrap t
211211 const refused = await serveAdmin(req('POST', '/admin/session', { form: { ...(await stranger.proof(c)), bootstrap: TOKEN } }), c)
212212 t.is(refused.status, 403, 'the spent bootstrap token still admits nobody')
213213})
214
215test('★★★ the timeline pages through what arrived, newest first', async (t) => {
216 const { MemoryInboundStore } = await import('../lib/inbound-store.js')
217 const c = await ctx()
218 // 120 items so there is a third page — enough that "load 50 more" is not a no-op.
219 c.inbound = new MemoryInboundStore(
220 Array.from({ length: 120 }, (_, i) => ({ id: 'i' + i, type: 'Create', actor: 'https://m.example/u/' + i, content: 'post ' + i, receivedAt: 1788800000000 + i })),
221 500
222 )
223 const first = await browser()
224 const login = await serveAdmin(req('POST', '/admin/session', { form: { ...(await first.proof(c)), bootstrap: TOKEN } }), c)
225 const cookie = { cookie: COOKIE + '=' + login.headers['set-cookie'].split(';')[0].split('=')[1] }
226
227 const p1 = await serveAdmin(req('GET', '/admin/timeline', { headers: cookie, query: {} }), c)
228 t.is(p1.status, 200)
229 t.ok(/1–50<\/strong> of <strong>120/.test(p1.body), 'it says where you are in what it holds')
230 t.ok(/post 0</.test(p1.body), 'newest first')
231 t.absent(/post 50</.test(p1.body), 'and page one stops at 50')
232 t.ok(/offset=50"?>load 50 more/.test(p1.body), 'with a link to the next 50')
233
234 const p2 = await serveAdmin(req('GET', '/admin/timeline', { headers: cookie, query: { offset: '50' } }), c)
235 t.ok(/post 50</.test(p2.body), 'the second page continues where the first stopped')
236 t.ok(/offset=0"?>← previous/.test(p2.body), 'and can go back')
237
238 // ⛔ The last page must NOT offer "load 50 more" — a control that returns an empty page is the page
239 // lying about the data behind it.
240 const p3 = await serveAdmin(req('GET', '/admin/timeline', { headers: cookie, query: { offset: '100' } }), c)
241 t.ok(/post 119</.test(p3.body), 'the tail is reachable')
242 t.absent(/load \d+ more/.test(p3.body), 'no next link when there is no next page')
243 t.ok(/That is everything/.test(p3.body), 'it says so instead')
244})
245
246test('★★ the timeline is gated like every other page', async (t) => {
247 const c = await ctx()
248 const res = await serveAdmin(req('GET', '/admin/timeline', { query: {} }), c)
249 t.is(res.status, 303, 'no session, no timeline')
250})
251
252test('★★ inbound content is escaped — it was written by a stranger', async (t) => {
253 const { MemoryInboundStore } = await import('../lib/inbound-store.js')
254 const c = await ctx()
255 c.inbound = new MemoryInboundStore([{ id: 'x', type: 'Create', actor: 'https://m.example/u/1', content: '<img src=x onerror=alert(1)>hello', receivedAt: 1 }], 500)
256 const first = await browser()
257 const login = await serveAdmin(req('POST', '/admin/session', { form: { ...(await first.proof(c)), bootstrap: TOKEN } }), c)
258 const cookie = { cookie: COOKIE + '=' + login.headers['set-cookie'].split(';')[0].split('=')[1] }
259
260 const res = await serveAdmin(req('GET', '/admin/timeline', { headers: cookie, query: {} }), c)
261 t.absent(/<img src=x/.test(res.body), 'no markup from a remote server reaches the operator page intact')
262 t.ok(/hello/.test(res.body), 'but the text is still readable')
263})