OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / a340f78

0.10.3: pictures on the feed, and the filter that hid every interaction
⛔ AN INTERACTION CARRIES NO AUDIENCE, AND ABSENCE IS NOT EVIDENCE OF PRIVACY. `isPublicActivity` looks for to/cc on the activity or its object. A Like has neither — its `object` is a bare URI string and Mastodon sends no addressing at all — so "no audience" read as "not public" and every inbound like, boost and follow was withheld BY CONSTRUCTION. Measured on the live bridge: one Like from @fleeky@prsm.space, stored `public: false`, invisible on the page built to show exactly that. The feed found it on its first day, which is the argument for the feed. The content rule is UNTOUCHED. A Create or Update with no addressing is still withheld, because a DM arrives at the same inbox through the same handler. An Announce can carry an audience, unlike a Like, so when it supplies one it is still honoured. ⚠ A stored `false` on a Like or a Follow is re-derived, because it was never a privacy decision — it was the old rule mistaking silence for refusal. An Announce is deliberately NOT in that exception: it might be a real answer, and treating it as an artifact could publish a followers-only boost. Images: ours render inline, a remote one is LINKED. This page has no login, so an <img> at a stranger's server hands them every viewer's IP and a view count. Mastodon proxies for this reason; we have no proxy, so we link and say what is there. Alt text travels; the list is bounded at both ends, because on the inbound side a remote server should not decide how tall our page is. 1179/1179 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a340f784ca89c6878a2c8744c08035e6f4035092
by plankatron <fleeky@gmail.com> · 2026-09-15T16:01:41.000Z

Changed files (7) +305 -105

