OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 8ab96f8

0.10.2: the public timeline becomes a feed, and shows interactions BOTH ways
The page showed only what the fediverse SENT us, as a flat list of activity types. That answers "did anything arrive" and cannot answer the thing this project keeps getting wrong: are interactions handled in both directions? A like we sent and a like we received now sit in one stream, so a broken direction is VISIBLE rather than inferred from a delivery log. ⛔ ONE InboundStore SERVES THE WHOLE PROCESS while the inbox PATHS are per-user, and nothing on a kept item said who it had been delivered to. Harmless while only the operator read it; a leak the moment a peer can ask for its own inbound. `onInbound` runs under `withUser`, so the recipient was known at arrival and simply not written down. Forward-only: `to`/`cc` are dropped at normalize and anything already stored is permanently unattributable, so a scoped read excludes it — nobody's rather than everybody's. All the withholding moved into `buildFeed`, which is pure: a DM, an item that cannot prove it was public, another peer's mail, and an activity type we have no words for are each dropped rather than guessed at. The route renders; it does not decide. ⚠ `projectLike` carries no `published` (projectAnnounce does), so `listInteractions` now returns the RECORD's time. Ordering by the activity alone would put every like at the bottom for ever. Interactions we cannot address are COUNTED on the page — "0 interactions" and "12 we cannot address" are different states and RecordSource already knew the difference. 1158/1158 pass, including the route, which had no test before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8ab96f8eaacbe76cf808b1bb8d6f673269810cad
by plankatron <fleeky@gmail.com> · 2026-09-15T15:32:09.000Z

Changed files (10) +728 -179

