OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 53e8501

0.10.9: observations — the durable half of an inbound interaction
A like from a stranger reached this bridge, went into a 500-item capped file for our own pages, and was never available to the peer it was aimed at. That is why a handset cannot see who liked its post without asking Bluesky's AppView. ⛔ AN OBSERVATION IS NOT AN EDGE. An edge a favela peer authors is a record in THEIR feed. A like from a Mastodon or Bluesky account is not ours to author — writing one into somebody's feed is authoring as them, which #61 §4 removed the room primitive to avoid. So this records something weaker and honest: we observed that this actor did this to this record of yours, at this time. ⛔ THIS BRIDGE ACCUMULATES AND SERVES; THE PEER ASKS AND STORES. We hold a registered peer's feed and are not a writer on it, so we never write their pointer or their segments. This is a buffer that gets drained, which is also why the interaction codec is NOT here: compression earns its place in an archive, not in something pruned. ⛔ SCOPED BY RECIPIENT WITH NO "EVERYTHING" CALL. One bridge serves every registered identity and an observation belongs to exactly one. A read with no recipient returns nothing rather than everything, so the door's authorisation is never the only thing between one person's social graph and another's. ⚠ CAUGHT BY ITS OWN TEST: a Follow is dispatched to onFollow and never reaches onInbound, so noting observations there alone recorded likes and boosts and silently lost every follow. The test asserts all three kinds for that reason rather than one. Also: a retry is not a second like (deduped by activity id); an undo is its own kind, never an absence (§4.3 — absence is ambiguous in a replicated log); the buffer is bounded oldest-out, because a full buffer refusing what we just learned is the wrong end to drop; and a torn tail from a killed process costs that line, not the file. 350/350, 1247/1247. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
53e8501c0e2e2ec3c517f7bbde738a744491c1cc
by plankatron <fleeky@gmail.com> · 2026-09-16T00:28:56.000Z

Changed files (7) +470 -94

