| … | … | @@ -1,9 +1,9 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.0", | |
| 3 | "version": "0.10.1", | |
| 4 | 4 | "runtime": "bare", |
| 5 | "rootTreeHash": "ff8be560c36e2d327015ad0a6915bab3c821b12cc81a75f2eff14fb91ae6591b", | |
| 6 | "publishedAt": "2026-09-12T00:37:22.222Z", | |
| 5 | "rootTreeHash": "075afb44758c6de5a10bf35a819c5207733e68b1c791227da992867c9cd48832", | |
| 6 | "publishedAt": "2026-09-12T00:46:51.247Z", | |
| 7 | 7 | "deps": [ |
| 8 | 8 | { |
| 9 | 9 | "name": "@hyperswarm/secret-stream", |
| … | … | @@ -4004,8 +4004,8 @@ |
| 4004 | 4004 | "tree": "38f44af48b2d59b46f1b5db31b81d02f3ca8b8bad6bc0fb9fffff698241f5e37" |
| 4005 | 4005 | } |
| 4006 | 4006 | ], |
| 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" | |
| 4009 | 4009 | }, |
| 4010 | 4010 | { |
| 4011 | 4011 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4019,8 +4019,8 @@ |
| 4019 | 4019 | "tree": "ee91b6bd77101e3404ca571b2e5adf0f47029d174398a05f50294fc207e39d99" |
| 4020 | 4020 | } |
| 4021 | 4021 | ], |
| 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" | |
| 4024 | 4024 | }, |
| 4025 | 4025 | { |
| 4026 | 4026 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4034,8 +4034,8 @@ |
| 4034 | 4034 | "tree": "3bb78d8f06263a549bd1077f37a7df257b1d557e44608631435af625f05a9c11" |
| 4035 | 4035 | } |
| 4036 | 4036 | ], |
| 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" | |
| 4039 | 4039 | }, |
| 4040 | 4040 | { |
| 4041 | 4041 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4054,8 +4054,8 @@ |
| 4054 | 4054 | "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d" |
| 4055 | 4055 | } |
| 4056 | 4056 | ], |
| 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" | |
| 4059 | 4059 | }, |
| 4060 | 4060 | { |
| 4061 | 4061 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4069,8 +4069,8 @@ |
| 4069 | 4069 | "tree": "da4c6b2654e9d66f10b0faa989311cf182878c24ab51b1c41458cfbf37ad29cd" |
| 4070 | 4070 | } |
| 4071 | 4071 | ], |
| 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" | |
| 4074 | 4074 | }, |
| 4075 | 4075 | { |
| 4076 | 4076 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4084,8 +4084,8 @@ |
| 4084 | 4084 | "tree": "40be8f559a13b54ebf0689e63e6c70f0d218035359e17c497ed7bc8a85c3cc95" |
| 4085 | 4085 | } |
| 4086 | 4086 | ], |
| 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" | |
| 4089 | 4089 | }, |
| 4090 | 4090 | { |
| 4091 | 4091 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4099,8 +4099,8 @@ |
| 4099 | 4099 | "tree": "3eae85efce82299769898c8151b5ed0a94e5a5ebdbcdee2105b815ca733a61cb" |
| 4100 | 4100 | } |
| 4101 | 4101 | ], |
| 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" | |
| 4104 | 4104 | }, |
| 4105 | 4105 | { |
| 4106 | 4106 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4114,8 +4114,8 @@ |
| 4114 | 4114 | "tree": "8d9e01d443a127781773598d2e77c3f372334fad09e20623a819dabe49b22e1a" |
| 4115 | 4115 | } |
| 4116 | 4116 | ], |
| 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" | |
| 4119 | 4119 | }, |
| 4120 | 4120 | { |
| 4121 | 4121 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4129,8 +4129,8 @@ |
| 4129 | 4129 | "tree": "dc1ad6c999fe90d946ba0ef34698ea9af451b639be2ad1c5b61eabb08337a6ca" |
| 4130 | 4130 | } |
| 4131 | 4131 | ], |
| 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" | |
| 4134 | 4134 | }, |
| 4135 | 4135 | { |
| 4136 | 4136 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4144,8 +4144,8 @@ |
| 4144 | 4144 | "tree": "88b4779570e5113aa21b204989ac018d1628b387681f7e53428a7c99114fb701" |
| 4145 | 4145 | } |
| 4146 | 4146 | ], |
| 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" | |
| 4149 | 4149 | }, |
| 4150 | 4150 | { |
| 4151 | 4151 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4159,8 +4159,8 @@ |
| 4159 | 4159 | "tree": "720b867ec4300a3caa2325eb1449499f9940bfb88341ecceb5992fa0fdf5f37d" |
| 4160 | 4160 | } |
| 4161 | 4161 | ], |
| 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" | |
| 4164 | 4164 | }, |
| 4165 | 4165 | { |
| 4166 | 4166 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4174,8 +4174,8 @@ |
| 4174 | 4174 | "tree": "b697fe174482a708b49d0764cd4786291845f0034a31e0699381ca7e7b269d04" |
| 4175 | 4175 | } |
| 4176 | 4176 | ], |
| 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" | |
| 4179 | 4179 | }, |
| 4180 | 4180 | { |
| 4181 | 4181 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4189,8 +4189,8 @@ |
| 4189 | 4189 | "tree": "767f07242277ae80bd395de5a4969fcdbf956c216158ae517fb2b562558ac1a4" |
| 4190 | 4190 | } |
| 4191 | 4191 | ], |
| 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" | |
| 4194 | 4194 | }, |
| 4195 | 4195 | { |
| 4196 | 4196 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4204,8 +4204,8 @@ |
| 4204 | 4204 | "tree": "de7deed21f587ffc8bde4971abe00fca77bb6c71c4e7effb31ad34ee330815df" |
| 4205 | 4205 | } |
| 4206 | 4206 | ], |
| 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" | |
| 4209 | 4209 | }, |
| 4210 | 4210 | { |
| 4211 | 4211 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4219,8 +4219,8 @@ |
| 4219 | 4219 | "tree": "2df42517ebf73cd9c11f2f652f75e87401fd4ed7b856c0adc15c33baf1d4f1ad" |
| 4220 | 4220 | } |
| 4221 | 4221 | ], |
| 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" | |
| 4224 | 4224 | }, |
| 4225 | 4225 | { |
| 4226 | 4226 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4234,8 +4234,8 @@ |
| 4234 | 4234 | "tree": "325aca171cbff560487177584078c8066133e91176639ca47c98d70e195aa92e" |
| 4235 | 4235 | } |
| 4236 | 4236 | ], |
| 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" | |
| 4239 | 4239 | }, |
| 4240 | 4240 | { |
| 4241 | 4241 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4264,8 +4264,8 @@ |
| 4264 | 4264 | "tree": "89a40400106bf4fef95fb5c44a66ee29aada6f76c7740bfba09d8aeeb841f39c" |
| 4265 | 4265 | } |
| 4266 | 4266 | ], |
| 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" | |
| 4269 | 4269 | }, |
| 4270 | 4270 | { |
| 4271 | 4271 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4279,8 +4279,8 @@ |
| 4279 | 4279 | "tree": "1bd0eb382789c62cd4622d47bffb60984fdd766260c79f73a2fc7a136f3edbe9" |
| 4280 | 4280 | } |
| 4281 | 4281 | ], |
| 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" | |
| 4284 | 4284 | }, |
| 4285 | 4285 | { |
| 4286 | 4286 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4294,8 +4294,8 @@ |
| 4294 | 4294 | "tree": "4526fe9dc5468264c7171ec16e9303726674b7b44ca0b3e09cb8b9a6a1be6169" |
| 4295 | 4295 | } |
| 4296 | 4296 | ], |
| 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" | |
| 4299 | 4299 | }, |
| 4300 | 4300 | { |
| 4301 | 4301 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4309,8 +4309,8 @@ |
| 4309 | 4309 | "tree": "32b291cd756e142da18a6ed01c351e024fcedc3e2b1ecfbfb85f3106ada4419b" |
| 4310 | 4310 | } |
| 4311 | 4311 | ], |
| 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" | |
| 4314 | 4314 | }, |
| 4315 | 4315 | { |
| 4316 | 4316 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4324,8 +4324,8 @@ |
| 4324 | 4324 | "tree": "4a8cd3b0093092b33088617dc0415e8179882d7f2f13b7649962ca20b08beeed" |
| 4325 | 4325 | } |
| 4326 | 4326 | ], |
| 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" | |
| 4329 | 4329 | }, |
| 4330 | 4330 | { |
| 4331 | 4331 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4339,8 +4339,8 @@ |
| 4339 | 4339 | "tree": "4f63f165ee6a581f689b40c4a40df4df01ab6fba17d46bd29e40ef5bbdd9884f" |
| 4340 | 4340 | } |
| 4341 | 4341 | ], |
| 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" | |
| 4344 | 4344 | }, |
| 4345 | 4345 | { |
| 4346 | 4346 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4354,8 +4354,8 @@ |
| 4354 | 4354 | "tree": "735109c736c0bd3413ab47414fd8b6ea9a99c401fe8e6d608459c79293806754" |
| 4355 | 4355 | } |
| 4356 | 4356 | ], |
| 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" | |
| 4359 | 4359 | }, |
| 4360 | 4360 | { |
| 4361 | 4361 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4369,8 +4369,8 @@ |
| 4369 | 4369 | "tree": "b32b75bf1b2acdd18ed0cc370d01d477fba42ba09fd1f2f78af3ebad925e72d5" |
| 4370 | 4370 | } |
| 4371 | 4371 | ], |
| 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" | |
| 4374 | 4374 | }, |
| 4375 | 4375 | { |
| 4376 | 4376 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4389,8 +4389,8 @@ |
| 4389 | 4389 | "tree": "189397178121e9a18430943345dab820691648a324229071ceb980bbb77a7393" |
| 4390 | 4390 | } |
| 4391 | 4391 | ], |
| 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" | |
| 4394 | 4394 | }, |
| 4395 | 4395 | { |
| 4396 | 4396 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4404,8 +4404,8 @@ |
| 4404 | 4404 | "tree": "3bedca5287a9041fc2fd5ec5ace4df1fa86240e6afec0a105ba67398bafc09a5" |
| 4405 | 4405 | } |
| 4406 | 4406 | ], |
| 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" | |
| 4409 | 4409 | }, |
| 4410 | 4410 | { |
| 4411 | 4411 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4419,8 +4419,8 @@ |
| 4419 | 4419 | "tree": "b9ca9b272008bd67672a1b252390e8b527095c46b46cf6b6725a7a187eb038b6" |
| 4420 | 4420 | } |
| 4421 | 4421 | ], |
| 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" | |
| 4424 | 4424 | }, |
| 4425 | 4425 | { |
| 4426 | 4426 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4434,8 +4434,8 @@ |
| 4434 | 4434 | "tree": "7793645d7d4248d6d6533e3cbcd4bf40bed4f38c678b0ca0606c15640ba5e7b9" |
| 4435 | 4435 | } |
| 4436 | 4436 | ], |
| 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" | |
| 4439 | 4439 | }, |
| 4440 | 4440 | { |
| 4441 | 4441 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4449,8 +4449,8 @@ |
| 4449 | 4449 | "tree": "79447513d4ac0357c8d60bdc7b81b3c5143d761b26700340cba266c4d1cb77b0" |
| 4450 | 4450 | } |
| 4451 | 4451 | ], |
| 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" | |
| 4454 | 4454 | }, |
| 4455 | 4455 | { |
| 4456 | 4456 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4464,8 +4464,8 @@ |
| 4464 | 4464 | "tree": "279d4e0447eb79fd75816864ba7f41f2df03080c9da69ea4603fe2900c8d9e53" |
| 4465 | 4465 | } |
| 4466 | 4466 | ], |
| 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" | |
| 4469 | 4469 | }, |
| 4470 | 4470 | { |
| 4471 | 4471 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4479,8 +4479,8 @@ |
| 4479 | 4479 | "tree": "ed4e0b480417610e195ff0b59e5b9a453abbf2db6d6ce72d7cd522269f835e9a" |
| 4480 | 4480 | } |
| 4481 | 4481 | ], |
| 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" | |
| 4484 | 4484 | }, |
| 4485 | 4485 | { |
| 4486 | 4486 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4499,8 +4499,8 @@ |
| 4499 | 4499 | "tree": "a9e07d54e526c838dfe5e27df33936682aefb218f61a45b6c15289243999f648" |
| 4500 | 4500 | } |
| 4501 | 4501 | ], |
| 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" | |
| 4504 | 4504 | }, |
| 4505 | 4505 | { |
| 4506 | 4506 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4514,8 +4514,8 @@ |
| 4514 | 4514 | "tree": "9d84c86441fe138b227c4fd01d1e948aa83bf8d13cfb1aa957838feea05fbb57" |
| 4515 | 4515 | } |
| 4516 | 4516 | ], |
| 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" | |
| 4519 | 4519 | }, |
| 4520 | 4520 | { |
| 4521 | 4521 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4529,8 +4529,8 @@ |
| 4529 | 4529 | "tree": "4a3afbe2fc7a882e24454eb2e7e7cfe6816811ee5d73ae0f92e8b9ea9cfb8c78" |
| 4530 | 4530 | } |
| 4531 | 4531 | ], |
| 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" | |
| 4534 | 4534 | }, |
| 4535 | 4535 | { |
| 4536 | 4536 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4544,8 +4544,8 @@ |
| 4544 | 4544 | "tree": "1e5c5810d8971a3c27fde7fb0e4d633dcabbe2cf98d8df86b473f8d67eead8b9" |
| 4545 | 4545 | } |
| 4546 | 4546 | ], |
| 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" | |
| 4549 | 4549 | }, |
| 4550 | 4550 | { |
| 4551 | 4551 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4559,8 +4559,8 @@ |
| 4559 | 4559 | "tree": "e3478f50e85ba0ac288efed83e84c992483904897dc39ccf243d20a4ea25d972" |
| 4560 | 4560 | } |
| 4561 | 4561 | ], |
| 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" | |
| 4564 | 4564 | }, |
| 4565 | 4565 | { |
| 4566 | 4566 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4574,8 +4574,8 @@ |
| 4574 | 4574 | "tree": "c05d8bf7eab5024ea98140f78c84d12fb494b4b7b10c7eedeb50b2be842fa519" |
| 4575 | 4575 | } |
| 4576 | 4576 | ], |
| 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" | |
| 4579 | 4579 | }, |
| 4580 | 4580 | { |
| 4581 | 4581 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4589,8 +4589,8 @@ |
| 4589 | 4589 | "tree": "0697bb338bbcec517353cffcdb2345a8232b9b90cc627a549a306f0323576390" |
| 4590 | 4590 | } |
| 4591 | 4591 | ], |
| 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" | |
| 4594 | 4594 | }, |
| 4595 | 4595 | { |
| 4596 | 4596 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4604,8 +4604,8 @@ |
| 4604 | 4604 | "tree": "a366a73e74f8d80f106465058083b62bb70b833c721047124e99c89880e5c706" |
| 4605 | 4605 | } |
| 4606 | 4606 | ], |
| 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" | |
| 4609 | 4609 | }, |
| 4610 | 4610 | { |
| 4611 | 4611 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4619,8 +4619,8 @@ |
| 4619 | 4619 | "tree": "7912e790c9e87e9829a6daa037c5f5da0cc4355214f51d334f96e57678dfddeb" |
| 4620 | 4620 | } |
| 4621 | 4621 | ], |
| 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" | |
| 4624 | 4624 | }, |
| 4625 | 4625 | { |
| 4626 | 4626 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4634,8 +4634,8 @@ |
| 4634 | 4634 | "tree": "003ac841449fcdda1abc3c87fee291a5d9596d245925a7298bd8ef5f8e423eae" |
| 4635 | 4635 | } |
| 4636 | 4636 | ], |
| 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" | |
| 4639 | 4639 | }, |
| 4640 | 4640 | { |
| 4641 | 4641 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4654,8 +4654,8 @@ |
| 4654 | 4654 | "tree": "7b3f314cf92558edbc90295c573885276a6add925318998c829c03d335edc765" |
| 4655 | 4655 | } |
| 4656 | 4656 | ], |
| 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" | |
| 4659 | 4659 | }, |
| 4660 | 4660 | { |
| 4661 | 4661 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4669,8 +4669,8 @@ |
| 4669 | 4669 | "tree": "c486fe73805bf013e9c5de91b5780d1aa5d2b2295306f411f7863757aaa05e60" |
| 4670 | 4670 | } |
| 4671 | 4671 | ], |
| 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" | |
| 4674 | 4674 | }, |
| 4675 | 4675 | { |
| 4676 | 4676 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4684,8 +4684,8 @@ |
| 4684 | 4684 | "tree": "10193a86e2c6f80c2d4377379a0aae4a959f8a40fd9e852b85fee081bfe6675a" |
| 4685 | 4685 | } |
| 4686 | 4686 | ], |
| 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" | |
| 4689 | 4689 | }, |
| 4690 | 4690 | { |
| 4691 | 4691 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4699,8 +4699,8 @@ |
| 4699 | 4699 | "tree": "20b1ecd2937f3e35102892288234b86a8ec6deb3711016d96ff3509474ac465b" |
| 4700 | 4700 | } |
| 4701 | 4701 | ], |
| 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" | |
| 4704 | 4704 | } |
| 4705 | 4705 | ] |
| 4706 | 4706 | } |
| … | … | @@ -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. | |
| 25 | const 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 | */ | |
| 40 | export 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 `<script>` readable as text, escaping alone lets a | |
| 54 | // remote server choose this page's layout. | |
| 55 | const textOf = (html) => String(html == null ? '' : html).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() | |
| 56 | ||
| 57 | const 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 | */ | |
| 70 | export 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 | ||
| 161 | export default { buildFeed, handleOf } |
| … | … | @@ -64,7 +64,7 @@ export function isPublicActivity (activity) { |
| 64 | 64 | return false |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | export function normalize (activity, receivedAt) { | |
| 67 | export function normalize (activity, receivedAt, recipient = null) { | |
| 68 | 68 | const obj = activity && typeof activity.object === 'object' ? activity.object : null |
| 69 | 69 | const objectId = obj ? obj.id : (typeof activity?.object === 'string' ? activity.object : null) |
| 70 | 70 | return { |
| … | … | @@ -85,6 +85,16 @@ export function normalize (activity, receivedAt) { |
| 85 | 85 | // it. Anything stored before this field existed is `undefined`, which every reader must treat as |
| 86 | 86 | // NOT public; see the note on isPublicActivity. |
| 87 | 87 | 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, | |
| 88 | 98 | receivedAt |
| 89 | 99 | } |
| 90 | 100 | } |
| … | … | @@ -143,9 +153,13 @@ export class InboundStore { |
| 143 | 153 | * reading, which shows up as a repeated row rather than a lost one. A cursor would be the right |
| 144 | 154 | * answer for a log that grows without limit; this one is capped by construction. |
| 145 | 155 | */ |
| 146 | async list ({ limit = 50, offset = 0 } = {}) { | |
| 156 | async list ({ limit = 50, offset = 0, recipient = null } = {}) { | |
| 147 | 157 | 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) | |
| 149 | 163 | } |
| 150 | 164 | |
| 151 | 165 | /** How many are held, so a page can say whether there is more without fetching it. */ |
| … | … | @@ -168,9 +182,12 @@ export class MemoryInboundStore { |
| 168 | 182 | return before - this.items.length |
| 169 | 183 | } |
| 170 | 184 | |
| 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 } = {}) { | |
| 172 | 188 | 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) | |
| 174 | 191 | } |
| 175 | 192 | |
| 176 | 193 | async count () { return this.items.length } |
| … | … | @@ -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. | |
| 2 | 2 | // |
| 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 | |
| 4 | 4 | // the filter IS the safety property — Mastodon delivers DIRECT MESSAGES to an inbox exactly as it |
| 5 | // delivers replies, through the same handler and into the same store. `normalize()` records `public` at | |
| 6 | // arrival because the addressing cannot be recovered afterwards. | |
| 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. | |
| 7 | 8 | // |
| 8 | 9 | // ⛔ 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 `<script>` | |
| 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 | // `<script>` 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. | |
| 12 | 16 | |
| 13 | 17 | const esc = (s) => String(s == null ? '' : s) |
| 14 | 18 | .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') |
| 15 | 19 | |
| 16 | const STYLE = `body{font:15px/1.6 system-ui,sans-serif;margin:0 auto;padding:32px 20px;max-width:680px;color:#111} | |
| 17 | h1{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} | |
| 21 | a{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}}` | |
| 20 | const STYLE = `*{box-sizing:border-box} | |
| 21 | body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;margin:0 auto; | |
| 22 | padding:0 0 48px;max-width:600px;color:#0f1419;background:#fff} | |
| 23 | header{position:sticky;top:0;background:rgba(255,255,255,.85);backdrop-filter:blur(12px); | |
| 24 | border-bottom:1px solid #eff3f4;padding:14px 16px;z-index:2} | |
| 25 | h1{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; | |
| 30 | display: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; | |
| 39 | border-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} | |
| 42 | nav{display:flex;gap:16px;padding:16px} | |
| 43 | a{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} | |
| 45 | footer{color:#536471;font-size:13px;padding:16px;text-align:center} | |
| 46 | @media(prefers-color-scheme:dark){body{background:#000;color:#e7e9ea} | |
| 47 | header{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}}` | |
| 24 | 50 | |
| 25 | const when = (ms) => { | |
| 26 | const d = new Date(Number(ms)) | |
| 51 | const when = (at) => { | |
| 52 | const d = new Date(at) | |
| 27 | 53 | return Number.isNaN(d.getTime()) ? '' : d.toISOString().replace('T', ' ').slice(0, 16) + 'Z' |
| 28 | 54 | } |
| 29 | 55 | |
| 30 | export 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. | |
| 58 | const 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 | ||
| 64 | function 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 | ||
| 90 | export 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>' | |
| 45 | 94 | |
| 46 | const next = offset + items.length | |
| 95 | const next = offset + rows.length | |
| 47 | 96 | 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>' : '', | |
| 49 | 98 | // ⛔ Only when there is more. A "load more" that returns an empty page is the page lying about |
| 50 | 99 | // 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>' : '' | |
| 52 | 101 | ].filter(Boolean).join('') |
| 53 | 102 | |
| 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>' + | |
| 55 | 104 | '<meta name="viewport" content="width=device-width,initial-scale=1">' + |
| 56 | 105 | // ⚠ Not indexed. This is other people's words republished on our domain; letting a crawler make a |
| 57 | 106 | // second permanent copy of them is a decision nobody here has made. |
| 58 | 107 | '<meta name="robots" content="noindex,nofollow">' + |
| 59 | 108 | '<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>' + | |
| 63 | 112 | list + |
| 64 | 113 | (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>' | |
| 67 | 123 | } |
| 68 | 124 | |
| 69 | 125 | export default { publicTimeline } |
| … | … | @@ -110,7 +110,11 @@ export class RecordSource { |
| 110 | 110 | }) |
| 111 | 111 | // Namespaced key: a like and a post can share an rkey (TIDs are unique per COLLECTION, not |
| 112 | 112 | // 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 }) | |
| 114 | 118 | else unaddressable++ |
| 115 | 119 | } |
| 116 | 120 | } |
| … | … | @@ -41,32 +41,8 @@ export async function handleRequest (req, ctx) { |
| 41 | 41 | // ⚠ ANYTHING STORED BEFORE THAT FIELD EXISTED IS `undefined` AND IS WITHHELD. An operator sees a |
| 42 | 42 | // shorter public page than they expect after upgrading; the alternative is publishing somebody's |
| 43 | 43 | // DM because a record predates the question being asked. |
| 44 | if (path === '/timeline') { | |
| 45 | if (!ctx.inbound) return text(404, 'this bridge keeps no inbound timeline') | |
| 46 | const limit = 50 | |
| 47 | const offset = Math.max(0, Number(query && query.offset) || 0) | |
| 48 | // ⛔ FILTERED BEFORE PAGING, never after: paging first and filtering second silently yields short | |
| 49 | // pages and an offset that no longer means what the link said. | |
| 50 | const all = (await ctx.inbound.list({ limit: 100000, offset: 0 })).filter((i) => i.public === true) | |
| 51 | const { publicTimeline } = await import('./public-timeline.js') | |
| 52 | return html(200, publicTimeline({ | |
| 53 | host: ctx.config.host, | |
| 54 | items: all.slice(offset, offset + limit), | |
| 55 | total: all.length, | |
| 56 | offset, | |
| 57 | limit | |
| 58 | })) | |
| 59 | } | |
| 44 | if (path === '/timeline') return feedPage(query, ctx) | |
| 60 | 45 | |
| 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. | |
| 70 | 46 | if (path === '/ap/register/challenge') { |
| 71 | 47 | if (!ctx.registrations || !ctx.admission) return text(404, 'not found') |
| 72 | 48 | if (method !== 'GET' && method !== 'POST') return text(405, 'GET or POST only') |
| … | … | @@ -137,6 +113,9 @@ export async function handleRequest (req, ctx) { |
| 137 | 113 | if (rest === '/actor') return actor(headers, c) |
| 138 | 114 | if (rest === '/inbox') return method === 'POST' ? inbox(req, c) : text(405, 'POST only') |
| 139 | 115 | 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) | |
| 140 | 119 | if (rest.startsWith('/objects/')) return object(rest, c) |
| 141 | 120 | if (rest.startsWith('/blobs/')) return blob(rest, c) |
| 142 | 121 | if (rest === '/followers') return followers(c) |
| … | … | @@ -330,6 +309,70 @@ async function collection (id, ctx) { |
| 330 | 309 | // mapped on demand (DESIGN.md §3) — the same bytes the atproto side derives its MST from. When |
| 331 | 310 | // there is no record source configured the collection is empty rather than an error: a bridge |
| 332 | 311 | // 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 | */ | |
| 323 | async 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 | ||
| 333 | 376 | async function outbox (query, ctx) { |
| 334 | 377 | const id = `${base(ctx)}/outbox` |
| 335 | 378 | if (!ctx.records) return json(200, buildCollection({ id }), ACTIVITY_JSON) |
| … | … | @@ -494,7 +537,11 @@ async function onInbound (activity, actorUri, ctx) { |
| 494 | 537 | if (!ctx.inbound) return { status: 202, headers: {}, body: '' } |
| 495 | 538 | |
| 496 | 539 | 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) | |
| 498 | 545 | const added = await ctx.inbound.add(item) |
| 499 | 546 | // A retry is not a second event; say which happened so a duplicate storm is visible. |
| 500 | 547 | ctx.log?.({ msg: added ? 'inbox: kept' : 'inbox: duplicate', type: activity.type, actor: actorUri, inReplyTo: item.inReplyTo || undefined }) |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "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", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| 7 | 7 | "scripts": { |
| … | … | @@ -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 | ||
| 14 | import test from 'brittle' | |
| 15 | import { buildFeed, handleOf } from '../lib/feed.js' | |
| 16 | ||
| 17 | const REMOTE = 'https://mastodon.social/users/alice' | |
| 18 | const OURS = 'https://p2p.7rnx.net/ap/objects/3kmine' | |
| 19 | ||
| 20 | const 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 | ||
| 28 | const 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 | ||
| 34 | test('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 | ||
| 41 | test('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 | ||
| 54 | test('★ 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 | ||
| 73 | test('⛔ 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 | ||
| 85 | test('⛔ 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 | ||
| 96 | test('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 | ||
| 108 | const 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 | ||
| 113 | test('★★ 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 | ||
| 129 | test('★ 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 | ||
| 140 | test('⛔ 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 | }) |
| … | … | @@ -9,6 +9,7 @@ import '../lib/bare-globals.js' |
| 9 | 9 | import test from 'brittle' |
| 10 | 10 | import { normalize, isPublicActivity } from '../lib/inbound-store.js' |
| 11 | 11 | import { publicTimeline } from '../lib/public-timeline.js' |
| 12 | import { buildFeed } from '../lib/feed.js' | |
| 12 | 13 | |
| 13 | 14 | const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public' |
| 14 | 15 | const note = (extra = {}, objExtra = {}) => ({ |
| … | … | @@ -53,18 +54,18 @@ test('★★★ an item with no addressing at all is withheld', (t) => { |
| 53 | 54 | }) |
| 54 | 55 | |
| 55 | 56 | test('★★★ 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>' }] | |
| 60 | 60 | }) |
| 61 | const body = publicTimeline({ host: 'ap.7rnx.net', total: 1, rows }) | |
| 61 | 62 | t.absent(/<img/.test(body), 'no remote tag survives') |
| 62 | 63 | t.absent(/onerror/.test(body), 'no remote attribute survives') |
| 63 | 64 | t.ok(/hello there/.test(body), 'the words do') |
| 64 | 65 | }) |
| 65 | 66 | |
| 66 | 67 | test('★★ 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 }) | |
| 68 | 69 | t.ok(/Direct and followers-only messages are never shown/.test(body), |
| 69 | 70 | 'a reader should not have to infer the policy from an absence') |
| 70 | 71 | // ⚠ 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) => |
| 73 | 74 | }) |
| 74 | 75 | |
| 75 | 76 | test('★★ 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 | ||
| 83 | test('★★★ 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') | |
| 80 | 96 | }) |
| … | … | @@ -230,12 +230,17 @@ test('individual objects are fetchable so receivers can verify a Create', async |
| 230 | 230 | const OUR_POST = 'https://p2p.7rnx.net/ap/objects/3mtecpdhk5c22' |
| 231 | 231 | |
| 232 | 232 | function 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. | |
| 237 | function signedActivityTo (path, activity) { | |
| 233 | 238 | const payload = JSON.stringify(activity) |
| 234 | 239 | 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, | |
| 236 | 241 | keyId: REMOTE_KEY, privateKey: TEST_PRIVATE_KEY |
| 237 | 242 | }) |
| 238 | return { method: 'POST', path: '/ap/inbox', headers: added, body: payload } | |
| 243 | return { method: 'POST', path, headers: added, body: payload } | |
| 239 | 244 | } |
| 240 | 245 | |
| 241 | 246 | const replyTo = (id, inReplyTo = OUR_POST) => ({ |
| … | … | @@ -343,6 +348,45 @@ test('★★ the primary keeps the flat paths and a second user gets their own', |
| 343 | 348 | t.is(JSON.parse(b.body).followers, 'https://p2p.7rnx.net/ap/users/ashatron/followers') |
| 344 | 349 | }) |
| 345 | 350 | |
| 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. | |
| 358 | test('★★ 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 | ||
| 374 | test('⛔ 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 | ||
| 346 | 390 | test('⛔ an unknown user is 404, never the primary', async (t) => { |
| 347 | 391 | const { Users } = await import('../lib/users.js') |
| 348 | 392 | 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) => { |
| 357 | 401 | t.is(a.status, 200) |
| 358 | 402 | t.is(JSON.parse(a.body).id, 'https://p2p.7rnx.net/ap/actor') |
| 359 | 403 | }) |
| 404 | ||
| 405 | // ── the public feed ─────────────────────────────────────────────────────────────────────────────── | |
| 406 | ||
| 407 | test('★★ /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 | ||
| 427 | test('⛔ 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 | ||
| 442 | test('★★ 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 | }) |