OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 73e4060

0.10.0: a public inbound timeline that cannot publish a DM
⛔ THE STORE COULD NOT TELL A REPLY FROM A DIRECT MESSAGE. Mastodon delivers DMs to an inbox exactly as it delivers replies — same signature check, same handler, same store — and `normalize()` kept id/type/actor/objectId/inReplyTo/content/url/published and DROPPED `to` and `cc`. So nothing in a stored record could answer "was this addressed to the public?", and rendering that store without a login would have published somebody's DM on our domain. The addressing is not recoverable afterwards: normalize drops it, and the remote object is only refetchable while its server still serves it. So `public` is decided AT ARRIVAL and stored. ⛔ FAIL CLOSED, TWICE. isPublicActivity returns false for anything it cannot positively show to be public, and the route requires `=== true` — so records written before this field existed are `undefined` and are withheld. An operator sees a shorter public page after upgrading; the alternative is publishing a DM because a record predates the question being asked. ⚠ THREE SPELLINGS. The full activitystreams URI is canonical, `as:Public` is the compacted JSON-LD form, and bare `Public` still arrives from older implementations — and Mastodon often addresses the OBJECT rather than the activity. Matching only the canonical URI fails in the safe direction while hiding half the fediverse from a public page. All four cases are asserted. ⛔ FILTERED BEFORE PAGING, never after: paging first and filtering second yields short pages and an offset that no longer means what the link said. The content is still somebody else's: markup is stripped AND then escaped — stripping alone leaves `&lt;script&gt;` readable as text, escaping alone lets a remote server choose this page's layout. Marked noindex, because republishing other people's words on our domain is one thing and letting a crawler take a second permanent copy is a decision nobody made. 1063/1063 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
73e4060efd73c77a4db70740fc67425535a63227
by plankatron <fleeky@gmail.com> · 2026-09-12T00:37:18.000Z

Changed files (6) +310 -94