.pgh-lock.json +95 -95
……@@ -1,9 +1,9 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.1",
3 "version": "0.10.2",
44 "runtime": "bare",
5 "rootTreeHash": "075afb44758c6de5a10bf35a819c5207733e68b1c791227da992867c9cd48832",
6 "publishedAt": "2026-09-12T00:46:51.247Z",
5 "rootTreeHash": "7e5aa041b30466190a4247b4bac3cdf98a1d4cf4663c83f0ec1b0f48c3962e6d",
6 "publishedAt": "2026-09-15T15:32:24.187Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -2396,8 +2396,8 @@
23962396 {
23972397 "name": "favela-peer",
23982398 "version": "0.0.56",
2399 "tree": "d42374919d053f6d95762c9f0cab06f295ee806239d05ff87cdb56eeef98c40c",
2400 "normHash": "332b4cf1dace21ba46cfd711602680d5f1f29a08b21592225ca674619fa6b336",
2399 "tree": "d671f1d149cab9418c05acb06158ebeb24cd1daa984d57e428f31a716732f590",
2400 "normHash": "2c803b1b4fa958b417a5fa554b969cdb0fd3949822902fcae2b8205d41da50b6",
24012401 "workspace": true
24022402 },
24032403 {
……@@ -4004,8 +4004,8 @@
40044004 "tree": "38f44af48b2d59b46f1b5db31b81d02f3ca8b8bad6bc0fb9fffff698241f5e37"
40054005 }
40064006 ],
4007 "reason": "ap-bridge 0.10.1 — silent re-auth",
4008 "acceptedAt": "2026-09-12T00:46:51.269Z"
4007 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4008 "acceptedAt": "2026-09-15T15:32:24.222Z"
40094009 },
40104010 {
40114011 "type": "VERSION_CONFLICT",
……@@ -4019,8 +4019,8 @@
40194019 "tree": "ee91b6bd77101e3404ca571b2e5adf0f47029d174398a05f50294fc207e39d99"
40204020 }
40214021 ],
4022 "reason": "ap-bridge 0.10.1 — silent re-auth",
4023 "acceptedAt": "2026-09-12T00:46:51.269Z"
4022 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4023 "acceptedAt": "2026-09-15T15:32:24.222Z"
40244024 },
40254025 {
40264026 "type": "VERSION_CONFLICT",
……@@ -4034,8 +4034,8 @@
40344034 "tree": "3bb78d8f06263a549bd1077f37a7df257b1d557e44608631435af625f05a9c11"
40354035 }
40364036 ],
4037 "reason": "ap-bridge 0.10.1 — silent re-auth",
4038 "acceptedAt": "2026-09-12T00:46:51.269Z"
4037 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4038 "acceptedAt": "2026-09-15T15:32:24.222Z"
40394039 },
40404040 {
40414041 "type": "VERSION_CONFLICT",
……@@ -4054,8 +4054,8 @@
40544054 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
40554055 }
40564056 ],
4057 "reason": "ap-bridge 0.10.1 — silent re-auth",
4058 "acceptedAt": "2026-09-12T00:46:51.269Z"
4057 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4058 "acceptedAt": "2026-09-15T15:32:24.222Z"
40594059 },
40604060 {
40614061 "type": "VERSION_CONFLICT",
……@@ -4069,8 +4069,8 @@
40694069 "tree": "da4c6b2654e9d66f10b0faa989311cf182878c24ab51b1c41458cfbf37ad29cd"
40704070 }
40714071 ],
4072 "reason": "ap-bridge 0.10.1 — silent re-auth",
4073 "acceptedAt": "2026-09-12T00:46:51.269Z"
4072 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4073 "acceptedAt": "2026-09-15T15:32:24.222Z"
40744074 },
40754075 {
40764076 "type": "VERSION_CONFLICT",
……@@ -4084,8 +4084,8 @@
40844084 "tree": "40be8f559a13b54ebf0689e63e6c70f0d218035359e17c497ed7bc8a85c3cc95"
40854085 }
40864086 ],
4087 "reason": "ap-bridge 0.10.1 — silent re-auth",
4088 "acceptedAt": "2026-09-12T00:46:51.269Z"
4087 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4088 "acceptedAt": "2026-09-15T15:32:24.222Z"
40894089 },
40904090 {
40914091 "type": "VERSION_CONFLICT",
……@@ -4099,8 +4099,8 @@
40994099 "tree": "3eae85efce82299769898c8151b5ed0a94e5a5ebdbcdee2105b815ca733a61cb"
41004100 }
41014101 ],
4102 "reason": "ap-bridge 0.10.1 — silent re-auth",
4103 "acceptedAt": "2026-09-12T00:46:51.269Z"
4102 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4103 "acceptedAt": "2026-09-15T15:32:24.222Z"
41044104 },
41054105 {
41064106 "type": "VERSION_CONFLICT",
……@@ -4114,8 +4114,8 @@
41144114 "tree": "8d9e01d443a127781773598d2e77c3f372334fad09e20623a819dabe49b22e1a"
41154115 }
41164116 ],
4117 "reason": "ap-bridge 0.10.1 — silent re-auth",
4118 "acceptedAt": "2026-09-12T00:46:51.269Z"
4117 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4118 "acceptedAt": "2026-09-15T15:32:24.222Z"
41194119 },
41204120 {
41214121 "type": "VERSION_CONFLICT",
……@@ -4129,8 +4129,8 @@
41294129 "tree": "dc1ad6c999fe90d946ba0ef34698ea9af451b639be2ad1c5b61eabb08337a6ca"
41304130 }
41314131 ],
4132 "reason": "ap-bridge 0.10.1 — silent re-auth",
4133 "acceptedAt": "2026-09-12T00:46:51.269Z"
4132 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4133 "acceptedAt": "2026-09-15T15:32:24.222Z"
41344134 },
41354135 {
41364136 "type": "VERSION_CONFLICT",
……@@ -4144,8 +4144,8 @@
41444144 "tree": "88b4779570e5113aa21b204989ac018d1628b387681f7e53428a7c99114fb701"
41454145 }
41464146 ],
4147 "reason": "ap-bridge 0.10.1 — silent re-auth",
4148 "acceptedAt": "2026-09-12T00:46:51.269Z"
4147 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4148 "acceptedAt": "2026-09-15T15:32:24.222Z"
41494149 },
41504150 {
41514151 "type": "VERSION_CONFLICT",
……@@ -4159,8 +4159,8 @@
41594159 "tree": "720b867ec4300a3caa2325eb1449499f9940bfb88341ecceb5992fa0fdf5f37d"
41604160 }
41614161 ],
4162 "reason": "ap-bridge 0.10.1 — silent re-auth",
4163 "acceptedAt": "2026-09-12T00:46:51.269Z"
4162 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4163 "acceptedAt": "2026-09-15T15:32:24.222Z"
41644164 },
41654165 {
41664166 "type": "VERSION_CONFLICT",
……@@ -4174,8 +4174,8 @@
41744174 "tree": "b697fe174482a708b49d0764cd4786291845f0034a31e0699381ca7e7b269d04"
41754175 }
41764176 ],
4177 "reason": "ap-bridge 0.10.1 — silent re-auth",
4178 "acceptedAt": "2026-09-12T00:46:51.269Z"
4177 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4178 "acceptedAt": "2026-09-15T15:32:24.222Z"
41794179 },
41804180 {
41814181 "type": "VERSION_CONFLICT",
……@@ -4189,8 +4189,8 @@
41894189 "tree": "767f07242277ae80bd395de5a4969fcdbf956c216158ae517fb2b562558ac1a4"
41904190 }
41914191 ],
4192 "reason": "ap-bridge 0.10.1 — silent re-auth",
4193 "acceptedAt": "2026-09-12T00:46:51.269Z"
4192 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4193 "acceptedAt": "2026-09-15T15:32:24.222Z"
41944194 },
41954195 {
41964196 "type": "VERSION_CONFLICT",
……@@ -4204,8 +4204,8 @@
42044204 "tree": "de7deed21f587ffc8bde4971abe00fca77bb6c71c4e7effb31ad34ee330815df"
42054205 }
42064206 ],
4207 "reason": "ap-bridge 0.10.1 — silent re-auth",
4208 "acceptedAt": "2026-09-12T00:46:51.269Z"
4207 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4208 "acceptedAt": "2026-09-15T15:32:24.222Z"
42094209 },
42104210 {
42114211 "type": "VERSION_CONFLICT",
……@@ -4219,8 +4219,8 @@
42194219 "tree": "2df42517ebf73cd9c11f2f652f75e87401fd4ed7b856c0adc15c33baf1d4f1ad"
42204220 }
42214221 ],
4222 "reason": "ap-bridge 0.10.1 — silent re-auth",
4223 "acceptedAt": "2026-09-12T00:46:51.269Z"
4222 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4223 "acceptedAt": "2026-09-15T15:32:24.222Z"
42244224 },
42254225 {
42264226 "type": "VERSION_CONFLICT",
……@@ -4234,8 +4234,8 @@
42344234 "tree": "325aca171cbff560487177584078c8066133e91176639ca47c98d70e195aa92e"
42354235 }
42364236 ],
4237 "reason": "ap-bridge 0.10.1 — silent re-auth",
4238 "acceptedAt": "2026-09-12T00:46:51.269Z"
4237 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4238 "acceptedAt": "2026-09-15T15:32:24.222Z"
42394239 },
42404240 {
42414241 "type": "VERSION_CONFLICT",
……@@ -4264,8 +4264,8 @@
42644264 "tree": "89a40400106bf4fef95fb5c44a66ee29aada6f76c7740bfba09d8aeeb841f39c"
42654265 }
42664266 ],
4267 "reason": "ap-bridge 0.10.1 — silent re-auth",
4268 "acceptedAt": "2026-09-12T00:46:51.269Z"
4267 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4268 "acceptedAt": "2026-09-15T15:32:24.222Z"
42694269 },
42704270 {
42714271 "type": "VERSION_CONFLICT",
……@@ -4279,8 +4279,8 @@
42794279 "tree": "1bd0eb382789c62cd4622d47bffb60984fdd766260c79f73a2fc7a136f3edbe9"
42804280 }
42814281 ],
4282 "reason": "ap-bridge 0.10.1 — silent re-auth",
4283 "acceptedAt": "2026-09-12T00:46:51.269Z"
4282 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4283 "acceptedAt": "2026-09-15T15:32:24.222Z"
42844284 },
42854285 {
42864286 "type": "VERSION_CONFLICT",
……@@ -4294,8 +4294,8 @@
42944294 "tree": "4526fe9dc5468264c7171ec16e9303726674b7b44ca0b3e09cb8b9a6a1be6169"
42954295 }
42964296 ],
4297 "reason": "ap-bridge 0.10.1 — silent re-auth",
4298 "acceptedAt": "2026-09-12T00:46:51.269Z"
4297 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4298 "acceptedAt": "2026-09-15T15:32:24.222Z"
42994299 },
43004300 {
43014301 "type": "VERSION_CONFLICT",
……@@ -4309,8 +4309,8 @@
43094309 "tree": "32b291cd756e142da18a6ed01c351e024fcedc3e2b1ecfbfb85f3106ada4419b"
43104310 }
43114311 ],
4312 "reason": "ap-bridge 0.10.1 — silent re-auth",
4313 "acceptedAt": "2026-09-12T00:46:51.269Z"
4312 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4313 "acceptedAt": "2026-09-15T15:32:24.222Z"
43144314 },
43154315 {
43164316 "type": "VERSION_CONFLICT",
……@@ -4324,8 +4324,8 @@
43244324 "tree": "4a8cd3b0093092b33088617dc0415e8179882d7f2f13b7649962ca20b08beeed"
43254325 }
43264326 ],
4327 "reason": "ap-bridge 0.10.1 — silent re-auth",
4328 "acceptedAt": "2026-09-12T00:46:51.269Z"
4327 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4328 "acceptedAt": "2026-09-15T15:32:24.222Z"
43294329 },
43304330 {
43314331 "type": "VERSION_CONFLICT",
……@@ -4339,8 +4339,8 @@
43394339 "tree": "4f63f165ee6a581f689b40c4a40df4df01ab6fba17d46bd29e40ef5bbdd9884f"
43404340 }
43414341 ],
4342 "reason": "ap-bridge 0.10.1 — silent re-auth",
4343 "acceptedAt": "2026-09-12T00:46:51.269Z"
4342 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4343 "acceptedAt": "2026-09-15T15:32:24.222Z"
43444344 },
43454345 {
43464346 "type": "VERSION_CONFLICT",
……@@ -4354,8 +4354,8 @@
43544354 "tree": "735109c736c0bd3413ab47414fd8b6ea9a99c401fe8e6d608459c79293806754"
43554355 }
43564356 ],
4357 "reason": "ap-bridge 0.10.1 — silent re-auth",
4358 "acceptedAt": "2026-09-12T00:46:51.269Z"
4357 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4358 "acceptedAt": "2026-09-15T15:32:24.222Z"
43594359 },
43604360 {
43614361 "type": "VERSION_CONFLICT",
……@@ -4369,8 +4369,8 @@
43694369 "tree": "b32b75bf1b2acdd18ed0cc370d01d477fba42ba09fd1f2f78af3ebad925e72d5"
43704370 }
43714371 ],
4372 "reason": "ap-bridge 0.10.1 — silent re-auth",
4373 "acceptedAt": "2026-09-12T00:46:51.269Z"
4372 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4373 "acceptedAt": "2026-09-15T15:32:24.222Z"
43744374 },
43754375 {
43764376 "type": "VERSION_CONFLICT",
……@@ -4389,8 +4389,8 @@
43894389 "tree": "189397178121e9a18430943345dab820691648a324229071ceb980bbb77a7393"
43904390 }
43914391 ],
4392 "reason": "ap-bridge 0.10.1 — silent re-auth",
4393 "acceptedAt": "2026-09-12T00:46:51.269Z"
4392 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4393 "acceptedAt": "2026-09-15T15:32:24.222Z"
43944394 },
43954395 {
43964396 "type": "VERSION_CONFLICT",
……@@ -4404,8 +4404,8 @@
44044404 "tree": "3bedca5287a9041fc2fd5ec5ace4df1fa86240e6afec0a105ba67398bafc09a5"
44054405 }
44064406 ],
4407 "reason": "ap-bridge 0.10.1 — silent re-auth",
4408 "acceptedAt": "2026-09-12T00:46:51.269Z"
4407 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4408 "acceptedAt": "2026-09-15T15:32:24.222Z"
44094409 },
44104410 {
44114411 "type": "VERSION_CONFLICT",
……@@ -4419,8 +4419,8 @@
44194419 "tree": "b9ca9b272008bd67672a1b252390e8b527095c46b46cf6b6725a7a187eb038b6"
44204420 }
44214421 ],
4422 "reason": "ap-bridge 0.10.1 — silent re-auth",
4423 "acceptedAt": "2026-09-12T00:46:51.269Z"
4422 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4423 "acceptedAt": "2026-09-15T15:32:24.222Z"
44244424 },
44254425 {
44264426 "type": "VERSION_CONFLICT",
……@@ -4434,8 +4434,8 @@
44344434 "tree": "7793645d7d4248d6d6533e3cbcd4bf40bed4f38c678b0ca0606c15640ba5e7b9"
44354435 }
44364436 ],
4437 "reason": "ap-bridge 0.10.1 — silent re-auth",
4438 "acceptedAt": "2026-09-12T00:46:51.269Z"
4437 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4438 "acceptedAt": "2026-09-15T15:32:24.222Z"
44394439 },
44404440 {
44414441 "type": "VERSION_CONFLICT",
……@@ -4449,8 +4449,8 @@
44494449 "tree": "79447513d4ac0357c8d60bdc7b81b3c5143d761b26700340cba266c4d1cb77b0"
44504450 }
44514451 ],
4452 "reason": "ap-bridge 0.10.1 — silent re-auth",
4453 "acceptedAt": "2026-09-12T00:46:51.269Z"
4452 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4453 "acceptedAt": "2026-09-15T15:32:24.222Z"
44544454 },
44554455 {
44564456 "type": "VERSION_CONFLICT",
……@@ -4464,8 +4464,8 @@
44644464 "tree": "279d4e0447eb79fd75816864ba7f41f2df03080c9da69ea4603fe2900c8d9e53"
44654465 }
44664466 ],
4467 "reason": "ap-bridge 0.10.1 — silent re-auth",
4468 "acceptedAt": "2026-09-12T00:46:51.269Z"
4467 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4468 "acceptedAt": "2026-09-15T15:32:24.222Z"
44694469 },
44704470 {
44714471 "type": "VERSION_CONFLICT",
……@@ -4479,8 +4479,8 @@
44794479 "tree": "ed4e0b480417610e195ff0b59e5b9a453abbf2db6d6ce72d7cd522269f835e9a"
44804480 }
44814481 ],
4482 "reason": "ap-bridge 0.10.1 — silent re-auth",
4483 "acceptedAt": "2026-09-12T00:46:51.269Z"
4482 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4483 "acceptedAt": "2026-09-15T15:32:24.222Z"
44844484 },
44854485 {
44864486 "type": "VERSION_CONFLICT",
……@@ -4499,8 +4499,8 @@
44994499 "tree": "a9e07d54e526c838dfe5e27df33936682aefb218f61a45b6c15289243999f648"
45004500 }
45014501 ],
4502 "reason": "ap-bridge 0.10.1 — silent re-auth",
4503 "acceptedAt": "2026-09-12T00:46:51.269Z"
4502 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4503 "acceptedAt": "2026-09-15T15:32:24.222Z"
45044504 },
45054505 {
45064506 "type": "VERSION_CONFLICT",
……@@ -4514,8 +4514,8 @@
45144514 "tree": "9d84c86441fe138b227c4fd01d1e948aa83bf8d13cfb1aa957838feea05fbb57"
45154515 }
45164516 ],
4517 "reason": "ap-bridge 0.10.1 — silent re-auth",
4518 "acceptedAt": "2026-09-12T00:46:51.269Z"
4517 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4518 "acceptedAt": "2026-09-15T15:32:24.222Z"
45194519 },
45204520 {
45214521 "type": "VERSION_CONFLICT",
……@@ -4529,8 +4529,8 @@
45294529 "tree": "4a3afbe2fc7a882e24454eb2e7e7cfe6816811ee5d73ae0f92e8b9ea9cfb8c78"
45304530 }
45314531 ],
4532 "reason": "ap-bridge 0.10.1 — silent re-auth",
4533 "acceptedAt": "2026-09-12T00:46:51.269Z"
4532 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4533 "acceptedAt": "2026-09-15T15:32:24.222Z"
45344534 },
45354535 {
45364536 "type": "VERSION_CONFLICT",
……@@ -4544,8 +4544,8 @@
45444544 "tree": "1e5c5810d8971a3c27fde7fb0e4d633dcabbe2cf98d8df86b473f8d67eead8b9"
45454545 }
45464546 ],
4547 "reason": "ap-bridge 0.10.1 — silent re-auth",
4548 "acceptedAt": "2026-09-12T00:46:51.269Z"
4547 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4548 "acceptedAt": "2026-09-15T15:32:24.222Z"
45494549 },
45504550 {
45514551 "type": "VERSION_CONFLICT",
……@@ -4559,8 +4559,8 @@
45594559 "tree": "e3478f50e85ba0ac288efed83e84c992483904897dc39ccf243d20a4ea25d972"
45604560 }
45614561 ],
4562 "reason": "ap-bridge 0.10.1 — silent re-auth",
4563 "acceptedAt": "2026-09-12T00:46:51.269Z"
4562 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4563 "acceptedAt": "2026-09-15T15:32:24.222Z"
45644564 },
45654565 {
45664566 "type": "VERSION_CONFLICT",
……@@ -4574,8 +4574,8 @@
45744574 "tree": "c05d8bf7eab5024ea98140f78c84d12fb494b4b7b10c7eedeb50b2be842fa519"
45754575 }
45764576 ],
4577 "reason": "ap-bridge 0.10.1 — silent re-auth",
4578 "acceptedAt": "2026-09-12T00:46:51.269Z"
4577 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4578 "acceptedAt": "2026-09-15T15:32:24.222Z"
45794579 },
45804580 {
45814581 "type": "VERSION_CONFLICT",
……@@ -4589,8 +4589,8 @@
45894589 "tree": "0697bb338bbcec517353cffcdb2345a8232b9b90cc627a549a306f0323576390"
45904590 }
45914591 ],
4592 "reason": "ap-bridge 0.10.1 — silent re-auth",
4593 "acceptedAt": "2026-09-12T00:46:51.269Z"
4592 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4593 "acceptedAt": "2026-09-15T15:32:24.222Z"
45944594 },
45954595 {
45964596 "type": "VERSION_CONFLICT",
……@@ -4604,8 +4604,8 @@
46044604 "tree": "a366a73e74f8d80f106465058083b62bb70b833c721047124e99c89880e5c706"
46054605 }
46064606 ],
4607 "reason": "ap-bridge 0.10.1 — silent re-auth",
4608 "acceptedAt": "2026-09-12T00:46:51.269Z"
4607 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4608 "acceptedAt": "2026-09-15T15:32:24.222Z"
46094609 },
46104610 {
46114611 "type": "VERSION_CONFLICT",
……@@ -4619,8 +4619,8 @@
46194619 "tree": "7912e790c9e87e9829a6daa037c5f5da0cc4355214f51d334f96e57678dfddeb"
46204620 }
46214621 ],
4622 "reason": "ap-bridge 0.10.1 — silent re-auth",
4623 "acceptedAt": "2026-09-12T00:46:51.269Z"
4622 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4623 "acceptedAt": "2026-09-15T15:32:24.222Z"
46244624 },
46254625 {
46264626 "type": "VERSION_CONFLICT",
……@@ -4634,8 +4634,8 @@
46344634 "tree": "003ac841449fcdda1abc3c87fee291a5d9596d245925a7298bd8ef5f8e423eae"
46354635 }
46364636 ],
4637 "reason": "ap-bridge 0.10.1 — silent re-auth",
4638 "acceptedAt": "2026-09-12T00:46:51.269Z"
4637 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4638 "acceptedAt": "2026-09-15T15:32:24.222Z"
46394639 },
46404640 {
46414641 "type": "VERSION_CONFLICT",
……@@ -4654,8 +4654,8 @@
46544654 "tree": "7b3f314cf92558edbc90295c573885276a6add925318998c829c03d335edc765"
46554655 }
46564656 ],
4657 "reason": "ap-bridge 0.10.1 — silent re-auth",
4658 "acceptedAt": "2026-09-12T00:46:51.269Z"
4657 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4658 "acceptedAt": "2026-09-15T15:32:24.222Z"
46594659 },
46604660 {
46614661 "type": "VERSION_CONFLICT",
……@@ -4669,8 +4669,8 @@
46694669 "tree": "c486fe73805bf013e9c5de91b5780d1aa5d2b2295306f411f7863757aaa05e60"
46704670 }
46714671 ],
4672 "reason": "ap-bridge 0.10.1 — silent re-auth",
4673 "acceptedAt": "2026-09-12T00:46:51.269Z"
4672 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4673 "acceptedAt": "2026-09-15T15:32:24.222Z"
46744674 },
46754675 {
46764676 "type": "VERSION_CONFLICT",
……@@ -4684,8 +4684,8 @@
46844684 "tree": "10193a86e2c6f80c2d4377379a0aae4a959f8a40fd9e852b85fee081bfe6675a"
46854685 }
46864686 ],
4687 "reason": "ap-bridge 0.10.1 — silent re-auth",
4688 "acceptedAt": "2026-09-12T00:46:51.269Z"
4687 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4688 "acceptedAt": "2026-09-15T15:32:24.222Z"
46894689 },
46904690 {
46914691 "type": "VERSION_CONFLICT",
……@@ -4699,8 +4699,8 @@
46994699 "tree": "20b1ecd2937f3e35102892288234b86a8ec6deb3711016d96ff3509474ac465b"
47004700 }
47014701 ],
4702 "reason": "ap-bridge 0.10.1 — silent re-auth",
4703 "acceptedAt": "2026-09-12T00:46:51.269Z"
4702 "reason": "0.10.2 feed: 45 conflicts, all version-splits/docs/packaging, 0 code-differs per pgh's own classification",
4703 "acceptedAt": "2026-09-15T15:32:24.222Z"
47044704 }
47054705 ]
47064706}
lib/feed.js +20 -3
……@@ -20,6 +20,8 @@
2020// PURE. No store, no network, no clock — the merge, the verbs, the withholding and the ordering are
2121// testable on their own, which is the only way a safety filter should ever be checked.
2222
23import { showableStored } from './inbound-store.js'
24
2325// AS2 activity type → what a reader is actually being told. A `Create` is a reply when it answers
2426// something and a mention otherwise — the distinction `inReplyTo` was kept for.
2527const VERB = {
……@@ -56,6 +58,18 @@ const textOf = (html) => String(html == null ? '' : html).replace(/<[^>]*>/g, '
5658
5759const timeOf = (i) => i.published || (i.receivedAt ? new Date(Number(i.receivedAt)).toISOString() : '')
5860
61// ⛔ BOUNDED, AND ONLY WHAT CAN BE SHOWN. The list comes from a remote server on the inbound side, so
62// an unbounded one lets a stranger decide how tall our page is. An entry with no url names nothing and
63// is dropped rather than rendered as a broken box.
64//
65// ⚠ `name` is the ALT TEXT and it travels. A picture with no description is unreadable to some
66// readers, and dropping it here is the kind of loss nobody notices because the page still looks fine.
67const MAX_ATTACHMENTS = 8
68const pickAttachments = (list) => (Array.isArray(list) ? list : [])
69 .filter((a) => a && typeof a.url === 'string' && a.url)
70 .slice(0, MAX_ATTACHMENTS)
71 .map((a) => ({ url: a.url, mediaType: a.mediaType || null, name: a.name || null }))
72
5973/**
6074 * One feed, newest first, from this peer's own public notes and the public activity sent to them.
6175 *
……@@ -81,7 +95,7 @@ export function buildFeed ({ self = {}, notes = [], inbound = [], own = [], reci
8195 text: textOf(n.content),
8296 url: n.id || null,
8397 subject: null,
84 attachments: Array.isArray(n.attachment) ? n.attachment.filter(Boolean) : [],
98 attachments: pickAttachments(n.attachment),
8599 source: 'own'
86100 })
87101 }
……@@ -121,7 +135,10 @@ export function buildFeed ({ self = {}, notes = [], inbound = [], own = [], reci
121135 // ⛔ FAILS CLOSED, TWICE. `public !== true` catches both a DM and an item stored before the field
122136 // existed; the recipient check catches another peer's mail. Neither is a formality: each is a
123137 // live class of item in the one store this reads.
124 if (i.public !== true) continue
138 // ⛔ FAILS CLOSED FOR CONTENT. A Create with no addressing is a DM until proven otherwise, and a
139 // DM arrives at this inbox through the same handler. `showableStored` widens this for a Like or a
140 // Follow only, where a stored `false` is the old rule mistaking "no audience" for "not public".
141 if (!showableStored(i)) continue
125142 if (recipient && i.recipient !== recipient) continue
126143
127144 let kind, verb
……@@ -147,7 +164,7 @@ export function buildFeed ({ self = {}, notes = [], inbound = [], own = [], reci
147164 url: i.url || i.objectId || null,
148165 // What of OURS it acted on, so a reader can follow it back.
149166 subject: i.inReplyTo || (kind === 'follow' ? null : i.objectId) || null,
150 attachments: [],
167 attachments: pickAttachments(i.attachment),
151168 source: 'fediverse'
152169 })
153170 }
lib/inbound-store.js +65 -1
……@@ -64,6 +64,62 @@ export function isPublicActivity (activity) {
6464 return false
6565}
6666
67// ⛔ AN INTERACTION CARRIES NO AUDIENCE, AND ABSENCE IS NOT EVIDENCE OF PRIVACY.
68//
69// `isPublicActivity` looks for to/cc on the activity or its object. A Like has neither — its `object`
70// is a bare URI string and Mastodon sends no addressing at all — so "no audience" read as "not
71// public" and EVERY inbound like, boost and follow was withheld by construction. Measured on the live
72// bridge 2026-09-15: one Like from @fleeky@prsm.space, stored `public: false`, invisible on a page
73// built to show precisely that.
74//
75// An interaction's visibility is not its own property: it belongs to the thing it acted on, which
76// here is one of our own posts, and those are public. That is also what Mastodon does — favourites
77// and boosts are visible to anyone who can see the post.
78//
79// ⚠ THE CONTENT RULE IS UNTOUCHED. A Create or Update with no addressing is still withheld, because a
80// DIRECT MESSAGE arrives at the same inbox through the same handler, and that is the one mistake this
81// whole area exists to prevent. Only the types below change, and an Announce — which CAN carry
82// addressing, unlike a Like — still has its stated audience honoured when it supplies one.
83const INTERACTION_TYPES = new Set(['Like', 'Announce', 'Follow'])
84
85export function isInteraction (activity) {
86 return !!(activity && INTERACTION_TYPES.has(activity.type))
87}
88
89// Was this addressed at all? Distinguishes "said nothing about audience" (an interaction) from
90// "said followers-only" (a boost that means it).
91function hasAddressing (activity) {
92 return audience(activity).length > 0 ||
93 (activity && typeof activity.object === 'object' ? audience(activity.object).length > 0 : false)
94}
95
96/** Should this appear on a page with no login in front of it? */
97export function isShowable (activity) {
98 if (isInteraction(activity)) {
99 // Stated privacy beats the default; silence means the protocol had nothing to say.
100 return hasAddressing(activity) ? isPublicActivity(activity) : true
101 }
102 return isPublicActivity(activity)
103}
104
105/**
106 * Should a STORED item appear? `public` was recorded at arrival and cannot be recomputed — the
107 * addressing is gone — so this is the stored value, with one narrow exception.
108 *
109 * ⛔ A Like or a Follow NEVER carries an audience, so a stored `false` on one is not a privacy
110 * decision: it is the old rule mistaking silence for refusal. Re-deriving those is reading the type,
111 * not inventing addressing we lost.
112 *
113 * ⚠ An Announce is NOT in that exception. It can carry an audience, so a stored value for one might
114 * be a real answer, and treating it as an artifact could publish a followers-only boost. There are
115 * none on the live bridge; new ones get the correct rule at arrival.
116 */
117export function showableStored (item) {
118 if (!item) return false
119 if (item.type === 'Like' || item.type === 'Follow') return true
120 return item.public === true
121}
122
67123export function normalize (activity, receivedAt, recipient = null) {
68124 const obj = activity && typeof activity.object === 'object' ? activity.object : null
69125 const objectId = obj ? obj.id : (typeof activity?.object === 'string' ? activity.object : null)
……@@ -79,12 +135,20 @@ export function normalize (activity, receivedAt, recipient = null) {
79135 // 40 KB post body, and the full object is always refetchable by id.
80136 content: typeof obj?.content === 'string' ? obj.content.slice(0, 4000) : null,
81137 url: obj?.url || objectId || null,
138 // ⛔ THE PICTURES, OR A REPLY WITH ONE IS SHOWN AS EMPTY TEXT. Kept as {url, mediaType, name}
139 // only — `name` is the alt text, and the rest of a remote attachment is whatever that server
140 // decided to send. Bounded here as well as at render: this is the copy that goes to disk.
141 attachment: Array.isArray(obj?.attachment)
142 ? obj.attachment.filter((a) => a && typeof a.url === 'string')
143 .slice(0, 8)
144 .map((a) => ({ url: a.url, mediaType: a.mediaType || null, name: a.name || null }))
145 : null,
82146 published: obj?.published || activity?.published || null,
83147 // ⛔ RECORDED AT ARRIVAL, because the addressing is not recoverable later — `to`/`cc` are dropped
84148 // by this very function, and the object is only refetchable while the remote server still serves
85149 // it. Anything stored before this field existed is `undefined`, which every reader must treat as
86150 // NOT public; see the note on isPublicActivity.
87 public: isPublicActivity(activity),
151 public: isShowable(activity),
88152 // ⛔ WHOSE MAIL IS THIS. One InboundStore serves the whole process while the inbox PATHS are
89153 // per-user (`/ap/users/<name>/inbox`), so without this a kept item cannot say who it was
90154 // delivered to. That is harmless while only the operator reads the store and a leak the moment a
lib/public-timeline.js +35 -4
……@@ -42,11 +42,14 @@ border-left:2px solid #eff3f4;padding-left:8px}
4242nav{display:flex;gap:16px;padding:16px}
4343a{color:#1d9bf0;text-decoration:none}a:hover{text-decoration:underline}
4444.pill{font-size:12px;color:#536471;border:1px solid #eff3f4;border-radius:999px;padding:1px 8px}
45.media{margin-top:8px;display:flex;flex-wrap:wrap;gap:6px}
46.media img{max-width:100%;max-height:420px;border-radius:14px;border:1px solid #eff3f4;display:block}
47.att{font-size:13px;color:#536471;border:1px solid #eff3f4;border-radius:999px;padding:3px 10px}
4548footer{color:#536471;font-size:13px;padding:16px;text-align:center}
4649@media(prefers-color-scheme:dark){body{background:#000;color:#e7e9ea}
4750header{background:rgba(0,0,0,.85);border-color:#2f3336}.row{border-color:#2f3336}
4851.row:hover{background:#080808}.handle,.time,.verb,.none,.pill,footer{color:#71767b}
49.sub-link{border-color:#2f3336;color:#71767b}.pill{border-color:#2f3336}}`
52.sub-link{border-color:#2f3336;color:#71767b}.pill,.media img,.att{border-color:#2f3336}}`
5053
5154const when = (at) => {
5255 const d = new Date(at)
……@@ -61,7 +64,34 @@ const avatar = (a) => {
6164 return '<span class="av">' + esc(String(src).replace(/^@/, '').charAt(0).toUpperCase() || '?') + '</span>'
6265}
6366
64function renderRow (r) {
67// ⛔ OUR OWN PICTURES ARE SHOWN; A REMOTE ONE IS LINKED, NOT HOTLINKED. This page has no login in
68// front of it, so an <img> pointing at a stranger's server hands that server every viewer's IP and a
69// view count for our page. Mastodon proxies remote media for exactly this reason; we do not have a
70// proxy, so we link and say what is there. The reader loses one click and nobody else learns who read
71// the page.
72//
73// ⚠ Same-origin is decided on the HOST, not a prefix match: "ap.7rnx.net.evil.com" starts with our
74// host and is not us.
75function sameHost (url, host) {
76 if (!host) return false
77 try { return new URL(String(url)).host === host } catch { return false }
78}
79
80const isImage = (a) => typeof a.mediaType === 'string' && a.mediaType.startsWith('image/')
81
82function renderAttachments (list, host) {
83 if (!list || !list.length) return ''
84 return '<div class="media">' + list.map((a) => {
85 const label = a.name || 'attachment'
86 if (isImage(a) && sameHost(a.url, host)) {
87 return '<a href="' + esc(a.url) + '"><img src="' + esc(a.url) + '" alt="' + esc(label) + '" loading="lazy"></a>'
88 }
89 return '<a class="att" href="' + esc(a.url) + '" rel="nofollow noopener ugc">' +
90 (isImage(a) ? '🖼 ' : '📎 ') + esc(label) + ' ↗</a>'
91 }).join('') + '</div>'
92}
93
94function renderRow (r, host) {
6595 const a = r.author || {}
6696 const name = a.name || String(a.handle || '').replace(/^@/, '').split('@')[0] || 'someone'
6797 const head = '<div class="head"><span class="name">' + esc(name) + '</span>' +
……@@ -84,12 +114,13 @@ function renderRow (r) {
84114 const link = r.url && !r.subject
85115 ? '<a class="sub-link" href="' + esc(r.url) + '" rel="nofollow noopener ugc">open ↗</a>'
86116 : ''
87 return '<article class="row">' + avatar(a) + '<div class="main">' + head + verb + body + back + link + '</div></article>'
117 const media = renderAttachments(r.attachments, host)
118 return '<article class="row">' + avatar(a) + '<div class="main">' + head + verb + body + media + back + link + '</div></article>'
88119}
89120
90121export function publicTimeline ({ host = '', rows = [], offset = 0, limit = 50, total = 0, who = null, unaddressable = 0 } = {}) {
91122 const list = rows.length
92 ? rows.map(renderRow).join('')
123 ? rows.map((r) => renderRow(r, host)).join('')
93124 : '<p class="none">Nothing public here yet.</p>'
94125
95126 const next = offset + rows.length
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.2",
3 "version": "0.10.3",
44 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/feed.test.js +32 -0
……@@ -141,3 +141,35 @@ test('⛔ an outbound interaction with no time still shows, sorted last', (t) =>
141141 const rows = buildFeed({ self: {}, own: [ownLike('l1', null)] })
142142 t.is(rows.length, 1, '"we do not know when" is not "it did not happen"')
143143})
144
145// ── images ────────────────────────────────────────────────────────────────────────────────────────
146
147test('a post carries its pictures through to the row', (t) => {
148 const withImg = {
149 ...note('img', '<p>look</p>', '2026-09-15T09:00:00Z'),
150 attachment: [{ type: 'Document', mediaType: 'image/png', url: 'https://p2p.7rnx.net/ap/blobs/bafk1', name: 'a screenshot' }]
151 }
152 const rows = buildFeed({ self: {}, notes: [withImg] })
153 t.is(rows[0].attachments.length, 1)
154 t.is(rows[0].attachments[0].url, 'https://p2p.7rnx.net/ap/blobs/bafk1')
155 t.is(rows[0].attachments[0].name, 'a screenshot', 'alt text travels — a picture with no description is unreadable to some readers')
156})
157
158test('★ an inbound reply keeps its pictures too', (t) => {
159 const rows = buildFeed({
160 self: {},
161 inbound: [inbound({
162 inReplyTo: OURS,
163 attachment: [{ type: 'Document', mediaType: 'image/jpeg', url: 'https://mastodon.social/media/1.jpg', name: 'their photo' }]
164 })]
165 })
166 t.is(rows[0].attachments.length, 1, 'a reply from the fediverse can have a picture in it')
167 t.is(rows[0].attachments[0].mediaType, 'image/jpeg')
168})
169
170test('⛔ an attachment with no url is dropped, and the count is bounded', (t) => {
171 const many = Array.from({ length: 20 }, (_, i) => ({ type: 'Document', mediaType: 'image/png', url: 'u' + i }))
172 const rows = buildFeed({ self: {}, notes: [{ ...note('m', '<p>x</p>', 'z'), attachment: [{ type: 'Document' }, ...many] }] })
173 t.absent(rows[0].attachments.some((a) => !a.url), 'nothing without a url')
174 t.ok(rows[0].attachments.length <= 8, 'a remote server does not get to decide how tall our page is')
175})
test/public-timeline.test.js +57 -1
……@@ -7,7 +7,7 @@
77
88import '../lib/bare-globals.js'
99import test from 'brittle'
10import { normalize, isPublicActivity } from '../lib/inbound-store.js'
10import { normalize, isPublicActivity, isInteraction } from '../lib/inbound-store.js'
1111import { publicTimeline } from '../lib/public-timeline.js'
1212import { buildFeed } from '../lib/feed.js'
1313
……@@ -94,3 +94,59 @@ test('★★★ an interaction is visible in words, which is why this page exist
9494 t.ok(/@alice@m.example/.test(body), 'attributed to a handle a fediverse user recognises')
9595 t.absent(/https:\/\/m\.example\/users\/alice<\/span>/.test(body), 'not to a raw URL')
9696})
97
98test('★★ our own picture renders; a remote one is LINKED, not hotlinked', (t) => {
99 const rows = buildFeed({
100 self: { handle: '@p2p@ap.7rnx.net' },
101 notes: [{ id: 'https://ap.7rnx.net/ap/objects/1', type: 'Note', content: '<p>look</p>', published: '2026-09-15T09:00:00Z', attachment: [{ type: 'Document', mediaType: 'image/png', url: 'https://ap.7rnx.net/ap/blobs/bafk1', name: 'a screenshot' }] }],
102 inbound: [{ id: 'r', type: 'Create', actor: 'https://m.example/users/alice', inReplyTo: 'https://ap.7rnx.net/ap/objects/1', public: true, published: '2026-09-15T10:00:00Z', content: '<p>nice</p>', attachment: [{ type: 'Document', mediaType: 'image/jpeg', url: 'https://m.example/media/1.jpg', name: 'their photo' }] }]
103 })
104 const body = publicTimeline({ host: 'ap.7rnx.net', rows, total: 2 })
105 t.ok(/<img src="https:\/\/ap.7rnx.net\/ap\/blobs\/bafk1"/.test(body), 'ours is shown — we serve those bytes')
106 t.ok(/alt="a screenshot"/.test(body), 'with its alt text')
107 // ⛔ A public page with no login hotlinking a remote image hands every viewer's IP and page view to
108 // a server we do not run. Mastodon proxies media for exactly this reason; we link instead.
109 t.absent(/<img src="https:\/\/m.example/.test(body), "a stranger's server does not get our readers' IPs")
110 t.ok(/their photo/.test(body), 'but the reader is told a picture is there, and can open it')
111})
112
113// ── interactions carry no audience, and absence is not evidence of privacy ─────────────────────────
114//
115// ⛔ THE RULE THAT WAS WRONG. `isPublicActivity` looks for to/cc on the activity or its object. A Like
116// has neither — its `object` is a bare URI string and Mastodon sends no audience at all — so "no
117// addressing" read as "not public" and EVERY inbound like, boost and follow was invisible by
118// construction. Measured on the live bridge: one Like from @fleeky@prsm.space, stored `public: false`,
119// withheld from a page built to show exactly that.
120//
121// ⚠ THE FIX IS NARROW. For CONTENT the old rule is right and unchanged — a Create with no addressing
122// is still withheld, because a DM arrives at the same inbox through the same handler. What changes is
123// that an interaction is judged by the thing it acted on, which on this bridge is a public post.
124
125test('★★★ a Create with no addressing is STILL withheld — the DM guard is untouched', (t) => {
126 t.absent(isPublicActivity(note({})), 'content with no audience stays private')
127 t.is(normalize(note({}), 1).public, false)
128 t.is(normalize(note({ to: ['https://ap.7rnx.net/ap/users/fleeky/actor'] }), 1).public, false, 'and a DM stays a DM')
129})
130
131test('★★★ a Like is showable — it has no audience because the protocol supplies none', (t) => {
132 const like = { id: 'l', type: 'Like', actor: 'https://m.example/users/a', object: 'https://ap.7rnx.net/ap/objects/1' }
133 t.ok(isInteraction(like), 'a Like is an interaction, not content')
134 t.is(normalize(like, 1).public, true, 'so it is recorded as showable, matching Mastodon')
135})
136
137test('★★ a Follow and a Boost too, and an explicitly private Boost is still refused', (t) => {
138 t.is(normalize({ type: 'Follow', actor: 'https://m.example/users/a', object: 'https://ap.7rnx.net/ap/actor' }, 1).public, true)
139 t.is(normalize({ type: 'Announce', actor: 'https://m.example/users/a', object: 'https://ap.7rnx.net/ap/objects/1' }, 1).public, true)
140 // ⚠ An Announce CAN carry addressing, unlike a Like. When it does, it is honoured.
141 const privateBoost = { type: 'Announce', actor: 'https://m.example/users/a', object: 'x', to: ['https://m.example/users/a/followers'] }
142 t.is(normalize(privateBoost, 1).public, false, 'stated privacy beats the default')
143})
144
145test('★★ the feed now shows a like that arrived', (t) => {
146 const rows = buildFeed({
147 self: {},
148 inbound: [normalize({ type: 'Like', actor: 'https://prsm.space/users/fleeky', object: 'https://ap.7rnx.net/ap/objects/1' }, Date.parse('2026-09-14T00:00:00Z'))]
149 })
150 t.is(rows.length, 1, 'the one measured on the live bridge would appear')
151 t.is(rows[0].verb, 'liked your post')
152})