.pgh-lock.json +93 -93
……@@ -1,9 +1,9 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.7",
3 "version": "0.10.8",
44 "runtime": "bare",
5 "rootTreeHash": "38ee12c24f843a8bb57fee2d1821ea985935b37a015214c1632d35361b101aa7",
6 "publishedAt": "2026-09-15T16:24:17.618Z",
5 "rootTreeHash": "a5f2dfc3a0fca68ec50c499d801ec68ccc8a7f6b158070d63826c29e44e55f9a",
6 "publishedAt": "2026-09-15T16:28:31.063Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -4004,8 +4004,8 @@
40044004 "tree": "38f44af48b2d59b46f1b5db31b81d02f3ca8b8bad6bc0fb9fffff698241f5e37"
40054005 }
40064006 ],
4007 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4008 "acceptedAt": "2026-09-15T16:24:17.637Z"
4007 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4008 "acceptedAt": "2026-09-15T16:28:31.080Z"
40094009 },
40104010 {
40114011 "type": "VERSION_CONFLICT",
……@@ -4019,8 +4019,8 @@
40194019 "tree": "ee91b6bd77101e3404ca571b2e5adf0f47029d174398a05f50294fc207e39d99"
40204020 }
40214021 ],
4022 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4023 "acceptedAt": "2026-09-15T16:24:17.637Z"
4022 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4023 "acceptedAt": "2026-09-15T16:28:31.080Z"
40244024 },
40254025 {
40264026 "type": "VERSION_CONFLICT",
……@@ -4034,8 +4034,8 @@
40344034 "tree": "3bb78d8f06263a549bd1077f37a7df257b1d557e44608631435af625f05a9c11"
40354035 }
40364036 ],
4037 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4038 "acceptedAt": "2026-09-15T16:24:17.637Z"
4037 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4038 "acceptedAt": "2026-09-15T16:28:31.080Z"
40394039 },
40404040 {
40414041 "type": "VERSION_CONFLICT",
……@@ -4054,8 +4054,8 @@
40544054 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
40554055 }
40564056 ],
4057 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4058 "acceptedAt": "2026-09-15T16:24:17.637Z"
4057 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4058 "acceptedAt": "2026-09-15T16:28:31.080Z"
40594059 },
40604060 {
40614061 "type": "VERSION_CONFLICT",
……@@ -4069,8 +4069,8 @@
40694069 "tree": "da4c6b2654e9d66f10b0faa989311cf182878c24ab51b1c41458cfbf37ad29cd"
40704070 }
40714071 ],
4072 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4073 "acceptedAt": "2026-09-15T16:24:17.637Z"
4072 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4073 "acceptedAt": "2026-09-15T16:28:31.080Z"
40744074 },
40754075 {
40764076 "type": "VERSION_CONFLICT",
……@@ -4084,8 +4084,8 @@
40844084 "tree": "40be8f559a13b54ebf0689e63e6c70f0d218035359e17c497ed7bc8a85c3cc95"
40854085 }
40864086 ],
4087 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4088 "acceptedAt": "2026-09-15T16:24:17.637Z"
4087 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4088 "acceptedAt": "2026-09-15T16:28:31.080Z"
40894089 },
40904090 {
40914091 "type": "VERSION_CONFLICT",
……@@ -4099,8 +4099,8 @@
40994099 "tree": "3eae85efce82299769898c8151b5ed0a94e5a5ebdbcdee2105b815ca733a61cb"
41004100 }
41014101 ],
4102 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4103 "acceptedAt": "2026-09-15T16:24:17.637Z"
4102 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4103 "acceptedAt": "2026-09-15T16:28:31.080Z"
41044104 },
41054105 {
41064106 "type": "VERSION_CONFLICT",
……@@ -4114,8 +4114,8 @@
41144114 "tree": "8d9e01d443a127781773598d2e77c3f372334fad09e20623a819dabe49b22e1a"
41154115 }
41164116 ],
4117 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4118 "acceptedAt": "2026-09-15T16:24:17.637Z"
4117 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4118 "acceptedAt": "2026-09-15T16:28:31.080Z"
41194119 },
41204120 {
41214121 "type": "VERSION_CONFLICT",
……@@ -4129,8 +4129,8 @@
41294129 "tree": "dc1ad6c999fe90d946ba0ef34698ea9af451b639be2ad1c5b61eabb08337a6ca"
41304130 }
41314131 ],
4132 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4133 "acceptedAt": "2026-09-15T16:24:17.637Z"
4132 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4133 "acceptedAt": "2026-09-15T16:28:31.080Z"
41344134 },
41354135 {
41364136 "type": "VERSION_CONFLICT",
……@@ -4144,8 +4144,8 @@
41444144 "tree": "88b4779570e5113aa21b204989ac018d1628b387681f7e53428a7c99114fb701"
41454145 }
41464146 ],
4147 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4148 "acceptedAt": "2026-09-15T16:24:17.637Z"
4147 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4148 "acceptedAt": "2026-09-15T16:28:31.080Z"
41494149 },
41504150 {
41514151 "type": "VERSION_CONFLICT",
……@@ -4159,8 +4159,8 @@
41594159 "tree": "720b867ec4300a3caa2325eb1449499f9940bfb88341ecceb5992fa0fdf5f37d"
41604160 }
41614161 ],
4162 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4163 "acceptedAt": "2026-09-15T16:24:17.637Z"
4162 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4163 "acceptedAt": "2026-09-15T16:28:31.080Z"
41644164 },
41654165 {
41664166 "type": "VERSION_CONFLICT",
……@@ -4174,8 +4174,8 @@
41744174 "tree": "b697fe174482a708b49d0764cd4786291845f0034a31e0699381ca7e7b269d04"
41754175 }
41764176 ],
4177 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4178 "acceptedAt": "2026-09-15T16:24:17.637Z"
4177 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4178 "acceptedAt": "2026-09-15T16:28:31.080Z"
41794179 },
41804180 {
41814181 "type": "VERSION_CONFLICT",
……@@ -4189,8 +4189,8 @@
41894189 "tree": "767f07242277ae80bd395de5a4969fcdbf956c216158ae517fb2b562558ac1a4"
41904190 }
41914191 ],
4192 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4193 "acceptedAt": "2026-09-15T16:24:17.637Z"
4192 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4193 "acceptedAt": "2026-09-15T16:28:31.080Z"
41944194 },
41954195 {
41964196 "type": "VERSION_CONFLICT",
……@@ -4204,8 +4204,8 @@
42044204 "tree": "de7deed21f587ffc8bde4971abe00fca77bb6c71c4e7effb31ad34ee330815df"
42054205 }
42064206 ],
4207 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4208 "acceptedAt": "2026-09-15T16:24:17.637Z"
4207 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4208 "acceptedAt": "2026-09-15T16:28:31.080Z"
42094209 },
42104210 {
42114211 "type": "VERSION_CONFLICT",
……@@ -4219,8 +4219,8 @@
42194219 "tree": "2df42517ebf73cd9c11f2f652f75e87401fd4ed7b856c0adc15c33baf1d4f1ad"
42204220 }
42214221 ],
4222 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4223 "acceptedAt": "2026-09-15T16:24:17.637Z"
4222 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4223 "acceptedAt": "2026-09-15T16:28:31.080Z"
42244224 },
42254225 {
42264226 "type": "VERSION_CONFLICT",
……@@ -4234,8 +4234,8 @@
42344234 "tree": "325aca171cbff560487177584078c8066133e91176639ca47c98d70e195aa92e"
42354235 }
42364236 ],
4237 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4238 "acceptedAt": "2026-09-15T16:24:17.637Z"
4237 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4238 "acceptedAt": "2026-09-15T16:28:31.080Z"
42394239 },
42404240 {
42414241 "type": "VERSION_CONFLICT",
……@@ -4264,8 +4264,8 @@
42644264 "tree": "89a40400106bf4fef95fb5c44a66ee29aada6f76c7740bfba09d8aeeb841f39c"
42654265 }
42664266 ],
4267 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4268 "acceptedAt": "2026-09-15T16:24:17.637Z"
4267 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4268 "acceptedAt": "2026-09-15T16:28:31.080Z"
42694269 },
42704270 {
42714271 "type": "VERSION_CONFLICT",
……@@ -4279,8 +4279,8 @@
42794279 "tree": "1bd0eb382789c62cd4622d47bffb60984fdd766260c79f73a2fc7a136f3edbe9"
42804280 }
42814281 ],
4282 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4283 "acceptedAt": "2026-09-15T16:24:17.637Z"
4282 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4283 "acceptedAt": "2026-09-15T16:28:31.080Z"
42844284 },
42854285 {
42864286 "type": "VERSION_CONFLICT",
……@@ -4294,8 +4294,8 @@
42944294 "tree": "4526fe9dc5468264c7171ec16e9303726674b7b44ca0b3e09cb8b9a6a1be6169"
42954295 }
42964296 ],
4297 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4298 "acceptedAt": "2026-09-15T16:24:17.637Z"
4297 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4298 "acceptedAt": "2026-09-15T16:28:31.080Z"
42994299 },
43004300 {
43014301 "type": "VERSION_CONFLICT",
……@@ -4309,8 +4309,8 @@
43094309 "tree": "32b291cd756e142da18a6ed01c351e024fcedc3e2b1ecfbfb85f3106ada4419b"
43104310 }
43114311 ],
4312 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4313 "acceptedAt": "2026-09-15T16:24:17.637Z"
4312 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4313 "acceptedAt": "2026-09-15T16:28:31.080Z"
43144314 },
43154315 {
43164316 "type": "VERSION_CONFLICT",
……@@ -4324,8 +4324,8 @@
43244324 "tree": "4a8cd3b0093092b33088617dc0415e8179882d7f2f13b7649962ca20b08beeed"
43254325 }
43264326 ],
4327 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4328 "acceptedAt": "2026-09-15T16:24:17.637Z"
4327 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4328 "acceptedAt": "2026-09-15T16:28:31.080Z"
43294329 },
43304330 {
43314331 "type": "VERSION_CONFLICT",
……@@ -4339,8 +4339,8 @@
43394339 "tree": "4f63f165ee6a581f689b40c4a40df4df01ab6fba17d46bd29e40ef5bbdd9884f"
43404340 }
43414341 ],
4342 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4343 "acceptedAt": "2026-09-15T16:24:17.637Z"
4342 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4343 "acceptedAt": "2026-09-15T16:28:31.080Z"
43444344 },
43454345 {
43464346 "type": "VERSION_CONFLICT",
……@@ -4354,8 +4354,8 @@
43544354 "tree": "735109c736c0bd3413ab47414fd8b6ea9a99c401fe8e6d608459c79293806754"
43554355 }
43564356 ],
4357 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4358 "acceptedAt": "2026-09-15T16:24:17.637Z"
4357 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4358 "acceptedAt": "2026-09-15T16:28:31.080Z"
43594359 },
43604360 {
43614361 "type": "VERSION_CONFLICT",
……@@ -4369,8 +4369,8 @@
43694369 "tree": "b32b75bf1b2acdd18ed0cc370d01d477fba42ba09fd1f2f78af3ebad925e72d5"
43704370 }
43714371 ],
4372 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4373 "acceptedAt": "2026-09-15T16:24:17.637Z"
4372 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4373 "acceptedAt": "2026-09-15T16:28:31.080Z"
43744374 },
43754375 {
43764376 "type": "VERSION_CONFLICT",
……@@ -4389,8 +4389,8 @@
43894389 "tree": "189397178121e9a18430943345dab820691648a324229071ceb980bbb77a7393"
43904390 }
43914391 ],
4392 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4393 "acceptedAt": "2026-09-15T16:24:17.637Z"
4392 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4393 "acceptedAt": "2026-09-15T16:28:31.080Z"
43944394 },
43954395 {
43964396 "type": "VERSION_CONFLICT",
……@@ -4404,8 +4404,8 @@
44044404 "tree": "3bedca5287a9041fc2fd5ec5ace4df1fa86240e6afec0a105ba67398bafc09a5"
44054405 }
44064406 ],
4407 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4408 "acceptedAt": "2026-09-15T16:24:17.637Z"
4407 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4408 "acceptedAt": "2026-09-15T16:28:31.080Z"
44094409 },
44104410 {
44114411 "type": "VERSION_CONFLICT",
……@@ -4419,8 +4419,8 @@
44194419 "tree": "b9ca9b272008bd67672a1b252390e8b527095c46b46cf6b6725a7a187eb038b6"
44204420 }
44214421 ],
4422 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4423 "acceptedAt": "2026-09-15T16:24:17.637Z"
4422 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4423 "acceptedAt": "2026-09-15T16:28:31.080Z"
44244424 },
44254425 {
44264426 "type": "VERSION_CONFLICT",
……@@ -4434,8 +4434,8 @@
44344434 "tree": "7793645d7d4248d6d6533e3cbcd4bf40bed4f38c678b0ca0606c15640ba5e7b9"
44354435 }
44364436 ],
4437 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4438 "acceptedAt": "2026-09-15T16:24:17.637Z"
4437 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4438 "acceptedAt": "2026-09-15T16:28:31.080Z"
44394439 },
44404440 {
44414441 "type": "VERSION_CONFLICT",
……@@ -4449,8 +4449,8 @@
44494449 "tree": "79447513d4ac0357c8d60bdc7b81b3c5143d761b26700340cba266c4d1cb77b0"
44504450 }
44514451 ],
4452 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4453 "acceptedAt": "2026-09-15T16:24:17.637Z"
4452 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4453 "acceptedAt": "2026-09-15T16:28:31.080Z"
44544454 },
44554455 {
44564456 "type": "VERSION_CONFLICT",
……@@ -4464,8 +4464,8 @@
44644464 "tree": "279d4e0447eb79fd75816864ba7f41f2df03080c9da69ea4603fe2900c8d9e53"
44654465 }
44664466 ],
4467 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4468 "acceptedAt": "2026-09-15T16:24:17.637Z"
4467 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4468 "acceptedAt": "2026-09-15T16:28:31.080Z"
44694469 },
44704470 {
44714471 "type": "VERSION_CONFLICT",
……@@ -4479,8 +4479,8 @@
44794479 "tree": "ed4e0b480417610e195ff0b59e5b9a453abbf2db6d6ce72d7cd522269f835e9a"
44804480 }
44814481 ],
4482 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4483 "acceptedAt": "2026-09-15T16:24:17.637Z"
4482 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4483 "acceptedAt": "2026-09-15T16:28:31.080Z"
44844484 },
44854485 {
44864486 "type": "VERSION_CONFLICT",
……@@ -4499,8 +4499,8 @@
44994499 "tree": "a9e07d54e526c838dfe5e27df33936682aefb218f61a45b6c15289243999f648"
45004500 }
45014501 ],
4502 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4503 "acceptedAt": "2026-09-15T16:24:17.637Z"
4502 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4503 "acceptedAt": "2026-09-15T16:28:31.080Z"
45044504 },
45054505 {
45064506 "type": "VERSION_CONFLICT",
……@@ -4514,8 +4514,8 @@
45144514 "tree": "9d84c86441fe138b227c4fd01d1e948aa83bf8d13cfb1aa957838feea05fbb57"
45154515 }
45164516 ],
4517 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4518 "acceptedAt": "2026-09-15T16:24:17.637Z"
4517 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4518 "acceptedAt": "2026-09-15T16:28:31.080Z"
45194519 },
45204520 {
45214521 "type": "VERSION_CONFLICT",
……@@ -4529,8 +4529,8 @@
45294529 "tree": "4a3afbe2fc7a882e24454eb2e7e7cfe6816811ee5d73ae0f92e8b9ea9cfb8c78"
45304530 }
45314531 ],
4532 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4533 "acceptedAt": "2026-09-15T16:24:17.637Z"
4532 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4533 "acceptedAt": "2026-09-15T16:28:31.080Z"
45344534 },
45354535 {
45364536 "type": "VERSION_CONFLICT",
……@@ -4544,8 +4544,8 @@
45444544 "tree": "1e5c5810d8971a3c27fde7fb0e4d633dcabbe2cf98d8df86b473f8d67eead8b9"
45454545 }
45464546 ],
4547 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4548 "acceptedAt": "2026-09-15T16:24:17.637Z"
4547 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4548 "acceptedAt": "2026-09-15T16:28:31.080Z"
45494549 },
45504550 {
45514551 "type": "VERSION_CONFLICT",
……@@ -4559,8 +4559,8 @@
45594559 "tree": "e3478f50e85ba0ac288efed83e84c992483904897dc39ccf243d20a4ea25d972"
45604560 }
45614561 ],
4562 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4563 "acceptedAt": "2026-09-15T16:24:17.637Z"
4562 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4563 "acceptedAt": "2026-09-15T16:28:31.080Z"
45644564 },
45654565 {
45664566 "type": "VERSION_CONFLICT",
……@@ -4574,8 +4574,8 @@
45744574 "tree": "c05d8bf7eab5024ea98140f78c84d12fb494b4b7b10c7eedeb50b2be842fa519"
45754575 }
45764576 ],
4577 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4578 "acceptedAt": "2026-09-15T16:24:17.637Z"
4577 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4578 "acceptedAt": "2026-09-15T16:28:31.080Z"
45794579 },
45804580 {
45814581 "type": "VERSION_CONFLICT",
……@@ -4589,8 +4589,8 @@
45894589 "tree": "0697bb338bbcec517353cffcdb2345a8232b9b90cc627a549a306f0323576390"
45904590 }
45914591 ],
4592 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4593 "acceptedAt": "2026-09-15T16:24:17.637Z"
4592 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4593 "acceptedAt": "2026-09-15T16:28:31.080Z"
45944594 },
45954595 {
45964596 "type": "VERSION_CONFLICT",
……@@ -4604,8 +4604,8 @@
46044604 "tree": "a366a73e74f8d80f106465058083b62bb70b833c721047124e99c89880e5c706"
46054605 }
46064606 ],
4607 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4608 "acceptedAt": "2026-09-15T16:24:17.637Z"
4607 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4608 "acceptedAt": "2026-09-15T16:28:31.080Z"
46094609 },
46104610 {
46114611 "type": "VERSION_CONFLICT",
……@@ -4619,8 +4619,8 @@
46194619 "tree": "7912e790c9e87e9829a6daa037c5f5da0cc4355214f51d334f96e57678dfddeb"
46204620 }
46214621 ],
4622 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4623 "acceptedAt": "2026-09-15T16:24:17.637Z"
4622 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4623 "acceptedAt": "2026-09-15T16:28:31.080Z"
46244624 },
46254625 {
46264626 "type": "VERSION_CONFLICT",
……@@ -4634,8 +4634,8 @@
46344634 "tree": "003ac841449fcdda1abc3c87fee291a5d9596d245925a7298bd8ef5f8e423eae"
46354635 }
46364636 ],
4637 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4638 "acceptedAt": "2026-09-15T16:24:17.637Z"
4637 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4638 "acceptedAt": "2026-09-15T16:28:31.080Z"
46394639 },
46404640 {
46414641 "type": "VERSION_CONFLICT",
……@@ -4654,8 +4654,8 @@
46544654 "tree": "7b3f314cf92558edbc90295c573885276a6add925318998c829c03d335edc765"
46554655 }
46564656 ],
4657 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4658 "acceptedAt": "2026-09-15T16:24:17.637Z"
4657 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4658 "acceptedAt": "2026-09-15T16:28:31.080Z"
46594659 },
46604660 {
46614661 "type": "VERSION_CONFLICT",
……@@ -4669,8 +4669,8 @@
46694669 "tree": "c486fe73805bf013e9c5de91b5780d1aa5d2b2295306f411f7863757aaa05e60"
46704670 }
46714671 ],
4672 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4673 "acceptedAt": "2026-09-15T16:24:17.637Z"
4672 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4673 "acceptedAt": "2026-09-15T16:28:31.080Z"
46744674 },
46754675 {
46764676 "type": "VERSION_CONFLICT",
……@@ -4684,8 +4684,8 @@
46844684 "tree": "10193a86e2c6f80c2d4377379a0aae4a959f8a40fd9e852b85fee081bfe6675a"
46854685 }
46864686 ],
4687 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4688 "acceptedAt": "2026-09-15T16:24:17.637Z"
4687 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4688 "acceptedAt": "2026-09-15T16:28:31.080Z"
46894689 },
46904690 {
46914691 "type": "VERSION_CONFLICT",
……@@ -4699,8 +4699,8 @@
46994699 "tree": "20b1ecd2937f3e35102892288234b86a8ec6deb3711016d96ff3509474ac465b"
47004700 }
47014701 ],
4702 "reason": "0.10.7: same 45 version-splits/docs/packaging, 0 code-differs",
4703 "acceptedAt": "2026-09-15T16:24:17.637Z"
4702 "reason": "0.10.8: same 45 version-splits/docs/packaging, 0 code-differs",
4703 "acceptedAt": "2026-09-15T16:28:31.080Z"
47044704 }
47054705 ]
47064706}
index.js +7 -0
……@@ -16,6 +16,7 @@ import { createServer } from './lib/server.js'
1616import { actorDocFor } from './lib/router.js'
1717import { FollowerStore } from './lib/followers.js'
1818import { InboundStore } from './lib/inbound-store.js'
19import { ObservationStore } from './lib/observations.js'
1920import { RemoteKeyResolver } from './lib/remote-key.js'
2021import { createDeliverer } from './lib/deliver.js'
2122import { LocalSigner } from './lib/signer.js'
……@@ -80,6 +81,11 @@ async function main () {
8081 // ⛔ THE CAP IS THE OPERATOR'S. It was fixed at 500 here, and for the fediverse an eviction is not a
8182 // cache miss — there is no backlink index to re-ask, so it can be the only copy. See config.js.
8283 const inbound = new InboundStore({ file: join(dataDir, 'inbound.json'), cap: config.inboundCap })
84 // ⛔ THE DURABLE HALF OF THE SAME ARRIVAL. `inbound` is a capped timeline for THIS bridge's pages;
85 // an observation is what a registered PEER collects and keeps. Both are written from one inbox POST
86 // and they have different lifetimes, which is why they are two stores rather than one with a flag —
87 // see hyper-social/docs/OBSERVED-INTERACTIONS.md §9.
88 const observations = new ObservationStore({ path: join(dataDir, 'observations.jsonl') })
8389 const resolver = new RemoteKeyResolver()
8490
8591 // ⛔ CUSTODY IS NAMED HERE, not implied by passing a key around. This is the primary actor and it
……@@ -146,6 +152,7 @@ async function main () {
146152 publicKeyPem,
147153 followers,
148154 inbound,
155 observations,
149156 resolvePublicKey: (kid) => resolver.resolve(kid),
150157 deliver,
151158 // Activity ids must be unique and stable enough that a receiver can dedup. Timestamp+counter
lib/observations.js +138 -0
……@@ -0,0 +1,138 @@
1// observations.js — what other people did to a peer's records, held until that peer collects it.
2//
3// ⛔ AN OBSERVATION IS NOT AN EDGE, and the distinction is the whole design. An edge a favela peer
4// authors is a record in THEIR feed and needs nothing here. A like from a Mastodon or Bluesky account
5// is not ours to author: writing one into somebody's feed would be authoring as them, which
6// hyper-social #61 §4 removed the room primitive to avoid. So what this holds is weaker and honest —
7// *we observed that this actor did this to this record of yours, at this time*.
8//
9// ⛔ THIS BRIDGE ACCUMULATES AND SERVES. THE PEER ASKS AND STORES. We hold a registered peer's feed
10// and are NOT a writer on it, so we never write their pointer or their segments. This is a buffer
11// that gets drained, not an archive — see OBSERVED-INTERACTIONS.md §9.
12//
13// ⚠ WHICH IS WHY THE CODEC IS NOT HERE. `favela-web/lib/interaction-codec.js` packs 50,000
14// interactions into 0.50 MB by interning actors and delta-encoding time, and that earns its place
15// where data is kept for ever — on the PEER. Compressing a buffer that is drained and pruned would be
16// symmetry for its own sake, and it would make this file harder to read at a debugger.
17//
18// ⚠ M1 SCOPE: append-only JSONL, matching followers.js and inbound-store.js rather than inventing a
19// third storage pattern for the same lifetime of data. A row is ~120 bytes and this is a serving
20// buffer, so the size that matters is bounded by the cap below, not by the network's enthusiasm.
21//
22// ⛔ SCOPED BY RECIPIENT, ALWAYS. One store serves every registered identity on this bridge, and an
23// observation belongs to exactly one of them. `recipient` is the field added to the inbound store on
24// 2026-09-15 for precisely this reason: without it a per-peer read is the only thing standing between
25// one identity's social graph and another's, and that is too much weight for a transport check.
26
27import fs from 'bare-fs'
28import { dirname, join } from 'bare-path'
29
30// Kinds, as small integers because they are written once per row. Tombstones are their own kind
31// rather than a flag: `interaction.md` §4.3 makes reversal positive evidence everywhere else, because
32// absence is ambiguous in a replicated log and "they un-liked" must not read as "we never saw it".
33export const KIND = { LIKE: 0, REPOST: 1, FOLLOW: 2, REPLY: 3, UNDO: 4 }
34
35// Bounded, for the same reason inbound-store.js is: whatever we accept, somebody else decides how
36// much of it there is. A serving buffer that grows without limit is a disk-exhaustion bug with a
37// signature check in front of it.
38export const DEFAULT_CAP = 20_000
39
40export class ObservationStore {
41 constructor ({ path, cap = DEFAULT_CAP } = {}) {
42 if (!path) throw new Error('ObservationStore: a path is required')
43 this._path = path
44 this._cap = Math.max(1, cap)
45 this._rows = null
46 this._seq = 0
47 }
48
49 _load () {
50 if (this._rows) return
51 this._rows = []
52 let raw = ''
53 try { raw = fs.readFileSync(this._path, 'utf8') } catch { raw = '' }
54 for (const line of raw.split('\n')) {
55 if (!line.trim()) continue
56 // ⚠ A single corrupt line must not cost the whole file. An append-only log written by a process
57 // that can be killed at any moment will eventually have a torn tail.
58 try {
59 const row = JSON.parse(line)
60 if (row && typeof row.seq === 'number') {
61 this._rows.push(row)
62 if (row.seq > this._seq) this._seq = row.seq
63 }
64 } catch { /* torn or partial — skip it, keep the rest */ }
65 }
66 }
67
68 _flush () {
69 try {
70 fs.mkdirSync(dirname(this._path), { recursive: true })
71 fs.writeFileSync(this._path, this._rows.map((r) => JSON.stringify(r)).join('\n') + '\n')
72 } catch { /* a buffer that cannot be persisted still serves from memory this run */ }
73 }
74
75 /**
76 * Record one observation. Returns the row, or null when it names nothing we can serve.
77 *
78 * ⛔ REFUSED RATHER THAN STORED when there is no recipient or no actor: a row nobody can be given
79 * and nobody can be attributed to is not a weaker observation, it is noise that will be read later
80 * as data.
81 */
82 note ({ recipient, subject = null, actor, kind = KIND.LIKE, at = Date.now(), id = null } = {}) {
83 if (typeof recipient !== 'string' || !recipient) return null
84 if (typeof actor !== 'string' || !actor) return null
85 this._load()
86
87 // ⚠ DEDUPED BY id WHEN THERE IS ONE. A remote server retrying delivery is not a second like, and
88 // the inbox already treats a repeat as a duplicate rather than an event.
89 if (id && this._rows.some((r) => r.id === id)) return null
90
91 const row = {
92 seq: ++this._seq,
93 recipient,
94 subject,
95 actor,
96 kind: (kind | 0),
97 at: Number(at) || Date.now(),
98 id: id || null
99 }
100 this._rows.push(row)
101 // Oldest first out: a peer that has been away for longer than the cap re-collects from whatever
102 // survives, and the alternative — refusing new rows when full — loses the ones we just learned.
103 if (this._rows.length > this._cap) this._rows = this._rows.slice(this._rows.length - this._cap)
104 this._flush()
105 return row
106 }
107
108 /**
109 * What one peer has not collected yet.
110 *
111 * ⛔ `recipient` IS REQUIRED and there is deliberately no "everything" call. A store that could
112 * return every identity's observations would make the door's authorisation the only thing standing
113 * between one peer's social graph and another's.
114 */
115 list ({ recipient, since = 0, limit = 500 } = {}) {
116 if (typeof recipient !== 'string' || !recipient) return { rows: [], cursor: since || 0, more: false }
117 this._load()
118 const after = Number(since) || 0
119 const mine = this._rows.filter((r) => r.recipient === recipient && r.seq > after)
120 const n = Math.max(1, Math.min(Number(limit) || 500, 1000))
121 const rows = mine.slice(0, n)
122 return {
123 rows,
124 // ⚠ The cursor advances only over rows actually returned. Advancing it past a page we did not
125 // send would silently skip observations, which is the failure this whole area keeps producing.
126 cursor: rows.length ? rows[rows.length - 1].seq : after,
127 more: mine.length > rows.length
128 }
129 }
130
131 /** How many are held for one peer — the cheap answer, with no rows moved. */
132 count ({ recipient } = {}) {
133 this._load()
134 return this._rows.filter((r) => !recipient || r.recipient === recipient).length
135 }
136}
137
138export default { ObservationStore, KIND, DEFAULT_CAP }
lib/router.js +59 -0
……@@ -14,6 +14,7 @@ import {
1414} from './actor.js'
1515import { verifyRequest } from './http-signature.js'
1616import { normalize } from './inbound-store.js'
17import { KIND as OBS } from './observations.js'
1718
1819const json = (status, doc, type = 'application/json') => ({
1920 status,
……@@ -520,6 +521,18 @@ async function onFollow (activity, actorUri, ctx) {
520521
521522 const added = await ctx.followers.add(actorUri)
522523
524 // ⛔ AND IT IS AN OBSERVATION. A Follow is dispatched HERE, never through onInbound, so noting it
525 // there alone recorded likes and boosts and silently lost every follow — caught by the test that
526 // asserts all three land, which is why that test names all three rather than one.
527 if (ctx.observations) {
528 const recipient = (ctx.user && ctx.user.username) || (ctx.config && ctx.config.username) || null
529 if (recipient) {
530 try {
531 ctx.observations.note({ recipient, subject: null, actor: actorUri, kind: OBS.FOLLOW, at: Date.now(), id: activity.id })
532 } catch (err) { ctx.log?.({ msg: 'inbox: follow not recorded', actor: actorUri, error: err.message }) }
533 }
534 }
535
523536 // ⛔ A NEW FOLLOWER IS A REASON TO DRAIN. Posts made while nobody was following are HELD rather
524537 // than recorded as delivered (publisher.js `_send`), which only means something if somebody
525538 // eventually goes and sends them. Without this the held posts sit until an unrelated future post
……@@ -567,6 +580,13 @@ async function onInbound (activity, actorUri, ctx) {
567580 const recipient = (ctx.user && ctx.user.username) || (ctx.config && ctx.config.username) || null
568581 const item = normalize(activity, Date.now(), recipient)
569582 const added = await ctx.inbound.add(item)
583 // ⛔ AND AS AN OBSERVATION, WHICH IS THE DURABLE HALF. The inbound store is a capped timeline for
584 // this bridge's own pages; an observation is what a PEER collects and keeps. Same arrival, two
585 // consumers with different lifetimes — see OBSERVED-INTERACTIONS.md §9.
586 //
587 // ⚠ NEVER FAILS THE POST. We owe a prompt 202; a storage problem is ours, and a 5xx here makes the
588 // sender retry for ever and eventually back off delivery to us entirely.
589 if (added) noteObservation(activity, actorUri, recipient, item, ctx)
570590 // A retry is not a second event; say which happened so a duplicate storm is visible.
571591 ctx.log?.({ msg: added ? 'inbox: kept' : 'inbox: duplicate', type: activity.type, actor: actorUri, inReplyTo: item.inReplyTo || undefined })
572592 } catch (err) {
……@@ -591,6 +611,29 @@ async function onDelete (activity, actorUri, ctx) {
591611 return { status: 202, headers: {}, body: '' }
592612}
593613
614// One inbound activity → one observation, or none.
615//
616// ⛔ ONLY WHAT WE HAVE WORDS FOR. An activity type with no kind is not recorded as the nearest
617// familiar one — a row read later as data has to mean what it says.
618function noteObservation (activity, actorUri, recipient, item, ctx) {
619 if (!ctx.observations || !recipient || !actorUri) return
620 const type = activity && activity.type
621 let kind = null
622 let subject = null
623
624 if (type === 'Like') { kind = OBS.LIKE; subject = item.objectId }
625 else if (type === 'Announce') { kind = OBS.REPOST; subject = item.objectId }
626 else if (type === 'Follow') { kind = OBS.FOLLOW; subject = null }
627 else if ((type === 'Create' || type === 'Update') && item.inReplyTo) { kind = OBS.REPLY; subject = item.inReplyTo }
628 if (kind === null) return
629
630 try {
631 ctx.observations.note({ recipient, subject: subject || null, actor: actorUri, kind, at: Date.parse(item.published || '') || Date.now(), id: item.id })
632 } catch (err) {
633 ctx.log?.({ msg: 'inbox: observation not recorded', type, actor: actorUri, error: err.message })
634 }
635}
636
594637async function onUndo (activity, actorUri, ctx) {
595638 const inner = activity.object
596639 const innerType = typeof inner === 'object' ? inner?.type : null
……@@ -598,6 +641,22 @@ async function onUndo (activity, actorUri, ctx) {
598641 await ctx.followers.remove(actorUri)
599642 ctx.log?.({ msg: 'inbox: unfollowed', actor: actorUri })
600643 }
644 // ⛔ A RETRACTION IS POSITIVE EVIDENCE, never an absence. interaction.md §4.3: absence is ambiguous
645 // in a replicated log, so "they un-liked" must not be indistinguishable from "we never saw it".
646 // The reader decides what a later UNDO means; the store does not silently forget the earlier row.
647 const recipient = (ctx.user && ctx.user.username) || (ctx.config && ctx.config.username) || null
648 if (ctx.observations && recipient && actorUri) {
649 try {
650 ctx.observations.note({
651 recipient,
652 subject: typeof inner === 'object' ? (inner.object && (inner.object.id || inner.object)) || null : null,
653 actor: actorUri,
654 kind: OBS.UNDO,
655 at: Date.now(),
656 id: activity && activity.id ? activity.id : null
657 })
658 } catch (err) { ctx.log?.({ msg: 'inbox: undo not recorded', actor: actorUri, error: err.message }) }
659 }
601660 return { status: 202, headers: {}, body: '' }
602661}
603662
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.8",
3 "version": "0.10.9",
44 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/observations.test.js +110 -0
……@@ -0,0 +1,110 @@
1// Observations — held until the peer collects them, and never mixed between peers.
2//
3// ⛔ THE ISOLATION IS THE SAFETY PROPERTY, exactly as it is for the inbound store. One bridge serves
4// every registered identity, and an observation belongs to precisely one of them. A read that could
5// return another peer's rows would put the door's authorisation between one person's social graph and
6// another's, which is too much weight for a transport check to carry.
7
8import test from 'brittle'
9import fs from 'bare-fs'
10import os from 'bare-os'
11import { join } from 'bare-path'
12import { ObservationStore, KIND } from '../lib/observations.js'
13
14const tmp = (t) => {
15 const d = fs.mkdtempSync(join(os.tmpdir(), 'obs-'))
16 t.teardown(() => { try { fs.rmSync(d, { recursive: true, force: true }) } catch {} })
17 return join(d, 'observations.jsonl')
18}
19
20const ALICE = 'https://mastodon.social/users/alice'
21const BOB = 'did:plc:wj62ebbusln6gxo5azo6ogzo'
22
23test('an observation is recorded and read back by its recipient', (t) => {
24 const s = new ObservationStore({ path: tmp(t) })
25 s.note({ recipient: 'fleeky', subject: 'https://ap.7rnx.net/ap/objects/1', actor: ALICE, kind: KIND.LIKE, at: 1000 })
26
27 const r = s.list({ recipient: 'fleeky' })
28 t.is(r.rows.length, 1)
29 t.is(r.rows[0].actor, ALICE)
30 t.is(r.rows[0].kind, KIND.LIKE)
31 t.is(r.cursor, 1, 'the cursor is the seq of the last row returned')
32})
33
34test('★★★ one peer never sees another peer\'s observations', (t) => {
35 const s = new ObservationStore({ path: tmp(t) })
36 s.note({ recipient: 'fleeky', subject: 'o1', actor: ALICE, at: 1 })
37 s.note({ recipient: 'ashatron', subject: 'o2', actor: BOB, at: 2 })
38
39 t.is(s.list({ recipient: 'fleeky' }).rows.length, 1)
40 t.is(s.list({ recipient: 'ashatron' }).rows[0].actor, BOB)
41 // ⛔ And there is no call that returns both.
42 t.is(s.list({}).rows.length, 0, 'a read with no recipient returns nothing rather than everything')
43})
44
45test('★★ the cursor advances only over rows actually returned', (t) => {
46 const s = new ObservationStore({ path: tmp(t) })
47 for (let i = 0; i < 5; i++) s.note({ recipient: 'fleeky', subject: 'o', actor: ALICE + i, at: i })
48
49 const first = s.list({ recipient: 'fleeky', limit: 2 })
50 t.is(first.rows.length, 2)
51 t.ok(first.more, 'it says there is more')
52 const second = s.list({ recipient: 'fleeky', since: first.cursor, limit: 2 })
53 t.is(second.rows[0].actor, ALICE + '2', 'resumes exactly where the last page ended — no gap, no repeat')
54 const rest = s.list({ recipient: 'fleeky', since: second.cursor })
55 t.is(rest.rows.length, 1)
56 t.absent(rest.more)
57})
58
59test('⛔ a retry is not a second like', (t) => {
60 const s = new ObservationStore({ path: tmp(t) })
61 const a = s.note({ recipient: 'fleeky', subject: 'o', actor: ALICE, id: 'act-1', at: 1 })
62 const b = s.note({ recipient: 'fleeky', subject: 'o', actor: ALICE, id: 'act-1', at: 1 })
63 t.ok(a)
64 t.is(b, null, 'the duplicate is refused')
65 t.is(s.count({ recipient: 'fleeky' }), 1)
66})
67
68test('⛔ a row nobody can be given, or attributed to, is refused', (t) => {
69 const s = new ObservationStore({ path: tmp(t) })
70 // Noise that will be read later as data is worse than a missing row.
71 t.is(s.note({ subject: 'o', actor: ALICE }), null, 'no recipient')
72 t.is(s.note({ recipient: 'fleeky', subject: 'o' }), null, 'no actor')
73 t.is(s.count({}), 0)
74})
75
76test('an undo is its own kind, not an absence', (t) => {
77 const s = new ObservationStore({ path: tmp(t) })
78 s.note({ recipient: 'fleeky', subject: 'o', actor: ALICE, kind: KIND.LIKE, at: 1 })
79 s.note({ recipient: 'fleeky', subject: 'o', actor: ALICE, kind: KIND.UNDO, at: 2 })
80 const rows = s.list({ recipient: 'fleeky' }).rows
81 // ⛔ Absence is ambiguous in a replicated log, so a retraction is positive evidence
82 // (interaction.md §4.3) — the reader decides, the store does not silently forget.
83 t.alike(rows.map((r) => r.kind), [KIND.LIKE, KIND.UNDO])
84})
85
86test('⚠ bounded, oldest out — a full buffer must not refuse what we just learned', (t) => {
87 const s = new ObservationStore({ path: tmp(t), cap: 3 })
88 for (let i = 0; i < 5; i++) s.note({ recipient: 'fleeky', subject: 'o', actor: ALICE + i, at: i })
89 const rows = s.list({ recipient: 'fleeky' }).rows
90 t.is(rows.length, 3)
91 t.is(rows[0].actor, ALICE + '2', 'the oldest were dropped, not the newest refused')
92})
93
94test('it survives a restart, and a torn tail does not cost the file', (t) => {
95 const path = tmp(t)
96 const s = new ObservationStore({ path })
97 s.note({ recipient: 'fleeky', subject: 'o', actor: ALICE, at: 1 })
98 s.note({ recipient: 'fleeky', subject: 'o', actor: BOB, at: 2 })
99
100 // A process killed mid-append leaves half a line. An append-only log will eventually have one.
101 fs.appendFileSync(path, '{"seq":99,"recipient":"fle')
102
103 const reopened = new ObservationStore({ path })
104 const rows = reopened.list({ recipient: 'fleeky' }).rows
105 t.is(rows.length, 2, 'the intact rows survive')
106 t.is(rows[1].actor, BOB)
107 // And the next write continues the sequence rather than colliding with what was read.
108 const next = reopened.note({ recipient: 'fleeky', subject: 'o', actor: 'x', at: 3 })
109 t.is(next.seq, 3)
110})
test/router.test.js +62 -0
……@@ -495,3 +495,65 @@ test('★★ /timeline is the COMBINED view — every peer this bridge serves, i
495495 t.ok(/from p2p/.test(mine.body))
496496 t.absent(/from fleeky/.test(mine.body), "p2p's page is p2p's alone")
497497})
498
499// ── inbound interactions become observations ──────────────────────────────────────────────────────
500//
501// ⛔ The inbound store is a CAPPED timeline for this bridge's own pages. An observation is what a PEER
502// collects and keeps. Same arrival, two consumers with different lifetimes — and the durable half is
503// the one that was missing, which is why a like from a stranger was invisible on a handset.
504
505test('★★★ a Like, an Announce and a Follow each land as an observation, scoped to the recipient', async (t) => {
506 const { ObservationStore, KIND } = await import('../lib/observations.js')
507 const fs = await import('bare-fs')
508 const os = await import('bare-os')
509 const { join } = await import('bare-path')
510 const dir = fs.mkdtempSync(join(os.tmpdir(), 'obs-router-'))
511 t.teardown(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} })
512
513 const observations = new ObservationStore({ path: join(dir, 'o.jsonl') })
514 const ctx = makeCtx({ inbound: new MemoryInboundStore(), observations })
515
516 const act = (type, over = {}) => ({
517 '@context': 'https://www.w3.org/ns/activitystreams',
518 id: `${REMOTE}/a/${type}`, type, actor: REMOTE,
519 object: 'https://p2p.7rnx.net/ap/objects/1', ...over
520 })
521 await handleRequest(signedActivity(act('Like')), ctx)
522 await handleRequest(signedActivity(act('Announce', { id: `${REMOTE}/a/Announce2` })), ctx)
523 await handleRequest(signedActivity(act('Follow', { id: `${REMOTE}/a/Follow2` })), ctx)
524
525 const rows = observations.list({ recipient: 'p2p' }).rows
526 t.alike(rows.map((r) => r.kind), [KIND.LIKE, KIND.REPOST, KIND.FOLLOW], 'all three, in words we have')
527 t.is(rows[0].actor, REMOTE, 'attributed to who did it')
528 t.is(rows[2].subject, null, 'a follow acts on you, not on a post')
529 // ⛔ And nobody else's page can read them.
530 t.is(observations.list({ recipient: 'someone-else' }).rows.length, 0)
531})
532
533test('⛔ an activity type we have no kind for is NOT recorded as the nearest one', async (t) => {
534 const { ObservationStore } = await import('../lib/observations.js')
535 const fs = await import('bare-fs'); const os = await import('bare-os'); const { join } = await import('bare-path')
536 const dir = fs.mkdtempSync(join(os.tmpdir(), 'obs-router2-'))
537 t.teardown(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} })
538
539 const observations = new ObservationStore({ path: join(dir, 'o.jsonl') })
540 const ctx = makeCtx({ inbound: new MemoryInboundStore(), observations })
541 await handleRequest(signedActivity({
542 '@context': 'https://www.w3.org/ns/activitystreams',
543 id: `${REMOTE}/a/flag`, type: 'Flag', actor: REMOTE, object: 'https://p2p.7rnx.net/ap/objects/1'
544 }), ctx)
545
546 // A row read later as data has to mean what it says.
547 t.is(observations.count({}), 0, 'dropped rather than guessed at')
548})
549
550test('⚠ a bridge with no observation store behaves exactly as before', async (t) => {
551 const inbound = new MemoryInboundStore()
552 const ctx = makeCtx({ inbound })
553 const res = await handleRequest(signedActivity({
554 '@context': 'https://www.w3.org/ns/activitystreams',
555 id: `${REMOTE}/a/like-noobs`, type: 'Like', actor: REMOTE, object: 'https://p2p.7rnx.net/ap/objects/1'
556 }), ctx)
557 t.is(res.status, 202, 'still acked promptly')
558 t.is((await inbound.list({})).length, 1, 'and still kept in the inbound timeline')
559})