.pgh-lock.json +93 -93
……@@ -1,9 +1,9 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.0",
3 "version": "0.10.1",
44 "runtime": "bare",
5 "rootTreeHash": "ff8be560c36e2d327015ad0a6915bab3c821b12cc81a75f2eff14fb91ae6591b",
6 "publishedAt": "2026-09-12T00:37:22.222Z",
5 "rootTreeHash": "075afb44758c6de5a10bf35a819c5207733e68b1c791227da992867c9cd48832",
6 "publishedAt": "2026-09-12T00:46:51.247Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -4004,8 +4004,8 @@
40044004 "tree": "38f44af48b2d59b46f1b5db31b81d02f3ca8b8bad6bc0fb9fffff698241f5e37"
40054005 }
40064006 ],
4007 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4008 "acceptedAt": "2026-09-12T00:37:22.248Z"
4007 "reason": "ap-bridge 0.10.1 — silent re-auth",
4008 "acceptedAt": "2026-09-12T00:46:51.269Z"
40094009 },
40104010 {
40114011 "type": "VERSION_CONFLICT",
……@@ -4019,8 +4019,8 @@
40194019 "tree": "ee91b6bd77101e3404ca571b2e5adf0f47029d174398a05f50294fc207e39d99"
40204020 }
40214021 ],
4022 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4023 "acceptedAt": "2026-09-12T00:37:22.248Z"
4022 "reason": "ap-bridge 0.10.1 — silent re-auth",
4023 "acceptedAt": "2026-09-12T00:46:51.269Z"
40244024 },
40254025 {
40264026 "type": "VERSION_CONFLICT",
……@@ -4034,8 +4034,8 @@
40344034 "tree": "3bb78d8f06263a549bd1077f37a7df257b1d557e44608631435af625f05a9c11"
40354035 }
40364036 ],
4037 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4038 "acceptedAt": "2026-09-12T00:37:22.248Z"
4037 "reason": "ap-bridge 0.10.1 — silent re-auth",
4038 "acceptedAt": "2026-09-12T00:46:51.269Z"
40394039 },
40404040 {
40414041 "type": "VERSION_CONFLICT",
……@@ -4054,8 +4054,8 @@
40544054 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
40554055 }
40564056 ],
4057 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4058 "acceptedAt": "2026-09-12T00:37:22.248Z"
4057 "reason": "ap-bridge 0.10.1 — silent re-auth",
4058 "acceptedAt": "2026-09-12T00:46:51.269Z"
40594059 },
40604060 {
40614061 "type": "VERSION_CONFLICT",
……@@ -4069,8 +4069,8 @@
40694069 "tree": "da4c6b2654e9d66f10b0faa989311cf182878c24ab51b1c41458cfbf37ad29cd"
40704070 }
40714071 ],
4072 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4073 "acceptedAt": "2026-09-12T00:37:22.248Z"
4072 "reason": "ap-bridge 0.10.1 — silent re-auth",
4073 "acceptedAt": "2026-09-12T00:46:51.269Z"
40744074 },
40754075 {
40764076 "type": "VERSION_CONFLICT",
……@@ -4084,8 +4084,8 @@
40844084 "tree": "40be8f559a13b54ebf0689e63e6c70f0d218035359e17c497ed7bc8a85c3cc95"
40854085 }
40864086 ],
4087 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4088 "acceptedAt": "2026-09-12T00:37:22.248Z"
4087 "reason": "ap-bridge 0.10.1 — silent re-auth",
4088 "acceptedAt": "2026-09-12T00:46:51.269Z"
40894089 },
40904090 {
40914091 "type": "VERSION_CONFLICT",
……@@ -4099,8 +4099,8 @@
40994099 "tree": "3eae85efce82299769898c8151b5ed0a94e5a5ebdbcdee2105b815ca733a61cb"
41004100 }
41014101 ],
4102 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4103 "acceptedAt": "2026-09-12T00:37:22.248Z"
4102 "reason": "ap-bridge 0.10.1 — silent re-auth",
4103 "acceptedAt": "2026-09-12T00:46:51.269Z"
41044104 },
41054105 {
41064106 "type": "VERSION_CONFLICT",
……@@ -4114,8 +4114,8 @@
41144114 "tree": "8d9e01d443a127781773598d2e77c3f372334fad09e20623a819dabe49b22e1a"
41154115 }
41164116 ],
4117 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4118 "acceptedAt": "2026-09-12T00:37:22.248Z"
4117 "reason": "ap-bridge 0.10.1 — silent re-auth",
4118 "acceptedAt": "2026-09-12T00:46:51.269Z"
41194119 },
41204120 {
41214121 "type": "VERSION_CONFLICT",
……@@ -4129,8 +4129,8 @@
41294129 "tree": "dc1ad6c999fe90d946ba0ef34698ea9af451b639be2ad1c5b61eabb08337a6ca"
41304130 }
41314131 ],
4132 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4133 "acceptedAt": "2026-09-12T00:37:22.248Z"
4132 "reason": "ap-bridge 0.10.1 — silent re-auth",
4133 "acceptedAt": "2026-09-12T00:46:51.269Z"
41344134 },
41354135 {
41364136 "type": "VERSION_CONFLICT",
……@@ -4144,8 +4144,8 @@
41444144 "tree": "88b4779570e5113aa21b204989ac018d1628b387681f7e53428a7c99114fb701"
41454145 }
41464146 ],
4147 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4148 "acceptedAt": "2026-09-12T00:37:22.248Z"
4147 "reason": "ap-bridge 0.10.1 — silent re-auth",
4148 "acceptedAt": "2026-09-12T00:46:51.269Z"
41494149 },
41504150 {
41514151 "type": "VERSION_CONFLICT",
……@@ -4159,8 +4159,8 @@
41594159 "tree": "720b867ec4300a3caa2325eb1449499f9940bfb88341ecceb5992fa0fdf5f37d"
41604160 }
41614161 ],
4162 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4163 "acceptedAt": "2026-09-12T00:37:22.248Z"
4162 "reason": "ap-bridge 0.10.1 — silent re-auth",
4163 "acceptedAt": "2026-09-12T00:46:51.269Z"
41644164 },
41654165 {
41664166 "type": "VERSION_CONFLICT",
……@@ -4174,8 +4174,8 @@
41744174 "tree": "b697fe174482a708b49d0764cd4786291845f0034a31e0699381ca7e7b269d04"
41754175 }
41764176 ],
4177 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4178 "acceptedAt": "2026-09-12T00:37:22.248Z"
4177 "reason": "ap-bridge 0.10.1 — silent re-auth",
4178 "acceptedAt": "2026-09-12T00:46:51.269Z"
41794179 },
41804180 {
41814181 "type": "VERSION_CONFLICT",
……@@ -4189,8 +4189,8 @@
41894189 "tree": "767f07242277ae80bd395de5a4969fcdbf956c216158ae517fb2b562558ac1a4"
41904190 }
41914191 ],
4192 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4193 "acceptedAt": "2026-09-12T00:37:22.248Z"
4192 "reason": "ap-bridge 0.10.1 — silent re-auth",
4193 "acceptedAt": "2026-09-12T00:46:51.269Z"
41944194 },
41954195 {
41964196 "type": "VERSION_CONFLICT",
……@@ -4204,8 +4204,8 @@
42044204 "tree": "de7deed21f587ffc8bde4971abe00fca77bb6c71c4e7effb31ad34ee330815df"
42054205 }
42064206 ],
4207 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4208 "acceptedAt": "2026-09-12T00:37:22.248Z"
4207 "reason": "ap-bridge 0.10.1 — silent re-auth",
4208 "acceptedAt": "2026-09-12T00:46:51.269Z"
42094209 },
42104210 {
42114211 "type": "VERSION_CONFLICT",
……@@ -4219,8 +4219,8 @@
42194219 "tree": "2df42517ebf73cd9c11f2f652f75e87401fd4ed7b856c0adc15c33baf1d4f1ad"
42204220 }
42214221 ],
4222 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4223 "acceptedAt": "2026-09-12T00:37:22.248Z"
4222 "reason": "ap-bridge 0.10.1 — silent re-auth",
4223 "acceptedAt": "2026-09-12T00:46:51.269Z"
42244224 },
42254225 {
42264226 "type": "VERSION_CONFLICT",
……@@ -4234,8 +4234,8 @@
42344234 "tree": "325aca171cbff560487177584078c8066133e91176639ca47c98d70e195aa92e"
42354235 }
42364236 ],
4237 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4238 "acceptedAt": "2026-09-12T00:37:22.248Z"
4237 "reason": "ap-bridge 0.10.1 — silent re-auth",
4238 "acceptedAt": "2026-09-12T00:46:51.269Z"
42394239 },
42404240 {
42414241 "type": "VERSION_CONFLICT",
……@@ -4264,8 +4264,8 @@
42644264 "tree": "89a40400106bf4fef95fb5c44a66ee29aada6f76c7740bfba09d8aeeb841f39c"
42654265 }
42664266 ],
4267 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4268 "acceptedAt": "2026-09-12T00:37:22.248Z"
4267 "reason": "ap-bridge 0.10.1 — silent re-auth",
4268 "acceptedAt": "2026-09-12T00:46:51.269Z"
42694269 },
42704270 {
42714271 "type": "VERSION_CONFLICT",
……@@ -4279,8 +4279,8 @@
42794279 "tree": "1bd0eb382789c62cd4622d47bffb60984fdd766260c79f73a2fc7a136f3edbe9"
42804280 }
42814281 ],
4282 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4283 "acceptedAt": "2026-09-12T00:37:22.248Z"
4282 "reason": "ap-bridge 0.10.1 — silent re-auth",
4283 "acceptedAt": "2026-09-12T00:46:51.269Z"
42844284 },
42854285 {
42864286 "type": "VERSION_CONFLICT",
……@@ -4294,8 +4294,8 @@
42944294 "tree": "4526fe9dc5468264c7171ec16e9303726674b7b44ca0b3e09cb8b9a6a1be6169"
42954295 }
42964296 ],
4297 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4298 "acceptedAt": "2026-09-12T00:37:22.248Z"
4297 "reason": "ap-bridge 0.10.1 — silent re-auth",
4298 "acceptedAt": "2026-09-12T00:46:51.269Z"
42994299 },
43004300 {
43014301 "type": "VERSION_CONFLICT",
……@@ -4309,8 +4309,8 @@
43094309 "tree": "32b291cd756e142da18a6ed01c351e024fcedc3e2b1ecfbfb85f3106ada4419b"
43104310 }
43114311 ],
4312 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4313 "acceptedAt": "2026-09-12T00:37:22.248Z"
4312 "reason": "ap-bridge 0.10.1 — silent re-auth",
4313 "acceptedAt": "2026-09-12T00:46:51.269Z"
43144314 },
43154315 {
43164316 "type": "VERSION_CONFLICT",
……@@ -4324,8 +4324,8 @@
43244324 "tree": "4a8cd3b0093092b33088617dc0415e8179882d7f2f13b7649962ca20b08beeed"
43254325 }
43264326 ],
4327 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4328 "acceptedAt": "2026-09-12T00:37:22.248Z"
4327 "reason": "ap-bridge 0.10.1 — silent re-auth",
4328 "acceptedAt": "2026-09-12T00:46:51.269Z"
43294329 },
43304330 {
43314331 "type": "VERSION_CONFLICT",
……@@ -4339,8 +4339,8 @@
43394339 "tree": "4f63f165ee6a581f689b40c4a40df4df01ab6fba17d46bd29e40ef5bbdd9884f"
43404340 }
43414341 ],
4342 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4343 "acceptedAt": "2026-09-12T00:37:22.248Z"
4342 "reason": "ap-bridge 0.10.1 — silent re-auth",
4343 "acceptedAt": "2026-09-12T00:46:51.269Z"
43444344 },
43454345 {
43464346 "type": "VERSION_CONFLICT",
……@@ -4354,8 +4354,8 @@
43544354 "tree": "735109c736c0bd3413ab47414fd8b6ea9a99c401fe8e6d608459c79293806754"
43554355 }
43564356 ],
4357 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4358 "acceptedAt": "2026-09-12T00:37:22.248Z"
4357 "reason": "ap-bridge 0.10.1 — silent re-auth",
4358 "acceptedAt": "2026-09-12T00:46:51.269Z"
43594359 },
43604360 {
43614361 "type": "VERSION_CONFLICT",
……@@ -4369,8 +4369,8 @@
43694369 "tree": "b32b75bf1b2acdd18ed0cc370d01d477fba42ba09fd1f2f78af3ebad925e72d5"
43704370 }
43714371 ],
4372 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4373 "acceptedAt": "2026-09-12T00:37:22.248Z"
4372 "reason": "ap-bridge 0.10.1 — silent re-auth",
4373 "acceptedAt": "2026-09-12T00:46:51.269Z"
43744374 },
43754375 {
43764376 "type": "VERSION_CONFLICT",
……@@ -4389,8 +4389,8 @@
43894389 "tree": "189397178121e9a18430943345dab820691648a324229071ceb980bbb77a7393"
43904390 }
43914391 ],
4392 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4393 "acceptedAt": "2026-09-12T00:37:22.248Z"
4392 "reason": "ap-bridge 0.10.1 — silent re-auth",
4393 "acceptedAt": "2026-09-12T00:46:51.269Z"
43944394 },
43954395 {
43964396 "type": "VERSION_CONFLICT",
……@@ -4404,8 +4404,8 @@
44044404 "tree": "3bedca5287a9041fc2fd5ec5ace4df1fa86240e6afec0a105ba67398bafc09a5"
44054405 }
44064406 ],
4407 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4408 "acceptedAt": "2026-09-12T00:37:22.248Z"
4407 "reason": "ap-bridge 0.10.1 — silent re-auth",
4408 "acceptedAt": "2026-09-12T00:46:51.269Z"
44094409 },
44104410 {
44114411 "type": "VERSION_CONFLICT",
……@@ -4419,8 +4419,8 @@
44194419 "tree": "b9ca9b272008bd67672a1b252390e8b527095c46b46cf6b6725a7a187eb038b6"
44204420 }
44214421 ],
4422 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4423 "acceptedAt": "2026-09-12T00:37:22.248Z"
4422 "reason": "ap-bridge 0.10.1 — silent re-auth",
4423 "acceptedAt": "2026-09-12T00:46:51.269Z"
44244424 },
44254425 {
44264426 "type": "VERSION_CONFLICT",
……@@ -4434,8 +4434,8 @@
44344434 "tree": "7793645d7d4248d6d6533e3cbcd4bf40bed4f38c678b0ca0606c15640ba5e7b9"
44354435 }
44364436 ],
4437 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4438 "acceptedAt": "2026-09-12T00:37:22.248Z"
4437 "reason": "ap-bridge 0.10.1 — silent re-auth",
4438 "acceptedAt": "2026-09-12T00:46:51.269Z"
44394439 },
44404440 {
44414441 "type": "VERSION_CONFLICT",
……@@ -4449,8 +4449,8 @@
44494449 "tree": "79447513d4ac0357c8d60bdc7b81b3c5143d761b26700340cba266c4d1cb77b0"
44504450 }
44514451 ],
4452 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4453 "acceptedAt": "2026-09-12T00:37:22.248Z"
4452 "reason": "ap-bridge 0.10.1 — silent re-auth",
4453 "acceptedAt": "2026-09-12T00:46:51.269Z"
44544454 },
44554455 {
44564456 "type": "VERSION_CONFLICT",
……@@ -4464,8 +4464,8 @@
44644464 "tree": "279d4e0447eb79fd75816864ba7f41f2df03080c9da69ea4603fe2900c8d9e53"
44654465 }
44664466 ],
4467 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4468 "acceptedAt": "2026-09-12T00:37:22.248Z"
4467 "reason": "ap-bridge 0.10.1 — silent re-auth",
4468 "acceptedAt": "2026-09-12T00:46:51.269Z"
44694469 },
44704470 {
44714471 "type": "VERSION_CONFLICT",
……@@ -4479,8 +4479,8 @@
44794479 "tree": "ed4e0b480417610e195ff0b59e5b9a453abbf2db6d6ce72d7cd522269f835e9a"
44804480 }
44814481 ],
4482 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4483 "acceptedAt": "2026-09-12T00:37:22.248Z"
4482 "reason": "ap-bridge 0.10.1 — silent re-auth",
4483 "acceptedAt": "2026-09-12T00:46:51.269Z"
44844484 },
44854485 {
44864486 "type": "VERSION_CONFLICT",
……@@ -4499,8 +4499,8 @@
44994499 "tree": "a9e07d54e526c838dfe5e27df33936682aefb218f61a45b6c15289243999f648"
45004500 }
45014501 ],
4502 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4503 "acceptedAt": "2026-09-12T00:37:22.248Z"
4502 "reason": "ap-bridge 0.10.1 — silent re-auth",
4503 "acceptedAt": "2026-09-12T00:46:51.269Z"
45044504 },
45054505 {
45064506 "type": "VERSION_CONFLICT",
……@@ -4514,8 +4514,8 @@
45144514 "tree": "9d84c86441fe138b227c4fd01d1e948aa83bf8d13cfb1aa957838feea05fbb57"
45154515 }
45164516 ],
4517 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4518 "acceptedAt": "2026-09-12T00:37:22.248Z"
4517 "reason": "ap-bridge 0.10.1 — silent re-auth",
4518 "acceptedAt": "2026-09-12T00:46:51.269Z"
45194519 },
45204520 {
45214521 "type": "VERSION_CONFLICT",
……@@ -4529,8 +4529,8 @@
45294529 "tree": "4a3afbe2fc7a882e24454eb2e7e7cfe6816811ee5d73ae0f92e8b9ea9cfb8c78"
45304530 }
45314531 ],
4532 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4533 "acceptedAt": "2026-09-12T00:37:22.248Z"
4532 "reason": "ap-bridge 0.10.1 — silent re-auth",
4533 "acceptedAt": "2026-09-12T00:46:51.269Z"
45344534 },
45354535 {
45364536 "type": "VERSION_CONFLICT",
……@@ -4544,8 +4544,8 @@
45444544 "tree": "1e5c5810d8971a3c27fde7fb0e4d633dcabbe2cf98d8df86b473f8d67eead8b9"
45454545 }
45464546 ],
4547 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4548 "acceptedAt": "2026-09-12T00:37:22.248Z"
4547 "reason": "ap-bridge 0.10.1 — silent re-auth",
4548 "acceptedAt": "2026-09-12T00:46:51.269Z"
45494549 },
45504550 {
45514551 "type": "VERSION_CONFLICT",
……@@ -4559,8 +4559,8 @@
45594559 "tree": "e3478f50e85ba0ac288efed83e84c992483904897dc39ccf243d20a4ea25d972"
45604560 }
45614561 ],
4562 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4563 "acceptedAt": "2026-09-12T00:37:22.248Z"
4562 "reason": "ap-bridge 0.10.1 — silent re-auth",
4563 "acceptedAt": "2026-09-12T00:46:51.269Z"
45644564 },
45654565 {
45664566 "type": "VERSION_CONFLICT",
……@@ -4574,8 +4574,8 @@
45744574 "tree": "c05d8bf7eab5024ea98140f78c84d12fb494b4b7b10c7eedeb50b2be842fa519"
45754575 }
45764576 ],
4577 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4578 "acceptedAt": "2026-09-12T00:37:22.248Z"
4577 "reason": "ap-bridge 0.10.1 — silent re-auth",
4578 "acceptedAt": "2026-09-12T00:46:51.269Z"
45794579 },
45804580 {
45814581 "type": "VERSION_CONFLICT",
……@@ -4589,8 +4589,8 @@
45894589 "tree": "0697bb338bbcec517353cffcdb2345a8232b9b90cc627a549a306f0323576390"
45904590 }
45914591 ],
4592 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4593 "acceptedAt": "2026-09-12T00:37:22.248Z"
4592 "reason": "ap-bridge 0.10.1 — silent re-auth",
4593 "acceptedAt": "2026-09-12T00:46:51.269Z"
45944594 },
45954595 {
45964596 "type": "VERSION_CONFLICT",
……@@ -4604,8 +4604,8 @@
46044604 "tree": "a366a73e74f8d80f106465058083b62bb70b833c721047124e99c89880e5c706"
46054605 }
46064606 ],
4607 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4608 "acceptedAt": "2026-09-12T00:37:22.248Z"
4607 "reason": "ap-bridge 0.10.1 — silent re-auth",
4608 "acceptedAt": "2026-09-12T00:46:51.269Z"
46094609 },
46104610 {
46114611 "type": "VERSION_CONFLICT",
……@@ -4619,8 +4619,8 @@
46194619 "tree": "7912e790c9e87e9829a6daa037c5f5da0cc4355214f51d334f96e57678dfddeb"
46204620 }
46214621 ],
4622 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4623 "acceptedAt": "2026-09-12T00:37:22.248Z"
4622 "reason": "ap-bridge 0.10.1 — silent re-auth",
4623 "acceptedAt": "2026-09-12T00:46:51.269Z"
46244624 },
46254625 {
46264626 "type": "VERSION_CONFLICT",
……@@ -4634,8 +4634,8 @@
46344634 "tree": "003ac841449fcdda1abc3c87fee291a5d9596d245925a7298bd8ef5f8e423eae"
46354635 }
46364636 ],
4637 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4638 "acceptedAt": "2026-09-12T00:37:22.248Z"
4637 "reason": "ap-bridge 0.10.1 — silent re-auth",
4638 "acceptedAt": "2026-09-12T00:46:51.269Z"
46394639 },
46404640 {
46414641 "type": "VERSION_CONFLICT",
……@@ -4654,8 +4654,8 @@
46544654 "tree": "7b3f314cf92558edbc90295c573885276a6add925318998c829c03d335edc765"
46554655 }
46564656 ],
4657 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4658 "acceptedAt": "2026-09-12T00:37:22.248Z"
4657 "reason": "ap-bridge 0.10.1 — silent re-auth",
4658 "acceptedAt": "2026-09-12T00:46:51.269Z"
46594659 },
46604660 {
46614661 "type": "VERSION_CONFLICT",
……@@ -4669,8 +4669,8 @@
46694669 "tree": "c486fe73805bf013e9c5de91b5780d1aa5d2b2295306f411f7863757aaa05e60"
46704670 }
46714671 ],
4672 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4673 "acceptedAt": "2026-09-12T00:37:22.248Z"
4672 "reason": "ap-bridge 0.10.1 — silent re-auth",
4673 "acceptedAt": "2026-09-12T00:46:51.269Z"
46744674 },
46754675 {
46764676 "type": "VERSION_CONFLICT",
……@@ -4684,8 +4684,8 @@
46844684 "tree": "10193a86e2c6f80c2d4377379a0aae4a959f8a40fd9e852b85fee081bfe6675a"
46854685 }
46864686 ],
4687 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4688 "acceptedAt": "2026-09-12T00:37:22.248Z"
4687 "reason": "ap-bridge 0.10.1 — silent re-auth",
4688 "acceptedAt": "2026-09-12T00:46:51.269Z"
46894689 },
46904690 {
46914691 "type": "VERSION_CONFLICT",
……@@ -4699,8 +4699,8 @@
46994699 "tree": "20b1ecd2937f3e35102892288234b86a8ec6deb3711016d96ff3509474ac465b"
47004700 }
47014701 ],
4702 "reason": "ap-bridge 0.10.0 — public inbound timeline",
4703 "acceptedAt": "2026-09-12T00:37:22.248Z"
4702 "reason": "ap-bridge 0.10.1 — silent re-auth",
4703 "acceptedAt": "2026-09-12T00:46:51.269Z"
47044704 }
47054705 ]
47064706}
lib/feed.js +161 -0
……@@ -0,0 +1,161 @@
1// feed.js — the bridge's public face, as a feed.
2//
3// ⛔ A PRODUCT SURFACE AND A DIAGNOSTIC, DELIBERATELY THE SAME PAGE. `/timeline` showed only what the
4// fediverse SENT us, as a flat list of activity types. That answers "did anything arrive" and cannot
5// answer the thing this project keeps getting wrong: are interactions handled in BOTH directions? On
6// a feed, a like we received and a post we published sit in one stream, so a missing one is VISIBLE
7// rather than inferred from a log — which is how every interaction defect so far has been found, and
8// always later than it should have been.
9//
10// ⛔ PUBLIC ONLY, AND THE FILTER IS THE SAFETY PROPERTY. There is no login in front of this page.
11// Mastodon delivers DIRECT MESSAGES to an inbox exactly as it delivers replies, through the same
12// handler and into the same store, and `normalize()` records `public` at arrival because the
13// addressing cannot be recovered afterwards. Anything that cannot POSITIVELY show it was public is
14// withheld — including everything stored before that field existed.
15//
16// ⛔ AND SCOPED TO ONE PEER. One InboundStore serves the whole process while the inbox paths are
17// per-user, so a feed that did not filter by `recipient` would publish one peer's mail on another's
18// page. Items with no recipient belong to nobody rather than everybody.
19//
20// PURE. No store, no network, no clock — the merge, the verbs, the withholding and the ordering are
21// testable on their own, which is the only way a safety filter should ever be checked.
22
23// AS2 activity type → what a reader is actually being told. A `Create` is a reply when it answers
24// something and a mention otherwise — the distinction `inReplyTo` was kept for.
25const VERB = {
26 Like: { kind: 'like', verb: 'liked your post' },
27 Announce: { kind: 'boost', verb: 'boosted your post' },
28 Follow: { kind: 'follow', verb: 'followed you' }
29}
30
31/**
32 * A remote actor URI as the handle its owner would recognise.
33 *
34 * `https://mastodon.social/users/alice` -> `@alice@mastodon.social`. A feed showing raw URLs does not
35 * read as a feed, and this needs no fetch: the URI already carries both halves.
36 *
37 * ⚠ Anything unparseable is returned AS-IS rather than guessed at. A wrong handle attributes
38 * somebody's words to somebody else, which is worse than an ugly one.
39 */
40export function handleOf (actorUri) {
41 const s = String(actorUri == null ? '' : actorUri)
42 try {
43 const u = new URL(s)
44 const name = u.pathname.split('/').filter(Boolean).pop()
45 if (!name) return u.host
46 return '@' + name.replace(/^@/, '') + '@' + u.host
47 } catch {
48 return s
49 }
50}
51
52// Somebody else's HTML is not markup we render. Tags are stripped here and the result is escaped by
53// the renderer: stripping alone leaves `&lt;script&gt;` readable as text, escaping alone lets a
54// remote server choose this page's layout.
55const textOf = (html) => String(html == null ? '' : html).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
56
57const timeOf = (i) => i.published || (i.receivedAt ? new Date(Number(i.receivedAt)).toISOString() : '')
58
59/**
60 * One feed, newest first, from this peer's own public notes and the public activity sent to them.
61 *
62 * @param {object} o
63 * self { handle, name, avatar } — how to attribute our own rows
64 * notes AS2 Notes from the peer's own records (already public by construction: the outbox
65 * serves them unauthenticated)
66 * inbound normalized inbound items
67 * own OUR interactions, from RecordSource.listInteractions()
68 * recipient which peer's page this is; null means the primary's flat paths
69 */
70export function buildFeed ({ self = {}, notes = [], inbound = [], own = [], recipient = null } = {}) {
71 const rows = []
72
73 for (const n of notes || []) {
74 if (!n) continue
75 rows.push({
76 id: n.id || null,
77 kind: 'post',
78 verb: null,
79 author: { handle: self.handle || null, name: self.name || null, url: n.attributedTo || null, avatar: self.avatar || null },
80 at: n.published || '',
81 text: textOf(n.content),
82 url: n.id || null,
83 subject: null,
84 attachments: Array.isArray(n.attachment) ? n.attachment.filter(Boolean) : [],
85 source: 'own'
86 })
87 }
88
89 // ⛔ THE OUTBOUND HALF, AND THE REASON THIS PAGE IS WORTH OPENING. Without it the feed shows our
90 // posts and other people's reactions to them — which proves INBOUND handling and says nothing about
91 // whether a like we sent ever left. Both directions in one stream makes a broken one visible instead
92 // of inferred from a delivery log.
93 //
94 // ⚠ FIRST PERSON, deliberately. "liked your post" and "you liked a post" are different events and a
95 // reader must never have to work out which way round one is.
96 for (const o of own || []) {
97 if (!o || !o.activity) continue
98 const kind = o.kind === 'announce' ? 'boost' : o.kind === 'like' ? 'like' : null
99 // Same rule as inbound: a kind we have no words for is dropped rather than shown as the nearest
100 // familiar one.
101 if (!kind) continue
102 const object = typeof o.activity.object === 'string' ? o.activity.object : null
103 rows.push({
104 id: o.key || o.activity.id || null,
105 kind,
106 verb: kind === 'like' ? 'you liked a post' : 'you boosted a post',
107 author: { handle: self.handle || null, name: self.name || null, url: null, avatar: self.avatar || null },
108 // ⚠ projectLike carries no `published` (projectAnnounce does), so the time comes from the
109 // RECORD via listInteractions. Undated still shows, sorted last.
110 at: o.at || o.activity.published || '',
111 text: '',
112 url: object,
113 subject: object,
114 attachments: [],
115 source: 'own'
116 })
117 }
118
119 for (const i of inbound || []) {
120 if (!i) continue
121 // ⛔ FAILS CLOSED, TWICE. `public !== true` catches both a DM and an item stored before the field
122 // existed; the recipient check catches another peer's mail. Neither is a formality: each is a
123 // live class of item in the one store this reads.
124 if (i.public !== true) continue
125 if (recipient && i.recipient !== recipient) continue
126
127 let kind, verb
128 if (i.type === 'Create' || i.type === 'Update') {
129 kind = i.inReplyTo ? 'reply' : 'mention'
130 verb = i.inReplyTo ? null : 'mentioned you'
131 } else {
132 const v = VERB[i.type]
133 // An activity type we have no words for is DROPPED. Rendering it as the nearest familiar verb
134 // tells the reader something that did not happen.
135 if (!v) continue
136 kind = v.kind
137 verb = v.verb
138 }
139
140 rows.push({
141 id: i.id || null,
142 kind,
143 verb,
144 author: { handle: handleOf(i.actor), name: null, url: i.actor || null, avatar: null },
145 at: timeOf(i),
146 text: textOf(i.content),
147 url: i.url || i.objectId || null,
148 // What of OURS it acted on, so a reader can follow it back.
149 subject: i.inReplyTo || (kind === 'follow' ? null : i.objectId) || null,
150 attachments: [],
151 source: 'fediverse'
152 })
153 }
154
155 // Undated rows sort last rather than being dropped: "we do not know when" is not "it did not
156 // happen", and an item with no timestamp is still something that arrived.
157 rows.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')))
158 return rows
159}
160
161export default { buildFeed, handleOf }
lib/inbound-store.js +22 -5
……@@ -64,7 +64,7 @@ export function isPublicActivity (activity) {
6464 return false
6565}
6666
67export function normalize (activity, receivedAt) {
67export function normalize (activity, receivedAt, recipient = null) {
6868 const obj = activity && typeof activity.object === 'object' ? activity.object : null
6969 const objectId = obj ? obj.id : (typeof activity?.object === 'string' ? activity.object : null)
7070 return {
……@@ -85,6 +85,16 @@ export function normalize (activity, receivedAt) {
8585 // it. Anything stored before this field existed is `undefined`, which every reader must treat as
8686 // NOT public; see the note on isPublicActivity.
8787 public: isPublicActivity(activity),
88 // ⛔ WHOSE MAIL IS THIS. One InboundStore serves the whole process while the inbox PATHS are
89 // per-user (`/ap/users/<name>/inbox`), so without this a kept item cannot say who it was
90 // delivered to. That is harmless while only the operator reads the store and a leak the moment a
91 // peer can ask for its own inbound — which is the only reason to keep it at all.
92 //
93 // ⚠ FORWARD-ONLY. `to`/`cc` are dropped above and the object is refetchable only while the
94 // remote server still serves it, so anything stored before this field is permanently
95 // unattributable and reads as `undefined`. A per-user read must exclude those rather than
96 // guess — the same fail-closed posture `public` takes, and for the same reason.
97 recipient: recipient || null,
8898 receivedAt
8999 }
90100}
……@@ -143,9 +153,13 @@ export class InboundStore {
143153 * reading, which shows up as a repeated row rather than a lost one. A cursor would be the right
144154 * answer for a log that grows without limit; this one is capped by construction.
145155 */
146 async list ({ limit = 50, offset = 0 } = {}) {
156 async list ({ limit = 50, offset = 0, recipient = null } = {}) {
147157 const from = Math.max(0, Number(offset) || 0)
148 return this._load().slice(from, from + limit)
158 // ⛔ FILTERED BEFORE PAGING. Paging first and filtering second yields short pages and an offset
159 // that no longer means what the caller asked for — the same mistake the public timeline had to
160 // fix, recorded at its call site.
161 const all = recipient ? this._load().filter((i) => i.recipient === recipient) : this._load()
162 return all.slice(from, from + limit)
149163 }
150164
151165 /** How many are held, so a page can say whether there is more without fetching it. */
……@@ -168,9 +182,12 @@ export class MemoryInboundStore {
168182 return before - this.items.length
169183 }
170184
171 async list ({ limit = 50, offset = 0 } = {}) {
185 // ⚠ The SAME filter as the real store. A double that ignores an option makes every test using it
186 // a statement about the double rather than about production.
187 async list ({ limit = 50, offset = 0, recipient = null } = {}) {
172188 const from = Math.max(0, Number(offset) || 0)
173 return this.items.slice(from, from + limit)
189 const all = recipient ? this.items.filter((i) => i.recipient === recipient) : this.items
190 return all.slice(from, from + limit)
174191 }
175192
176193 async count () { return this.items.length }
lib/public-timeline.js +97 -41
……@@ -1,69 +1,125 @@
1// The public face of what the fediverse sent this bridge.
1// The public face of this bridge, rendered as a FEED.
22//
3// ⛔ PUBLIC ITEMS ONLY, AND THE CALLER FILTERS BEFORE PAGING. This page has no login in front of it, so
3// ⛔ PUBLIC ROWS ONLY, AND THE CALLER HAS ALREADY FILTERED. This page has no login in front of it, so
44// 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.
5// delivers replies, through the same handler and into the same store. `buildFeed` withholds anything
6// that cannot positively show it was public, and anything addressed to another peer. This file
7// renders; it does not decide.
78//
89// ⛔ 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.
10// to render. `buildFeed` strips markup and this escapes what is left: stripping alone leaves
11// `&lt;script&gt;` readable as text, escaping alone leaves a remote server choosing this page's layout.
12//
13// ⚠ IT LOOKS LIKE A FEED ON PURPOSE. Not decoration: a post we published and a like somebody sent us
14// sit in one stream, so a missing interaction is VISIBLE. Every interaction defect in this project so
15// far was found by reading a log, later than it should have been.
1216
1317const esc = (s) => String(s == null ? '' : s)
1418 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
1519
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}}`
20const STYLE = `*{box-sizing:border-box}
21body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;margin:0 auto;
22padding:0 0 48px;max-width:600px;color:#0f1419;background:#fff}
23header{position:sticky;top:0;background:rgba(255,255,255,.85);backdrop-filter:blur(12px);
24border-bottom:1px solid #eff3f4;padding:14px 16px;z-index:2}
25h1{font-size:19px;margin:0;font-weight:700}
26.sub{color:#536471;font-size:13px;margin:2px 0 0}
27.row{display:flex;gap:12px;padding:12px 16px;border-bottom:1px solid #eff3f4}
28.row:hover{background:#f7f9f9}
29.av{flex:0 0 40px;width:40px;height:40px;border-radius:50%;background:#1d9bf0;color:#fff;
30display:flex;align-items:center;justify-content:center;font-weight:700;font-size:16px;overflow:hidden}
31.av img{width:100%;height:100%;object-fit:cover}
32.main{min-width:0;flex:1}
33.head{display:flex;gap:6px;align-items:baseline;flex-wrap:wrap}
34.name{font-weight:700}
35.handle,.time{color:#536471;font-size:14px}
36.verb{color:#536471;font-size:14px;margin:1px 0 0}
37.body{margin:3px 0 0;white-space:pre-wrap;word-wrap:break-word}
38.sub-link{display:block;margin-top:6px;font-size:13px;color:#536471;text-decoration:none;
39border-left:2px solid #eff3f4;padding-left:8px}
40.sub-link:hover{color:#1d9bf0}
41.none{color:#536471;font-style:italic;padding:32px 16px;text-align:center}
42nav{display:flex;gap:16px;padding:16px}
43a{color:#1d9bf0;text-decoration:none}a:hover{text-decoration:underline}
44.pill{font-size:12px;color:#536471;border:1px solid #eff3f4;border-radius:999px;padding:1px 8px}
45footer{color:#536471;font-size:13px;padding:16px;text-align:center}
46@media(prefers-color-scheme:dark){body{background:#000;color:#e7e9ea}
47header{background:rgba(0,0,0,.85);border-color:#2f3336}.row{border-color:#2f3336}
48.row:hover{background:#080808}.handle,.time,.verb,.none,.pill,footer{color:#71767b}
49.sub-link{border-color:#2f3336;color:#71767b}.pill{border-color:#2f3336}}`
2450
25const when = (ms) => {
26 const d = new Date(Number(ms))
51const when = (at) => {
52 const d = new Date(at)
2753 return Number.isNaN(d.getTime()) ? '' : d.toISOString().replace('T', ' ').slice(0, 16) + 'Z'
2854}
2955
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>'
56// The avatar, or the first letter of whoever this is. A feed with blank circles reads as broken; a
57// letter reads as "no picture", which is the truth.
58const avatar = (a) => {
59 if (a && a.avatar) return '<span class="av"><img src="' + esc(a.avatar) + '" alt=""></span>'
60 const src = (a && (a.name || a.handle)) || '?'
61 return '<span class="av">' + esc(String(src).replace(/^@/, '').charAt(0).toUpperCase() || '?') + '</span>'
62}
63
64function renderRow (r) {
65 const a = r.author || {}
66 const name = a.name || String(a.handle || '').replace(/^@/, '').split('@')[0] || 'someone'
67 const head = '<div class="head"><span class="name">' + esc(name) + '</span>' +
68 (a.handle ? '<span class="handle">' + esc(a.handle) + '</span>' : '') +
69 (r.at ? '<span class="time">· ' + esc(when(r.at)) + '</span>' : '') +
70 (r.source === 'fediverse' ? '<span class="pill">fediverse</span>' : '') + '</div>'
71
72 // ⛔ THE VERB IS SHOWN IN WORDS. "Like" as a bare type is a protocol detail; "liked your post" is
73 // what happened, and it is the line that makes a missing interaction obvious at a glance.
74 const verb = r.verb ? '<p class="verb">' + esc(r.verb) + '</p>' : ''
75 const body = r.text ? '<p class="body">' + esc(r.text) + '</p>' : ''
76 // Whose post the subject is depends on which way the interaction went, and saying it the wrong way
77 // round is exactly the confusion this page exists to remove.
78 const label = r.source === 'own'
79 ? 'the post you acted on ↗'
80 : (r.kind === 'reply' ? 'in reply to your post ↗' : 'your post ↗')
81 const back = r.subject
82 ? '<a class="sub-link" href="' + esc(r.subject) + '" rel="nofollow noopener ugc">' + label + '</a>'
83 : ''
84 const link = r.url && !r.subject
85 ? '<a class="sub-link" href="' + esc(r.url) + '" rel="nofollow noopener ugc">open ↗</a>'
86 : ''
87 return '<article class="row">' + avatar(a) + '<div class="main">' + head + verb + body + back + link + '</div></article>'
88}
89
90export function publicTimeline ({ host = '', rows = [], offset = 0, limit = 50, total = 0, who = null, unaddressable = 0 } = {}) {
91 const list = rows.length
92 ? rows.map(renderRow).join('')
93 : '<p class="none">Nothing public here yet.</p>'
4594
46 const next = offset + items.length
95 const next = offset + rows.length
4796 const nav = [
48 offset > 0 ? '<a href="/timeline?offset=' + Math.max(0, offset - limit) + '">← newer</a>' : '',
97 offset > 0 ? '<a href="?offset=' + Math.max(0, offset - limit) + '">← newer</a>' : '',
4998 // ⛔ Only when there is more. A "load more" that returns an empty page is the page lying about
5099 // the data behind it.
51 next < total ? '<a href="/timeline?offset=' + next + '">' + Math.min(limit, total - next) + ' older →</a>' : ''
100 next < total ? '<a href="?offset=' + next + '">' + Math.min(limit, total - next) + ' older →</a>' : ''
52101 ].filter(Boolean).join('')
53102
54 return '<!doctype html><meta charset="utf-8"><title>timeline · ' + esc(host) + '</title>' +
103 return '<!doctype html><meta charset="utf-8"><title>' + esc(who || host) + ' · feed</title>' +
55104 '<meta name="viewport" content="width=device-width,initial-scale=1">' +
56105 // ⚠ Not indexed. This is other people's words republished on our domain; letting a crawler make a
57106 // second permanent copy of them is a decision nobody here has made.
58107 '<meta name="robots" content="noindex,nofollow">' +
59108 '<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>' +
109 '<header><h1>' + esc(who || host) + '</h1>' +
110 '<p class="sub">Public posts and interactions. ' +
111 'Direct and followers-only messages are never shown here.</p></header>' +
63112 list +
64113 (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>'
114 '<footer>Showing ' + (rows.length ? (offset + 1) + '–' + next : '0') + ' of ' + total +
115 ' public item(s). Newest first, and bounded — the oldest is dropped once the bridge is full.' +
116 // ⛔ SAID OUT LOUD. "0 interactions" and "12 interactions we cannot address" are different states
117 // and RecordSource already counts the difference; swallowing it here would turn a real defect into
118 // an empty-looking feed.
119 (unaddressable
120 ? '<br>' + unaddressable + ' of our interaction(s) could not be addressed and are not shown.'
121 : '') +
122 '</footer>'
67123}
68124
69125export default { publicTimeline }
lib/records.js +5 -1
……@@ -110,7 +110,11 @@ export class RecordSource {
110110 })
111111 // Namespaced key: a like and a post can share an rkey (TIDs are unique per COLLECTION, not
112112 // per repo), and the delivered-map is keyed by string.
113 if (activity) interactions.push({ kind, rkey, key: kind + ':' + rkey, activity })
113 // ⚠ THE RECORD'S TIME, not the projection's. projectLike carries no `published` at all
114 // (projectAnnounce does), so a consumer ordering by the activity alone would put every like
115 // at the bottom of a timeline for ever.
116 const value = r.value || r.record || {}
117 if (activity) interactions.push({ kind, rkey, key: kind + ':' + rkey, at: value.createdAt || null, activity })
114118 else unaddressable++
115119 }
116120 }
lib/router.js +73 -26
……@@ -41,32 +41,8 @@ export async function handleRequest (req, ctx) {
4141 // ⚠ ANYTHING STORED BEFORE THAT FIELD EXISTED IS `undefined` AND IS WITHHELD. An operator sees a
4242 // shorter public page than they expect after upgrading; the alternative is publishing somebody's
4343 // 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 }
44 if (path === '/timeline') return feedPage(query, ctx)
6045
61 // ⛔ REGISTRATION — a peer asks this bridge for a name, and on an open bridge gets one with no
62 // operator in the loop. That is the whole point: a bridge is a service peers CONNECT TO, so
63 // getting a name on it must be something a peer can do by itself.
64 //
65 // Off unless the operator turned it on: no registry means 404, never a silent accept.
66 // GET is discovery — a peer asks whether this bridge is open before trying, rather than guessing
67 // from a refusal.
68 // ⛔ THE CHALLENGE. `readAttested` has no clock, so it cannot detect replay; freshness has to come
69 // from something this bridge issues and remembers. See lib/admission.js.
7046 if (path === '/ap/register/challenge') {
7147 if (!ctx.registrations || !ctx.admission) return text(404, 'not found')
7248 if (method !== 'GET' && method !== 'POST') return text(405, 'GET or POST only')
……@@ -137,6 +113,9 @@ export async function handleRequest (req, ctx) {
137113 if (rest === '/actor') return actor(headers, c)
138114 if (rest === '/inbox') return method === 'POST' ? inbox(req, c) : text(405, 'POST only')
139115 if (rest === '/outbox') return outbox(query, c)
116 // The same page as the primary's /timeline, for a registered peer — `c` is already bound to them,
117 // so their notes, their inbound, their name, by the same code path.
118 if (rest === '/timeline') return feedPage(query, c)
140119 if (rest.startsWith('/objects/')) return object(rest, c)
141120 if (rest.startsWith('/blobs/')) return blob(rest, c)
142121 if (rest === '/followers') return followers(c)
……@@ -330,6 +309,70 @@ async function collection (id, ctx) {
330309// mapped on demand (DESIGN.md §3) — the same bytes the atproto side derives its MST from. When
331310// there is no record source configured the collection is empty rather than an error: a bridge
332311// that cannot reach its peer should look quiet, not broken.
312/**
313 * The public feed — this peer's own public posts and the public activity sent to them, in one stream.
314 *
315 * ⛔ THE FILTER LIVES IN buildFeed, NOT HERE. It withholds anything that cannot positively show it was
316 * public (a DM arrives at this inbox exactly like a reply) and anything addressed to another peer (one
317 * InboundStore serves the whole process while the inbox PATHS are per-user). Duplicating either check
318 * at the route is how one of them eventually drifts.
319 *
320 * ⚠ MERGED, THEN PAGED. Paging each source separately and interleaving afterwards yields short pages
321 * and an offset that no longer means what the link said — the mistake the old handler had to note.
322 */
323async function feedPage (query, ctx) {
324 if (!ctx.inbound) return text(404, 'this bridge keeps no inbound timeline')
325 const limit = 50
326 const offset = Math.max(0, Number(query && query.offset) || 0)
327
328 let notes = []
329 if (ctx.records && typeof ctx.records.listNotes === 'function') {
330 // ⚠ A record source that is down must not blank the page. The inbound half is still true, and a
331 // feed missing our own posts is visibly wrong in a way an error page is not.
332 try { notes = (await ctx.records.listNotes({ limit: 200, cursor: null })).notes || [] } catch (err) {
333 ctx.log?.({ msg: 'feed: own records unavailable — showing inbound only', error: err.message })
334 }
335 }
336
337 // ⛔ OUR OWN INTERACTIONS, which is the half that makes this a diagnostic rather than an inbox.
338 // Without them the page proves inbound handling and says nothing about whether a like we sent ever
339 // left. `unaddressable` is carried through rather than dropped: "0 interactions" and "12 we cannot
340 // address" are different states and RecordSource already counts the difference.
341 let own = []
342 let unaddressable = 0
343 if (ctx.records && typeof ctx.records.listInteractions === 'function') {
344 try {
345 const r = await ctx.records.listInteractions({ limit: 100 })
346 own = r.interactions || []
347 unaddressable = r.unaddressable || 0
348 } catch (err) {
349 ctx.log?.({ msg: 'feed: own interactions unavailable', error: err.message })
350 }
351 }
352
353 const recipient = (ctx.user && ctx.user.username) || (ctx.config && ctx.config.username) || null
354 const inboundItems = await ctx.inbound.list({ limit: 100000, offset: 0 })
355 const { buildFeed } = await import('./feed.js')
356 const rows = buildFeed({
357 self: { handle: recipient ? '@' + recipient + '@' + ctx.config.host : null, name: ctx.config.displayName || null },
358 notes,
359 inbound: inboundItems,
360 own,
361 recipient
362 })
363
364 const { publicTimeline } = await import('./public-timeline.js')
365 return html(200, publicTimeline({
366 host: ctx.config.host,
367 who: recipient ? '@' + recipient + '@' + ctx.config.host : ctx.config.host,
368 rows: rows.slice(offset, offset + limit),
369 offset,
370 limit,
371 total: rows.length,
372 unaddressable
373 }))
374}
375
333376async function outbox (query, ctx) {
334377 const id = `${base(ctx)}/outbox`
335378 if (!ctx.records) return json(200, buildCollection({ id }), ACTIVITY_JSON)
……@@ -494,7 +537,11 @@ async function onInbound (activity, actorUri, ctx) {
494537 if (!ctx.inbound) return { status: 202, headers: {}, body: '' }
495538
496539 try {
497 const item = normalize(activity, Date.now())
540 // ⛔ WHOSE INBOX THIS WAS. `ctx` is already bound to the user by withUser (the inbox path is
541 // /ap/users/<name>/inbox), so the recipient is known here and nowhere later — normalize drops
542 // to/cc and the remote object outlives our record of it only by the sender's goodwill.
543 const recipient = (ctx.user && ctx.user.username) || (ctx.config && ctx.config.username) || null
544 const item = normalize(activity, Date.now(), recipient)
498545 const added = await ctx.inbound.add(item)
499546 // A retry is not a second event; say which happened so a duplicate storm is visible.
500547 ctx.log?.({ msg: added ? 'inbox: kept' : 'inbox: duplicate', type: activity.type, actor: actorUri, inReplyTo: item.inReplyTo || undefined })
package.json +2 -2
……@@ -1,7 +1,7 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.1",
4 "description": "ActivityPub membrane — a second projection of the self-custody atproto peer",
3 "version": "0.10.2",
4 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
77 "scripts": {
test/feed.test.js +143 -0
……@@ -0,0 +1,143 @@
1// feed.js — the bridge's public face as a FEED, not a log of arrivals.
2//
3// WHY IT IS A PRODUCT SURFACE AND A DIAGNOSTIC AT ONCE. The existing /timeline shows only what the
4// fediverse SENT us, as a flat list of activity types. That answers "did anything arrive" and cannot
5// answer the question we actually keep getting wrong: are interactions being handled in BOTH
6// directions? A like we received and a like we sent are the same shape on this page, so a missing
7// one is visible rather than inferred from a log.
8//
9// ⛔ PUBLIC ONLY, AND THE FILTER IS THE SAFETY PROPERTY. There is no login in front of this. Mastodon
10// delivers DMs to an inbox exactly as it delivers replies, through the same handler into the same
11// store, so anything that cannot prove it was public is withheld — including everything stored
12// before `public` was recorded.
13
14import test from 'brittle'
15import { buildFeed, handleOf } from '../lib/feed.js'
16
17const REMOTE = 'https://mastodon.social/users/alice'
18const OURS = 'https://p2p.7rnx.net/ap/objects/3kmine'
19
20const note = (rkey, content, published) => ({
21 id: `https://p2p.7rnx.net/ap/objects/${rkey}`,
22 type: 'Note',
23 content,
24 published,
25 attributedTo: 'https://p2p.7rnx.net/ap/actor'
26})
27
28const inbound = (over) => ({
29 id: 'act-' + Math.random(), type: 'Create', actor: REMOTE, objectId: `${REMOTE}/notes/x`,
30 content: '<p>nice post</p>', published: '2026-09-15T10:00:00Z', public: true,
31 recipient: 'p2p', receivedAt: Date.parse('2026-09-15T10:00:00Z'), ...over
32})
33
34test('a remote actor URI reads as the handle a fediverse user recognises', (t) => {
35 t.is(handleOf(REMOTE), '@alice@mastodon.social')
36 t.is(handleOf('https://prsm.space/users/fleeky'), '@fleeky@prsm.space')
37 t.is(handleOf('https://example.org/@bob'), '@bob@example.org', 'the /@name form too')
38 t.is(handleOf('not a url'), 'not a url', 'and anything unparseable is shown as-is, never invented')
39})
40
41test('own posts and inbound activity land in ONE feed, newest first', (t) => {
42 const rows = buildFeed({
43 self: { handle: '@p2p@p2p.7rnx.net', name: 'Fleeky' },
44 notes: [note('a', '<p>my post</p>', '2026-09-15T09:00:00Z')],
45 inbound: [inbound({ published: '2026-09-15T11:00:00Z' })]
46 })
47 t.is(rows.length, 2)
48 t.is(rows[0].at, '2026-09-15T11:00:00Z', 'newest first, like every feed anyone has used')
49 t.is(rows[1].kind, 'post')
50 t.is(rows[1].author.handle, '@p2p@p2p.7rnx.net', 'our own post is attributed to us, not to a URL')
51 t.is(rows[1].text, 'my post', "and the remote server's markup is not ours to render")
52})
53
54test('★ every interaction verb is visible and labelled, which is the point of the page', (t) => {
55 const rows = buildFeed({
56 self: { handle: '@p2p@p2p.7rnx.net' },
57 notes: [],
58 inbound: [
59 inbound({ type: 'Like', objectId: OURS, content: null, published: '2026-09-15T10:00:00Z' }),
60 inbound({ type: 'Announce', objectId: OURS, content: null, published: '2026-09-15T10:01:00Z' }),
61 inbound({ type: 'Follow', objectId: null, content: null, published: '2026-09-15T10:02:00Z' }),
62 inbound({ type: 'Create', inReplyTo: OURS, published: '2026-09-15T10:03:00Z' })
63 ]
64 })
65 t.alike(rows.map((r) => r.kind), ['reply', 'follow', 'boost', 'like'], 'all four, newest first')
66 t.is(rows[3].verb, 'liked your post', 'a like says so in words')
67 t.is(rows[2].verb, 'boosted your post')
68 t.is(rows[1].verb, 'followed you')
69 t.is(rows[0].kind, 'reply', 'a reply is content, not a bare verb')
70 t.is(rows[0].text, 'nice post')
71})
72
73test('⛔ anything that cannot prove it was public is withheld', (t) => {
74 const rows = buildFeed({
75 self: {},
76 notes: [],
77 inbound: [
78 inbound({ id: 'dm', public: false, content: '<p>secret</p>' }),
79 inbound({ id: 'legacy', content: '<p>from before the field existed</p>' })
80 ].map((i) => (i.id === 'legacy' ? (delete i.public, i) : i))
81 })
82 t.is(rows.length, 0, 'a DM and an unattributable item are both absent')
83})
84
85test('⛔ a row for somebody else never appears on this peer\'s feed', (t) => {
86 const rows = buildFeed({
87 self: {},
88 recipient: 'ashatron',
89 notes: [],
90 inbound: [inbound({ recipient: 'p2p' }), inbound({ id: 'mine', recipient: 'ashatron' })]
91 })
92 t.is(rows.length, 1)
93 t.is(rows[0].id, 'mine')
94})
95
96test('an unknown activity type is dropped, not shown as the nearest familiar one', (t) => {
97 const rows = buildFeed({ self: {}, notes: [], inbound: [inbound({ type: 'Flag' })] })
98 t.is(rows.length, 0, 'inventing a verb would tell the reader something that did not happen')
99})
100
101// ── the outbound half ─────────────────────────────────────────────────────────────────────────────
102//
103// ⛔ THE REASON THE PAGE IS WORTH LOOKING AT. Without these the feed shows our posts and other
104// people's reactions to them, which proves INBOUND handling and says nothing about outbound. A like
105// we sent and a like we received now sit in the same stream, so a broken direction is visible instead
106// of being inferred from a delivery log.
107
108const ownLike = (rkey, at, object = 'https://mastodon.social/notes/1') => ({
109 kind: 'like', rkey, key: 'like:' + rkey, at,
110 activity: { id: 'https://p2p.7rnx.net/ap/activities/like/' + rkey, type: 'Like', object }
111})
112
113test('★★ our own likes and boosts appear, attributed to us', (t) => {
114 const rows = buildFeed({
115 self: { handle: '@p2p@p2p.7rnx.net', name: 'Fleeky' },
116 own: [
117 ownLike('l1', '2026-09-15T12:00:00Z'),
118 { kind: 'announce', rkey: 'b1', key: 'announce:b1', at: '2026-09-15T12:01:00Z', activity: { type: 'Announce', object: 'https://mastodon.social/notes/2' } }
119 ]
120 })
121 t.alike(rows.map((r) => r.kind), ['boost', 'like'], 'newest first')
122 t.is(rows[1].verb, 'you liked a post', 'in words, and in the first person — it was us')
123 t.is(rows[0].verb, 'you boosted a post')
124 t.is(rows[1].author.handle, '@p2p@p2p.7rnx.net', 'attributed to us, not to whoever we acted on')
125 t.is(rows[1].source, 'own')
126 t.is(rows[1].subject, 'https://mastodon.social/notes/1', 'and it links to what we acted on')
127})
128
129test('★ both directions interleave by time in ONE stream', (t) => {
130 const rows = buildFeed({
131 self: { handle: '@p2p@p2p.7rnx.net' },
132 notes: [note('a', '<p>my post</p>', '2026-09-15T09:00:00Z')],
133 own: [ownLike('l1', '2026-09-15T11:00:00Z')],
134 inbound: [inbound({ type: 'Like', objectId: OURS, content: null, published: '2026-09-15T10:00:00Z' })]
135 })
136 t.alike(rows.map((r) => r.verb), ['you liked a post', 'liked your post', null],
137 'ours, theirs, and a post — one timeline, which is the whole diagnostic')
138})
139
140test('⛔ an outbound interaction with no time still shows, sorted last', (t) => {
141 const rows = buildFeed({ self: {}, own: [ownLike('l1', null)] })
142 t.is(rows.length, 1, '"we do not know when" is not "it did not happen"')
143})
test/public-timeline.test.js +25 -9
……@@ -9,6 +9,7 @@ import '../lib/bare-globals.js'
99import test from 'brittle'
1010import { normalize, isPublicActivity } from '../lib/inbound-store.js'
1111import { publicTimeline } from '../lib/public-timeline.js'
12import { buildFeed } from '../lib/feed.js'
1213
1314const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
1415const note = (extra = {}, objExtra = {}) => ({
……@@ -53,18 +54,18 @@ test('★★★ an item with no addressing at all is withheld', (t) => {
5354})
5455
5556test('★★★ 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>' }]
57 const rows = buildFeed({
58 self: {},
59 inbound: [{ id: 'x', actor: 'https://m.example/u/1', type: 'Create', inReplyTo: 'https://ap.7rnx.net/ap/objects/1', public: true, receivedAt: 1788800000000, content: '<img src=x onerror=alert(1)>hello <b>there</b>' }]
6060 })
61 const body = publicTimeline({ host: 'ap.7rnx.net', total: 1, rows })
6162 t.absent(/<img/.test(body), 'no remote tag survives')
6263 t.absent(/onerror/.test(body), 'no remote attribute survives')
6364 t.ok(/hello there/.test(body), 'the words do')
6465})
6566
6667test('★★ 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 const body = publicTimeline({ host: 'ap.7rnx.net', rows: [], total: 0 })
6869 t.ok(/Direct and followers-only messages are never shown/.test(body),
6970 'a reader should not have to infer the policy from an absence')
7071 // ⚠ Other people's words republished on our domain — letting a crawler take a second permanent copy
……@@ -73,8 +74,23 @@ test('★★ it says what it does not show, and asks not to be indexed', (t) =>
7374})
7475
7576test('★★ 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')
77 const rows = Array.from({ length: 50 }, (_, i) => ({ kind: 'post', author: { handle: '@a@b' }, at: String(i), text: 'x', source: 'own' }))
78 t.ok(/50 older/.test(publicTimeline({ rows, total: 120, offset: 0 })), 'more to come')
79 t.absent(/older/.test(publicTimeline({ rows, total: 50, offset: 0 })), 'nothing more, no link')
80 t.ok(/newer/.test(publicTimeline({ rows, total: 120, offset: 50 })), 'and a way back')
81})
82
83test('★★★ an interaction is visible in words, which is why this page exists', (t) => {
84 const rows = buildFeed({
85 self: {},
86 inbound: [
87 { id: 'l', type: 'Like', actor: 'https://m.example/users/alice', objectId: 'https://ap.7rnx.net/ap/objects/1', public: true, published: '2026-09-15T10:00:00Z' },
88 { id: 'b', type: 'Announce', actor: 'https://m.example/users/bob', objectId: 'https://ap.7rnx.net/ap/objects/1', public: true, published: '2026-09-15T10:01:00Z' }
89 ]
90 })
91 const body = publicTimeline({ host: 'ap.7rnx.net', rows, total: 2 })
92 t.ok(/liked your post/.test(body), 'a like reads as a like, not as the string "Like"')
93 t.ok(/boosted your post/.test(body), 'and a boost as a boost')
94 t.ok(/@alice@m.example/.test(body), 'attributed to a handle a fediverse user recognises')
95 t.absent(/https:\/\/m\.example\/users\/alice<\/span>/.test(body), 'not to a raw URL')
8096})
test/router.test.js +107 -2
……@@ -230,12 +230,17 @@ test('individual objects are fetchable so receivers can verify a Create', async
230230const OUR_POST = 'https://p2p.7rnx.net/ap/objects/3mtecpdhk5c22'
231231
232232function signedActivity (activity) {
233 return signedActivityTo('/ap/inbox', activity)
234}
235
236// The same, to any inbox path — a registered user's inbox is /ap/users/<name>/inbox.
237function signedActivityTo (path, activity) {
233238 const payload = JSON.stringify(activity)
234239 const added = signRequest({
235 method: 'POST', url: 'https://p2p.7rnx.net/ap/inbox', body: payload,
240 method: 'POST', url: 'https://p2p.7rnx.net' + path, body: payload,
236241 keyId: REMOTE_KEY, privateKey: TEST_PRIVATE_KEY
237242 })
238 return { method: 'POST', path: '/ap/inbox', headers: added, body: payload }
243 return { method: 'POST', path, headers: added, body: payload }
239244}
240245
241246const replyTo = (id, inReplyTo = OUR_POST) => ({
……@@ -343,6 +348,45 @@ test('★★ the primary keeps the flat paths and a second user gets their own',
343348 t.is(JSON.parse(b.body).followers, 'https://p2p.7rnx.net/ap/users/ashatron/followers')
344349})
345350
351// ⛔ "An inbox shared by accident is somebody else's mail" — the test above proves the PATHS are
352// separate. The STORE was not: one InboundStore serves the whole process, and nothing on a kept item
353// said who it had been delivered to. Harmless while only the operator read it; a leak the moment a
354// peer can ask the bridge for its own inbound, which is the whole point of keeping it.
355//
356// The recipient is known at arrival — onInbound runs under withUser — so this is about persisting
357// what the route already knows, not about discovering it.
358test('★★ a kept item records WHICH user it was delivered to', async (t) => {
359 const { Users } = await import('../lib/users.js')
360 const users = new Users([{ username: 'p2p', primary: true }, { username: 'ashatron' }], 'p2p.7rnx.net')
361 const inbound = new MemoryInboundStore()
362 const ctx = makeCtx({ users, inbound })
363
364 await handleRequest(signedActivityTo('/ap/users/ashatron/inbox', replyTo('u1')), ctx)
365 await handleRequest(signedActivityTo('/ap/inbox', replyTo('u2')), ctx)
366
367 const kept = await inbound.list({})
368 t.is(kept.length, 2, 'both were kept')
369 const byId = Object.fromEntries(kept.map((i) => [i.objectId, i]))
370 t.is(byId[`${REMOTE}/notes/u1`].recipient, 'ashatron', "the registered user's mail is theirs")
371 t.is(byId[`${REMOTE}/notes/u2`].recipient, 'p2p', 'and the primary keeps the flat path')
372})
373
374test('⛔ a read scoped to one user excludes items that cannot say who they were for', async (t) => {
375 const inbound = new MemoryInboundStore()
376 // An item stored before the field existed. Its addressing is not recoverable — normalize drops
377 // to/cc and the object is only refetchable while the remote server still serves it.
378 await inbound.add({ id: 'old', type: 'Create', actor: REMOTE, objectId: 'x', public: true, receivedAt: 1 })
379 await inbound.add({ id: 'new', type: 'Create', actor: REMOTE, objectId: 'y', public: true, receivedAt: 2, recipient: 'ashatron' })
380
381 const all = await inbound.list({})
382 t.is(all.length, 2, 'both are still held — nothing is destroyed by the new field')
383 const mine = await inbound.list({ recipient: 'ashatron' })
384 t.is(mine.length, 1, 'only the attributable one is served')
385 t.is(mine[0].id, 'new')
386 const theirs = await inbound.list({ recipient: 'p2p' })
387 t.is(theirs.length, 0, "an unattributable item is nobody's, rather than everybody's")
388})
389
346390test('⛔ an unknown user is 404, never the primary', async (t) => {
347391 const { Users } = await import('../lib/users.js')
348392 const ctx = makeCtx({ users: new Users([{ username: 'p2p', primary: true }], 'p2p.7rnx.net') })
……@@ -357,3 +401,64 @@ test('⚠ a node with NO registry behaves exactly as before', async (t) => {
357401 t.is(a.status, 200)
358402 t.is(JSON.parse(a.body).id, 'https://p2p.7rnx.net/ap/actor')
359403})
404
405// ── the public feed ───────────────────────────────────────────────────────────────────────────────
406
407test('★★ /timeline serves one feed: our own posts and the interactions sent to us', async (t) => {
408 const inbound = new MemoryInboundStore()
409 const ctx = makeCtx({
410 inbound,
411 records: {
412 async listNotes () {
413 return { notes: [{ id: 'https://p2p.7rnx.net/ap/objects/1', type: 'Note', content: '<p>my post</p>', published: '2026-09-15T09:00:00Z' }] }
414 }
415 }
416 })
417 await inbound.add({ id: 'l1', type: 'Like', actor: REMOTE, objectId: 'https://p2p.7rnx.net/ap/objects/1', public: true, recipient: 'p2p', published: '2026-09-15T10:00:00Z' })
418 await inbound.add({ id: 'dm', type: 'Create', actor: REMOTE, content: '<p>secret</p>', public: false, recipient: 'p2p', published: '2026-09-15T11:00:00Z' })
419
420 const res = await handleRequest({ method: 'GET', path: '/timeline', headers: {} }, ctx)
421 t.is(res.status, 200)
422 t.ok(/my post/.test(res.body), 'our own post is on our own feed')
423 t.ok(/liked your post/.test(res.body), 'and so is the interaction — the reason to look at this page')
424 t.absent(/secret/.test(res.body), 'a DM arrived at the same inbox and is not published')
425})
426
427test('⛔ a peer\'s feed shows their mail and not another peer\'s', async (t) => {
428 const { Users } = await import('../lib/users.js')
429 const users = new Users([{ username: 'p2p', primary: true }, { username: 'ashatron' }], 'p2p.7rnx.net')
430 const inbound = new MemoryInboundStore()
431 const ctx = makeCtx({ users, inbound })
432 await inbound.add({ id: 'a', type: 'Like', actor: REMOTE, objectId: 'o', public: true, recipient: 'ashatron', published: '2026-09-15T10:00:00Z' })
433 await inbound.add({ id: 'p', type: 'Announce', actor: REMOTE, objectId: 'o', public: true, recipient: 'p2p', published: '2026-09-15T10:01:00Z' })
434
435 const mine = await handleRequest({ method: 'GET', path: '/ap/users/ashatron/timeline', headers: {} }, ctx)
436 t.is(mine.status, 200)
437 t.ok(/liked your post/.test(mine.body), "ashatron's like is on ashatron's page")
438 t.absent(/boosted your post/.test(mine.body), "and p2p's boost is not")
439 t.ok(/@ashatron@p2p.7rnx.net/.test(mine.body), 'the page says whose it is')
440})
441
442test('★★ the feed shows BOTH directions — a like we sent and a like we got', async (t) => {
443 const inbound = new MemoryInboundStore()
444 const ctx = makeCtx({
445 inbound,
446 records: {
447 async listNotes () { return { notes: [] } },
448 async listInteractions () {
449 return {
450 interactions: [{ kind: 'like', rkey: 'x1', key: 'like:x1', at: '2026-09-15T12:00:00Z', activity: { type: 'Like', object: 'https://m.example/notes/9' } }],
451 unaddressable: 2
452 }
453 }
454 }
455 })
456 await inbound.add({ id: 'l1', type: 'Like', actor: REMOTE, objectId: 'https://p2p.7rnx.net/ap/objects/1', public: true, recipient: 'p2p', published: '2026-09-15T10:00:00Z' })
457
458 const res = await handleRequest({ method: 'GET', path: '/timeline', headers: {} }, ctx)
459 t.is(res.status, 200)
460 t.ok(/you liked a post/.test(res.body), 'outbound — the half that proves a like we sent exists')
461 t.ok(/liked your post/.test(res.body), 'inbound')
462 t.ok(/2 of our interaction\(s\) could not be addressed/.test(res.body),
463 'and the ones we could not name are counted, not silently absent')
464})