.pgh-lock.json +93 -93
……@@ -1,9 +1,9 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.98",
3 "version": "0.9.99",
44 "runtime": "bare",
5 "rootTreeHash": "80795269da91cedadd2a9d3398996d884aaa1e5345e6d8b14bbdf87dde10d70f",
6 "publishedAt": "2026-09-12T00:25:34.585Z",
5 "rootTreeHash": "901431e785e78776a3291b2fdb3758e3273bc9b2567764c54b84ec126ce8c851",
6 "publishedAt": "2026-09-12T00:28:10.087Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -4004,8 +4004,8 @@
40044004 "tree": "38f44af48b2d59b46f1b5db31b81d02f3ca8b8bad6bc0fb9fffff698241f5e37"
40054005 }
40064006 ],
4007 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4008 "acceptedAt": "2026-09-12T00:25:34.631Z"
4007 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4008 "acceptedAt": "2026-09-12T00:28:10.119Z"
40094009 },
40104010 {
40114011 "type": "VERSION_CONFLICT",
……@@ -4019,8 +4019,8 @@
40194019 "tree": "ee91b6bd77101e3404ca571b2e5adf0f47029d174398a05f50294fc207e39d99"
40204020 }
40214021 ],
4022 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4023 "acceptedAt": "2026-09-12T00:25:34.631Z"
4022 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4023 "acceptedAt": "2026-09-12T00:28:10.119Z"
40244024 },
40254025 {
40264026 "type": "VERSION_CONFLICT",
……@@ -4034,8 +4034,8 @@
40344034 "tree": "3bb78d8f06263a549bd1077f37a7df257b1d557e44608631435af625f05a9c11"
40354035 }
40364036 ],
4037 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4038 "acceptedAt": "2026-09-12T00:25:34.631Z"
4037 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4038 "acceptedAt": "2026-09-12T00:28:10.119Z"
40394039 },
40404040 {
40414041 "type": "VERSION_CONFLICT",
……@@ -4054,8 +4054,8 @@
40544054 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
40554055 }
40564056 ],
4057 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4058 "acceptedAt": "2026-09-12T00:25:34.631Z"
4057 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4058 "acceptedAt": "2026-09-12T00:28:10.119Z"
40594059 },
40604060 {
40614061 "type": "VERSION_CONFLICT",
……@@ -4069,8 +4069,8 @@
40694069 "tree": "da4c6b2654e9d66f10b0faa989311cf182878c24ab51b1c41458cfbf37ad29cd"
40704070 }
40714071 ],
4072 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4073 "acceptedAt": "2026-09-12T00:25:34.631Z"
4072 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4073 "acceptedAt": "2026-09-12T00:28:10.119Z"
40744074 },
40754075 {
40764076 "type": "VERSION_CONFLICT",
……@@ -4084,8 +4084,8 @@
40844084 "tree": "40be8f559a13b54ebf0689e63e6c70f0d218035359e17c497ed7bc8a85c3cc95"
40854085 }
40864086 ],
4087 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4088 "acceptedAt": "2026-09-12T00:25:34.631Z"
4087 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4088 "acceptedAt": "2026-09-12T00:28:10.119Z"
40894089 },
40904090 {
40914091 "type": "VERSION_CONFLICT",
……@@ -4099,8 +4099,8 @@
40994099 "tree": "3eae85efce82299769898c8151b5ed0a94e5a5ebdbcdee2105b815ca733a61cb"
41004100 }
41014101 ],
4102 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4103 "acceptedAt": "2026-09-12T00:25:34.631Z"
4102 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4103 "acceptedAt": "2026-09-12T00:28:10.119Z"
41044104 },
41054105 {
41064106 "type": "VERSION_CONFLICT",
……@@ -4114,8 +4114,8 @@
41144114 "tree": "8d9e01d443a127781773598d2e77c3f372334fad09e20623a819dabe49b22e1a"
41154115 }
41164116 ],
4117 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4118 "acceptedAt": "2026-09-12T00:25:34.631Z"
4117 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4118 "acceptedAt": "2026-09-12T00:28:10.119Z"
41194119 },
41204120 {
41214121 "type": "VERSION_CONFLICT",
……@@ -4129,8 +4129,8 @@
41294129 "tree": "dc1ad6c999fe90d946ba0ef34698ea9af451b639be2ad1c5b61eabb08337a6ca"
41304130 }
41314131 ],
4132 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4133 "acceptedAt": "2026-09-12T00:25:34.631Z"
4132 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4133 "acceptedAt": "2026-09-12T00:28:10.119Z"
41344134 },
41354135 {
41364136 "type": "VERSION_CONFLICT",
……@@ -4144,8 +4144,8 @@
41444144 "tree": "88b4779570e5113aa21b204989ac018d1628b387681f7e53428a7c99114fb701"
41454145 }
41464146 ],
4147 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4148 "acceptedAt": "2026-09-12T00:25:34.631Z"
4147 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4148 "acceptedAt": "2026-09-12T00:28:10.119Z"
41494149 },
41504150 {
41514151 "type": "VERSION_CONFLICT",
……@@ -4159,8 +4159,8 @@
41594159 "tree": "720b867ec4300a3caa2325eb1449499f9940bfb88341ecceb5992fa0fdf5f37d"
41604160 }
41614161 ],
4162 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4163 "acceptedAt": "2026-09-12T00:25:34.631Z"
4162 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4163 "acceptedAt": "2026-09-12T00:28:10.119Z"
41644164 },
41654165 {
41664166 "type": "VERSION_CONFLICT",
……@@ -4174,8 +4174,8 @@
41744174 "tree": "b697fe174482a708b49d0764cd4786291845f0034a31e0699381ca7e7b269d04"
41754175 }
41764176 ],
4177 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4178 "acceptedAt": "2026-09-12T00:25:34.631Z"
4177 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4178 "acceptedAt": "2026-09-12T00:28:10.119Z"
41794179 },
41804180 {
41814181 "type": "VERSION_CONFLICT",
……@@ -4189,8 +4189,8 @@
41894189 "tree": "767f07242277ae80bd395de5a4969fcdbf956c216158ae517fb2b562558ac1a4"
41904190 }
41914191 ],
4192 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4193 "acceptedAt": "2026-09-12T00:25:34.631Z"
4192 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4193 "acceptedAt": "2026-09-12T00:28:10.119Z"
41944194 },
41954195 {
41964196 "type": "VERSION_CONFLICT",
……@@ -4204,8 +4204,8 @@
42044204 "tree": "de7deed21f587ffc8bde4971abe00fca77bb6c71c4e7effb31ad34ee330815df"
42054205 }
42064206 ],
4207 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4208 "acceptedAt": "2026-09-12T00:25:34.631Z"
4207 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4208 "acceptedAt": "2026-09-12T00:28:10.119Z"
42094209 },
42104210 {
42114211 "type": "VERSION_CONFLICT",
……@@ -4219,8 +4219,8 @@
42194219 "tree": "2df42517ebf73cd9c11f2f652f75e87401fd4ed7b856c0adc15c33baf1d4f1ad"
42204220 }
42214221 ],
4222 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4223 "acceptedAt": "2026-09-12T00:25:34.631Z"
4222 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4223 "acceptedAt": "2026-09-12T00:28:10.119Z"
42244224 },
42254225 {
42264226 "type": "VERSION_CONFLICT",
……@@ -4234,8 +4234,8 @@
42344234 "tree": "325aca171cbff560487177584078c8066133e91176639ca47c98d70e195aa92e"
42354235 }
42364236 ],
4237 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4238 "acceptedAt": "2026-09-12T00:25:34.631Z"
4237 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4238 "acceptedAt": "2026-09-12T00:28:10.119Z"
42394239 },
42404240 {
42414241 "type": "VERSION_CONFLICT",
……@@ -4264,8 +4264,8 @@
42644264 "tree": "89a40400106bf4fef95fb5c44a66ee29aada6f76c7740bfba09d8aeeb841f39c"
42654265 }
42664266 ],
4267 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4268 "acceptedAt": "2026-09-12T00:25:34.631Z"
4267 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4268 "acceptedAt": "2026-09-12T00:28:10.119Z"
42694269 },
42704270 {
42714271 "type": "VERSION_CONFLICT",
……@@ -4279,8 +4279,8 @@
42794279 "tree": "1bd0eb382789c62cd4622d47bffb60984fdd766260c79f73a2fc7a136f3edbe9"
42804280 }
42814281 ],
4282 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4283 "acceptedAt": "2026-09-12T00:25:34.631Z"
4282 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4283 "acceptedAt": "2026-09-12T00:28:10.119Z"
42844284 },
42854285 {
42864286 "type": "VERSION_CONFLICT",
……@@ -4294,8 +4294,8 @@
42944294 "tree": "4526fe9dc5468264c7171ec16e9303726674b7b44ca0b3e09cb8b9a6a1be6169"
42954295 }
42964296 ],
4297 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4298 "acceptedAt": "2026-09-12T00:25:34.631Z"
4297 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4298 "acceptedAt": "2026-09-12T00:28:10.119Z"
42994299 },
43004300 {
43014301 "type": "VERSION_CONFLICT",
……@@ -4309,8 +4309,8 @@
43094309 "tree": "32b291cd756e142da18a6ed01c351e024fcedc3e2b1ecfbfb85f3106ada4419b"
43104310 }
43114311 ],
4312 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4313 "acceptedAt": "2026-09-12T00:25:34.631Z"
4312 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4313 "acceptedAt": "2026-09-12T00:28:10.119Z"
43144314 },
43154315 {
43164316 "type": "VERSION_CONFLICT",
……@@ -4324,8 +4324,8 @@
43244324 "tree": "4a8cd3b0093092b33088617dc0415e8179882d7f2f13b7649962ca20b08beeed"
43254325 }
43264326 ],
4327 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4328 "acceptedAt": "2026-09-12T00:25:34.631Z"
4327 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4328 "acceptedAt": "2026-09-12T00:28:10.119Z"
43294329 },
43304330 {
43314331 "type": "VERSION_CONFLICT",
……@@ -4339,8 +4339,8 @@
43394339 "tree": "4f63f165ee6a581f689b40c4a40df4df01ab6fba17d46bd29e40ef5bbdd9884f"
43404340 }
43414341 ],
4342 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4343 "acceptedAt": "2026-09-12T00:25:34.631Z"
4342 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4343 "acceptedAt": "2026-09-12T00:28:10.119Z"
43444344 },
43454345 {
43464346 "type": "VERSION_CONFLICT",
……@@ -4354,8 +4354,8 @@
43544354 "tree": "735109c736c0bd3413ab47414fd8b6ea9a99c401fe8e6d608459c79293806754"
43554355 }
43564356 ],
4357 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4358 "acceptedAt": "2026-09-12T00:25:34.631Z"
4357 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4358 "acceptedAt": "2026-09-12T00:28:10.119Z"
43594359 },
43604360 {
43614361 "type": "VERSION_CONFLICT",
……@@ -4369,8 +4369,8 @@
43694369 "tree": "b32b75bf1b2acdd18ed0cc370d01d477fba42ba09fd1f2f78af3ebad925e72d5"
43704370 }
43714371 ],
4372 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4373 "acceptedAt": "2026-09-12T00:25:34.631Z"
4372 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4373 "acceptedAt": "2026-09-12T00:28:10.119Z"
43744374 },
43754375 {
43764376 "type": "VERSION_CONFLICT",
……@@ -4389,8 +4389,8 @@
43894389 "tree": "189397178121e9a18430943345dab820691648a324229071ceb980bbb77a7393"
43904390 }
43914391 ],
4392 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4393 "acceptedAt": "2026-09-12T00:25:34.631Z"
4392 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4393 "acceptedAt": "2026-09-12T00:28:10.119Z"
43944394 },
43954395 {
43964396 "type": "VERSION_CONFLICT",
……@@ -4404,8 +4404,8 @@
44044404 "tree": "3bedca5287a9041fc2fd5ec5ace4df1fa86240e6afec0a105ba67398bafc09a5"
44054405 }
44064406 ],
4407 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4408 "acceptedAt": "2026-09-12T00:25:34.631Z"
4407 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4408 "acceptedAt": "2026-09-12T00:28:10.119Z"
44094409 },
44104410 {
44114411 "type": "VERSION_CONFLICT",
……@@ -4419,8 +4419,8 @@
44194419 "tree": "b9ca9b272008bd67672a1b252390e8b527095c46b46cf6b6725a7a187eb038b6"
44204420 }
44214421 ],
4422 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4423 "acceptedAt": "2026-09-12T00:25:34.631Z"
4422 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4423 "acceptedAt": "2026-09-12T00:28:10.119Z"
44244424 },
44254425 {
44264426 "type": "VERSION_CONFLICT",
……@@ -4434,8 +4434,8 @@
44344434 "tree": "7793645d7d4248d6d6533e3cbcd4bf40bed4f38c678b0ca0606c15640ba5e7b9"
44354435 }
44364436 ],
4437 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4438 "acceptedAt": "2026-09-12T00:25:34.631Z"
4437 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4438 "acceptedAt": "2026-09-12T00:28:10.119Z"
44394439 },
44404440 {
44414441 "type": "VERSION_CONFLICT",
……@@ -4449,8 +4449,8 @@
44494449 "tree": "79447513d4ac0357c8d60bdc7b81b3c5143d761b26700340cba266c4d1cb77b0"
44504450 }
44514451 ],
4452 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4453 "acceptedAt": "2026-09-12T00:25:34.631Z"
4452 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4453 "acceptedAt": "2026-09-12T00:28:10.119Z"
44544454 },
44554455 {
44564456 "type": "VERSION_CONFLICT",
……@@ -4464,8 +4464,8 @@
44644464 "tree": "279d4e0447eb79fd75816864ba7f41f2df03080c9da69ea4603fe2900c8d9e53"
44654465 }
44664466 ],
4467 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4468 "acceptedAt": "2026-09-12T00:25:34.631Z"
4467 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4468 "acceptedAt": "2026-09-12T00:28:10.119Z"
44694469 },
44704470 {
44714471 "type": "VERSION_CONFLICT",
……@@ -4479,8 +4479,8 @@
44794479 "tree": "ed4e0b480417610e195ff0b59e5b9a453abbf2db6d6ce72d7cd522269f835e9a"
44804480 }
44814481 ],
4482 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4483 "acceptedAt": "2026-09-12T00:25:34.631Z"
4482 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4483 "acceptedAt": "2026-09-12T00:28:10.119Z"
44844484 },
44854485 {
44864486 "type": "VERSION_CONFLICT",
……@@ -4499,8 +4499,8 @@
44994499 "tree": "a9e07d54e526c838dfe5e27df33936682aefb218f61a45b6c15289243999f648"
45004500 }
45014501 ],
4502 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4503 "acceptedAt": "2026-09-12T00:25:34.631Z"
4502 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4503 "acceptedAt": "2026-09-12T00:28:10.119Z"
45044504 },
45054505 {
45064506 "type": "VERSION_CONFLICT",
……@@ -4514,8 +4514,8 @@
45144514 "tree": "9d84c86441fe138b227c4fd01d1e948aa83bf8d13cfb1aa957838feea05fbb57"
45154515 }
45164516 ],
4517 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4518 "acceptedAt": "2026-09-12T00:25:34.631Z"
4517 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4518 "acceptedAt": "2026-09-12T00:28:10.119Z"
45194519 },
45204520 {
45214521 "type": "VERSION_CONFLICT",
……@@ -4529,8 +4529,8 @@
45294529 "tree": "4a3afbe2fc7a882e24454eb2e7e7cfe6816811ee5d73ae0f92e8b9ea9cfb8c78"
45304530 }
45314531 ],
4532 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4533 "acceptedAt": "2026-09-12T00:25:34.631Z"
4532 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4533 "acceptedAt": "2026-09-12T00:28:10.119Z"
45344534 },
45354535 {
45364536 "type": "VERSION_CONFLICT",
……@@ -4544,8 +4544,8 @@
45444544 "tree": "1e5c5810d8971a3c27fde7fb0e4d633dcabbe2cf98d8df86b473f8d67eead8b9"
45454545 }
45464546 ],
4547 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4548 "acceptedAt": "2026-09-12T00:25:34.631Z"
4547 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4548 "acceptedAt": "2026-09-12T00:28:10.119Z"
45494549 },
45504550 {
45514551 "type": "VERSION_CONFLICT",
……@@ -4559,8 +4559,8 @@
45594559 "tree": "e3478f50e85ba0ac288efed83e84c992483904897dc39ccf243d20a4ea25d972"
45604560 }
45614561 ],
4562 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4563 "acceptedAt": "2026-09-12T00:25:34.631Z"
4562 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4563 "acceptedAt": "2026-09-12T00:28:10.119Z"
45644564 },
45654565 {
45664566 "type": "VERSION_CONFLICT",
……@@ -4574,8 +4574,8 @@
45744574 "tree": "c05d8bf7eab5024ea98140f78c84d12fb494b4b7b10c7eedeb50b2be842fa519"
45754575 }
45764576 ],
4577 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4578 "acceptedAt": "2026-09-12T00:25:34.631Z"
4577 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4578 "acceptedAt": "2026-09-12T00:28:10.119Z"
45794579 },
45804580 {
45814581 "type": "VERSION_CONFLICT",
……@@ -4589,8 +4589,8 @@
45894589 "tree": "0697bb338bbcec517353cffcdb2345a8232b9b90cc627a549a306f0323576390"
45904590 }
45914591 ],
4592 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4593 "acceptedAt": "2026-09-12T00:25:34.631Z"
4592 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4593 "acceptedAt": "2026-09-12T00:28:10.119Z"
45944594 },
45954595 {
45964596 "type": "VERSION_CONFLICT",
……@@ -4604,8 +4604,8 @@
46044604 "tree": "a366a73e74f8d80f106465058083b62bb70b833c721047124e99c89880e5c706"
46054605 }
46064606 ],
4607 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4608 "acceptedAt": "2026-09-12T00:25:34.631Z"
4607 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4608 "acceptedAt": "2026-09-12T00:28:10.119Z"
46094609 },
46104610 {
46114611 "type": "VERSION_CONFLICT",
……@@ -4619,8 +4619,8 @@
46194619 "tree": "7912e790c9e87e9829a6daa037c5f5da0cc4355214f51d334f96e57678dfddeb"
46204620 }
46214621 ],
4622 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4623 "acceptedAt": "2026-09-12T00:25:34.631Z"
4622 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4623 "acceptedAt": "2026-09-12T00:28:10.119Z"
46244624 },
46254625 {
46264626 "type": "VERSION_CONFLICT",
……@@ -4634,8 +4634,8 @@
46344634 "tree": "003ac841449fcdda1abc3c87fee291a5d9596d245925a7298bd8ef5f8e423eae"
46354635 }
46364636 ],
4637 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4638 "acceptedAt": "2026-09-12T00:25:34.631Z"
4637 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4638 "acceptedAt": "2026-09-12T00:28:10.119Z"
46394639 },
46404640 {
46414641 "type": "VERSION_CONFLICT",
……@@ -4654,8 +4654,8 @@
46544654 "tree": "7b3f314cf92558edbc90295c573885276a6add925318998c829c03d335edc765"
46554655 }
46564656 ],
4657 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4658 "acceptedAt": "2026-09-12T00:25:34.631Z"
4657 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4658 "acceptedAt": "2026-09-12T00:28:10.119Z"
46594659 },
46604660 {
46614661 "type": "VERSION_CONFLICT",
……@@ -4669,8 +4669,8 @@
46694669 "tree": "c486fe73805bf013e9c5de91b5780d1aa5d2b2295306f411f7863757aaa05e60"
46704670 }
46714671 ],
4672 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4673 "acceptedAt": "2026-09-12T00:25:34.631Z"
4672 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4673 "acceptedAt": "2026-09-12T00:28:10.119Z"
46744674 },
46754675 {
46764676 "type": "VERSION_CONFLICT",
……@@ -4684,8 +4684,8 @@
46844684 "tree": "10193a86e2c6f80c2d4377379a0aae4a959f8a40fd9e852b85fee081bfe6675a"
46854685 }
46864686 ],
4687 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4688 "acceptedAt": "2026-09-12T00:25:34.631Z"
4687 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4688 "acceptedAt": "2026-09-12T00:28:10.119Z"
46894689 },
46904690 {
46914691 "type": "VERSION_CONFLICT",
……@@ -4699,8 +4699,8 @@
46994699 "tree": "20b1ecd2937f3e35102892288234b86a8ec6deb3711016d96ff3509474ac465b"
47004700 }
47014701 ],
4702 "reason": "ap-bridge 0.9.98 — ap-bridge admin subcommand",
4703 "acceptedAt": "2026-09-12T00:25:34.631Z"
4702 "reason": "ap-bridge 0.9.99 — paged inbound timeline",
4703 "acceptedAt": "2026-09-12T00:28:10.119Z"
47044704 }
47054705 ]
47064706}
lib/inbound-store.js +39 -0
……@@ -30,6 +30,40 @@ export const DEFAULT_CAP = 500
3030// Keep the fields a notification needs and drop the rest. A remote Note carries attachments,
3131// emojis, tags and instance-specific extensions; storing the whole thing means storing whatever a
3232// remote server decides to send, forever.
33// ActivityPub's magic audience. An activity addressed to it is public by definition; anything else —
34// a followers-only post, a direct message — is not, whatever else it looks like.
35//
36// ⚠ THREE SPELLINGS IN THE WILD. The full URI is canonical, `as:Public` is the compacted JSON-LD form,
37// and bare `Public` appears from older implementations. Matching only the first quietly treats a public
38// post as private, which fails in the safe direction but hides half the fediverse from a public page.
39const PUBLIC_URIS = new Set([
40 'https://www.w3.org/ns/activitystreams#Public',
41 'as:Public',
42 'Public'
43])
44
45const audience = (x) => {
46 if (!x) return []
47 const v = [].concat(x.to || [], x.cc || [], x.bto || [], x.bcc || [])
48 return v.filter((a) => typeof a === 'string')
49}
50
51/**
52 * Was this addressed to the public?
53 *
54 * ⛔ THE ANSWER GATES A PUBLIC PAGE, so it fails closed: anything this cannot positively show to be
55 * public is treated as private. Mastodon delivers DIRECT MESSAGES to an inbox exactly like it delivers
56 * replies, and they arrive at the same handler — so an inbound store that could not tell them apart
57 * would publish somebody's DM the moment anything rendered it without a login.
58 */
59export function isPublicActivity (activity) {
60 const obj = activity && typeof activity.object === 'object' ? activity.object : null
61 for (const a of audience(activity).concat(audience(obj))) {
62 if (PUBLIC_URIS.has(a)) return true
63 }
64 return false
65}
66
3367export function normalize (activity, receivedAt) {
3468 const obj = activity && typeof activity.object === 'object' ? activity.object : null
3569 const objectId = obj ? obj.id : (typeof activity?.object === 'string' ? activity.object : null)
……@@ -46,6 +80,11 @@ export function normalize (activity, receivedAt) {
4680 content: typeof obj?.content === 'string' ? obj.content.slice(0, 4000) : null,
4781 url: obj?.url || objectId || null,
4882 published: obj?.published || activity?.published || null,
83 // ⛔ RECORDED AT ARRIVAL, because the addressing is not recoverable later — `to`/`cc` are dropped
84 // by this very function, and the object is only refetchable while the remote server still serves
85 // it. Anything stored before this field existed is `undefined`, which every reader must treat as
86 // NOT public; see the note on isPublicActivity.
87 public: isPublicActivity(activity),
4988 receivedAt
5089 }
5190}
lib/public-timeline.js +69 -0
……@@ -0,0 +1,69 @@
1// The public face of what the fediverse sent this bridge.
2//
3// ⛔ PUBLIC ITEMS ONLY, AND THE CALLER FILTERS BEFORE PAGING. This page has no login in front of it, so
4// the filter IS the safety property — Mastodon delivers DIRECT MESSAGES to an inbox exactly as it
5// delivers replies, through the same handler and into the same store. `normalize()` records `public` at
6// arrival because the addressing cannot be recovered afterwards.
7//
8// ⛔ AND IT IS STILL SOMEBODY ELSE'S TEXT. Verified at the inbox says WHO sent it, not that it is safe
9// to render — the content is HTML from a remote server, and inbound-store.js says it must be escaped by
10// whatever displays it. Markup is stripped and then escaped: stripping alone leaves `&lt;script&gt;`
11// readable as text, escaping alone leaves a remote server choosing this page's layout.
12
13const esc = (s) => String(s == null ? '' : s)
14 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
15
16const STYLE = `body{font:15px/1.6 system-ui,sans-serif;margin:0 auto;padding:32px 20px;max-width:680px;color:#111}
17h1{font-size:22px;margin-bottom:4px}.who{color:#666;font-size:13px}
18.item{padding:14px 0;border-bottom:1px solid #eee}
19.from{font-size:13px;color:#555;word-break:break-all}
20.body{margin:6px 0 0}.none{color:#999;font-style:italic}
21a{color:#06c}nav{margin-top:20px;display:flex;gap:16px}
22@media(prefers-color-scheme:dark){body{background:#111;color:#eee}.item{border-color:#333}
23.who,.from{color:#999}a{color:#6af}}`
24
25const when = (ms) => {
26 const d = new Date(Number(ms))
27 return Number.isNaN(d.getTime()) ? '' : d.toISOString().replace('T', ' ').slice(0, 16) + 'Z'
28}
29
30export function publicTimeline ({ host = '', items = [], offset = 0, limit = 50, total = 0 } = {}) {
31 const list = items.length
32 ? items.map((i) => {
33 // Strip the remote server's markup, then escape what is left.
34 const body = String(i.content || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
35 const who = String(i.actor || '').replace(/^https?:\/\//, '')
36 return '<div class="item">' +
37 '<div class="from">' + (i.url
38 ? '<a href="' + esc(i.url) + '" rel="nofollow noopener ugc">' + esc(who) + '</a>'
39 : esc(who)) +
40 ' <span class="who">· ' + esc(i.type || '') + ' · ' + esc(when(i.receivedAt)) + '</span></div>' +
41 (body ? '<p class="body">' + esc(body) + '</p>' : '') +
42 '</div>'
43 }).join('')
44 : '<p class="none">Nothing public has arrived yet.</p>'
45
46 const next = offset + items.length
47 const nav = [
48 offset > 0 ? '<a href="/timeline?offset=' + Math.max(0, offset - limit) + '">← newer</a>' : '',
49 // ⛔ Only when there is more. A "load more" that returns an empty page is the page lying about
50 // the data behind it.
51 next < total ? '<a href="/timeline?offset=' + next + '">' + Math.min(limit, total - next) + ' older →</a>' : ''
52 ].filter(Boolean).join('')
53
54 return '<!doctype html><meta charset="utf-8"><title>timeline · ' + esc(host) + '</title>' +
55 '<meta name="viewport" content="width=device-width,initial-scale=1">' +
56 // ⚠ Not indexed. This is other people's words republished on our domain; letting a crawler make a
57 // second permanent copy of them is a decision nobody here has made.
58 '<meta name="robots" content="noindex,nofollow">' +
59 '<style>' + STYLE + '</style>' +
60 '<h1>Inbound timeline</h1>' +
61 '<p class="who">Public replies, mentions and boosts sent to <strong>' + esc(host) + '</strong>. ' +
62 'Direct and followers-only messages are never shown here.</p>' +
63 list +
64 (nav ? '<nav>' + nav + '</nav>' : '') +
65 '<p class="who">Showing ' + (items.length ? (offset + 1) + '–' + next : '0') + ' of ' + total +
66 ' public item(s). Newest first, and bounded — the oldest is dropped once the bridge is full.</p>'
67}
68
69export default { publicTimeline }
lib/router.js +28 -0
……@@ -22,6 +22,7 @@ const json = (status, doc, type = 'application/json') => ({
2222})
2323
2424const text = (status, s) => ({ status, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: s })
25const html = (status, s) => ({ status, headers: { 'content-type': 'text/html; charset=utf-8' }, body: s })
2526
2627export async function handleRequest (req, ctx) {
2728 const { method, path, query = {}, headers = {}, body = null } = req
……@@ -30,6 +31,33 @@ export async function handleRequest (req, ctx) {
3031 if (path === '/') return root(headers, ctx)
3132 if (path === '/healthz') return text(200, 'ok')
3233
34 // ⛔ THE PUBLIC TIMELINE — PUBLIC ITEMS ONLY, AND IT FAILS CLOSED. This renders what the fediverse
35 // sent this bridge, with no login, so the filter is the whole safety property: Mastodon delivers
36 // DIRECT MESSAGES to an inbox exactly as it delivers replies, through the same handler and into the
37 // same store. normalize() records `public` at arrival because the addressing is not recoverable
38 // afterwards — `to`/`cc` are dropped there and the remote object is only refetchable while its
39 // server still serves it.
40 //
41 // ⚠ ANYTHING STORED BEFORE THAT FIELD EXISTED IS `undefined` AND IS WITHHELD. An operator sees a
42 // shorter public page than they expect after upgrading; the alternative is publishing somebody's
43 // DM because a record predates the question being asked.
44 if (path === '/timeline') {
45 if (!ctx.inbound) return text(404, 'this bridge keeps no inbound timeline')
46 const limit = 50
47 const offset = Math.max(0, Number(query && query.offset) || 0)
48 // ⛔ FILTERED BEFORE PAGING, never after: paging first and filtering second silently yields short
49 // pages and an offset that no longer means what the link said.
50 const all = (await ctx.inbound.list({ limit: 100000, offset: 0 })).filter((i) => i.public === true)
51 const { publicTimeline } = await import('./public-timeline.js')
52 return html(200, publicTimeline({
53 host: ctx.config.host,
54 items: all.slice(offset, offset + limit),
55 total: all.length,
56 offset,
57 limit
58 }))
59 }
60
3361 // ⛔ REGISTRATION — a peer asks this bridge for a name, and on an open bridge gets one with no
3462 // operator in the loop. That is the whole point: a bridge is a service peers CONNECT TO, so
3563 // getting a name on it must be something a peer can do by itself.
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.99",
3 "version": "0.10.0",
44 "description": "ActivityPub membrane — a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/public-timeline.test.js +80 -0
……@@ -0,0 +1,80 @@
1// A page with no login in front of it, rendering what strangers sent us.
2//
3// ⛔ THE FILTER IS THE SAFETY PROPERTY. Mastodon delivers DIRECT MESSAGES to an inbox exactly as it
4// delivers replies — same handler, same store — so a public page over that store publishes somebody's
5// DM unless addressing is checked. normalize() drops `to`/`cc`, and the remote object is only
6// refetchable while its server still serves it, so the answer has to be recorded AT ARRIVAL.
7
8import '../lib/bare-globals.js'
9import test from 'brittle'
10import { normalize, isPublicActivity } from '../lib/inbound-store.js'
11import { publicTimeline } from '../lib/public-timeline.js'
12
13const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
14const note = (extra = {}, objExtra = {}) => ({
15 id: 'https://m.example/a/1',
16 type: 'Create',
17 actor: 'https://m.example/users/someone',
18 object: { id: 'https://m.example/o/1', type: 'Note', content: '<p>hi</p>', ...objExtra },
19 ...extra
20})
21
22test('★★★ a direct message is NOT public', (t) => {
23 // Addressed to one actor and nobody else — this is a DM, and it arrives at the same inbox.
24 const dm = note({ to: ['https://ap.7rnx.net/ap/users/fleeky/actor'] })
25 t.absent(isPublicActivity(dm), 'a DM is not public')
26 t.is(normalize(dm, 1).public, false, 'and the store records that at arrival')
27})
28
29test('★★★ followers-only is NOT public', (t) => {
30 const followers = note({ to: ['https://m.example/users/someone/followers'] })
31 t.absent(isPublicActivity(followers))
32 t.is(normalize(followers, 1).public, false)
33})
34
35test('★★★ public is recognised in all three spellings seen in the wild', (t) => {
36 // ⚠ The full URI is canonical, `as:Public` is the compacted JSON-LD form, bare `Public` comes from
37 // older implementations. Matching only the first fails safely but hides half the fediverse.
38 for (const form of [PUBLIC, 'as:Public', 'Public']) {
39 t.ok(isPublicActivity(note({ to: [form] })), form + ' in to')
40 t.ok(isPublicActivity(note({ cc: [form] })), form + ' in cc')
41 }
42 // And on the OBJECT rather than the activity, which is where Mastodon often puts it.
43 t.ok(isPublicActivity(note({}, { to: [PUBLIC] })), 'public on the object counts')
44})
45
46test('★★★ an item with no addressing at all is withheld', (t) => {
47 // ⛔ FAIL CLOSED. Records stored before `public` existed have `undefined`, and the route requires
48 // `=== true`. An operator sees a shorter page after upgrading; the alternative is publishing a DM
49 // because the record predates the question being asked.
50 t.absent(isPublicActivity(note({})), 'no to/cc means not public')
51 const legacy = { type: 'Create', actor: 'https://m.example/u/1', content: 'old' }
52 t.not(legacy.public, true, 'a legacy record is not `=== true`, which is what the route tests')
53})
54
55test('★★★ remote markup never reaches the page intact', (t) => {
56 const body = publicTimeline({
57 host: 'ap.7rnx.net',
58 total: 1,
59 items: [{ actor: 'https://m.example/u/1', type: 'Create', receivedAt: 1788800000000, content: '<img src=x onerror=alert(1)>hello <b>there</b>' }]
60 })
61 t.absent(/<img/.test(body), 'no remote tag survives')
62 t.absent(/onerror/.test(body), 'no remote attribute survives')
63 t.ok(/hello there/.test(body), 'the words do')
64})
65
66test('★★ it says what it does not show, and asks not to be indexed', (t) => {
67 const body = publicTimeline({ host: 'ap.7rnx.net', items: [], total: 0 })
68 t.ok(/Direct and followers-only messages are never shown/.test(body),
69 'a reader should not have to infer the policy from an absence')
70 // ⚠ Other people's words republished on our domain — letting a crawler take a second permanent copy
71 // is a decision nobody here has made.
72 t.ok(/noindex/.test(body))
73})
74
75test('★★ paging offers an older link only when there is more', (t) => {
76 const items = Array.from({ length: 50 }, (_, i) => ({ actor: 'a', type: 'Create', content: 'x', receivedAt: i }))
77 t.ok(/50 older/.test(publicTimeline({ items, total: 120, offset: 0 })), 'more to come')
78 t.absent(/older/.test(publicTimeline({ items, total: 50, offset: 0 })), 'nothing more, no link')
79 t.ok(/newer/.test(publicTimeline({ items, total: 120, offset: 50 })), 'and a way back')
80})