OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / a62074a

0.9.92: an operator surface, and a cap the operator chooses
⛔ THE CAP WAS HARDCODED AT 500 and it is not a cache size. The bridge pushes inbound to the peer that proved it holds the name, so the PEER is the durable copy and this is a buffer for peers that are offline — it sizes how long someone may be away, not how much history exists. Now AP_INBOUND_CAP. ⚠ AND FOR THIS NETWORK AN EVICTION IS PERMANENT. atproto inbound can be rebuilt from Constellation, a backlink INDEX that answers "what links to this subject?" for any point in the past — it returns the same {did, collection, rkey} locator spacedust emits, so the drain cannot tell the sources apart. ActivityPub is push-only delivery into an inbox and no such index exists: an evicted Like can be chased only through the original object's own collection, which Mastodon serves partially or not at all. So the cap here can destroy the only copy there ever was, and the admin page says so where the number is shown. ⛔ THE ADMIN SURFACE MINTS NOTHING. This bridge is keyless for atproto and holds only its own AP signing key; a screen that created identities would make it a key holder for other people and reverse the peer/bridge split. Every action is a flow that ALREADY EXISTS — issue an invite, list who registered, release a name whose namespace the operator owns. A test asserts there is no mint route and that the word does not appear. Released names are SHOWN rather than hidden: a burned name is not a free name, because the generation counter makes a reused one a different actor so it can never inherit the previous holder's audience. An operator seeing only the live list would read a burned name as absent. ⛔ SECOND LISTENER, 127.0.0.1 HARDCODED, OFF UNLESS AP_ADMIN_PORT IS SET. Not config.bind — an operator who made the public listener routable must not thereby publish the admin surface. It has no authentication; reach it over SSH. Same decision favela-surface states for its own: "exposing it must be a decision, never a default." lib/admin.js is pure and returns {status, headers, body} like router.js, so the routing tests bind no port — and because that leaves the wiring as the part that only fails in production, admin-serve.test.js binds a real one and posts a real form. A release that parsed no username would 303 back to a page still listing the name, which reads as "the button does nothing". 988/988 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a62074a7091ddd86a1b4599f8a2fc112afa37467
by plankatron <fleeky@gmail.com> · 2026-09-11T23:29:42.000Z

Changed files (9) +477 -104

.pgh-lock.json +95 -100
……@@ -1,9 +1,9 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.90",
3 "version": "0.9.91",
44 "runtime": "bare",
5 "rootTreeHash": "6b2041d78a1d74336fff1aa05b710e533a0ae19a164f61783af79ca5bc421f31",
6 "publishedAt": "2026-09-11T16:48:10.194Z",
5 "rootTreeHash": "4373422f9b5b8c299b8a34e7104da5dae1e608935ac858b423df236dc2474db4",
6 "publishedAt": "2026-09-11T22:03:58.277Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -2396,8 +2396,8 @@
23962396 {
23972397 "name": "favela-peer",
23982398 "version": "0.0.56",
2399 "tree": "facecf308fc9567acaf159e03fb313b3b3495ef8ffd8ea1981740d6d156bad78",
2400 "normHash": "774857dc8827b4eb191ee2b03dbf149afa2da9b891f6d50634e6f105b7fde75d",
2399 "tree": "8ea15d633438bd0f3d4dc69babecd0069ba33c3e7e63de8dd5d428f9530062b7",
2400 "normHash": "42db39cc01ba6f0424f2302b28fe5a523e082501ae708961cd16f2a025faada2",
24012401 "workspace": true
24022402 },
24032403 {
……@@ -3998,19 +3998,14 @@
39983998 "newVersion": "0.0.4",
39993999 "newTree": "4622ac9e189974583e9a16be390684676a5add245bd12d1715df940c7f9ea581",
40004000 "existing": [
4001 {
4002 "appName": "atproto-bridge",
4003 "version": "0.0.3",
4004 "tree": "a8cce432de1374051f5481059bbfc264b5eec47471a24faf9fbb0241625fa2b5"
4005 },
40064001 {
40074002 "appName": "favela-web",
40084003 "version": "0.0.3",
40094004 "tree": "38f44af48b2d59b46f1b5db31b81d02f3ca8b8bad6bc0fb9fffff698241f5e37"
40104005 }
40114006 ],
4012 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4013 "acceptedAt": "2026-09-11T16:48:10.217Z"
4007 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4008 "acceptedAt": "2026-09-11T22:03:58.310Z"
40144009 },
40154010 {
40164011 "type": "VERSION_CONFLICT",
……@@ -4024,8 +4019,8 @@
40244019 "tree": "ee91b6bd77101e3404ca571b2e5adf0f47029d174398a05f50294fc207e39d99"
40254020 }
40264021 ],
4027 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4028 "acceptedAt": "2026-09-11T16:48:10.217Z"
4022 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4023 "acceptedAt": "2026-09-11T22:03:58.310Z"
40294024 },
40304025 {
40314026 "type": "VERSION_CONFLICT",
……@@ -4039,8 +4034,8 @@
40394034 "tree": "3bb78d8f06263a549bd1077f37a7df257b1d557e44608631435af625f05a9c11"
40404035 }
40414036 ],
4042 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4043 "acceptedAt": "2026-09-11T16:48:10.217Z"
4037 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4038 "acceptedAt": "2026-09-11T22:03:58.310Z"
40444039 },
40454040 {
40464041 "type": "VERSION_CONFLICT",
……@@ -4059,8 +4054,8 @@
40594054 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
40604055 }
40614056 ],
4062 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4063 "acceptedAt": "2026-09-11T16:48:10.217Z"
4057 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4058 "acceptedAt": "2026-09-11T22:03:58.310Z"
40644059 },
40654060 {
40664061 "type": "VERSION_CONFLICT",
……@@ -4074,8 +4069,8 @@
40744069 "tree": "da4c6b2654e9d66f10b0faa989311cf182878c24ab51b1c41458cfbf37ad29cd"
40754070 }
40764071 ],
4077 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4078 "acceptedAt": "2026-09-11T16:48:10.217Z"
4072 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4073 "acceptedAt": "2026-09-11T22:03:58.310Z"
40794074 },
40804075 {
40814076 "type": "VERSION_CONFLICT",
……@@ -4089,8 +4084,8 @@
40894084 "tree": "40be8f559a13b54ebf0689e63e6c70f0d218035359e17c497ed7bc8a85c3cc95"
40904085 }
40914086 ],
4092 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4093 "acceptedAt": "2026-09-11T16:48:10.217Z"
4087 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4088 "acceptedAt": "2026-09-11T22:03:58.310Z"
40944089 },
40954090 {
40964091 "type": "VERSION_CONFLICT",
……@@ -4104,8 +4099,8 @@
41044099 "tree": "3eae85efce82299769898c8151b5ed0a94e5a5ebdbcdee2105b815ca733a61cb"
41054100 }
41064101 ],
4107 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4108 "acceptedAt": "2026-09-11T16:48:10.217Z"
4102 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4103 "acceptedAt": "2026-09-11T22:03:58.310Z"
41094104 },
41104105 {
41114106 "type": "VERSION_CONFLICT",
……@@ -4119,8 +4114,8 @@
41194114 "tree": "8d9e01d443a127781773598d2e77c3f372334fad09e20623a819dabe49b22e1a"
41204115 }
41214116 ],
4122 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4123 "acceptedAt": "2026-09-11T16:48:10.217Z"
4117 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4118 "acceptedAt": "2026-09-11T22:03:58.310Z"
41244119 },
41254120 {
41264121 "type": "VERSION_CONFLICT",
……@@ -4134,8 +4129,8 @@
41344129 "tree": "dc1ad6c999fe90d946ba0ef34698ea9af451b639be2ad1c5b61eabb08337a6ca"
41354130 }
41364131 ],
4137 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4138 "acceptedAt": "2026-09-11T16:48:10.217Z"
4132 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4133 "acceptedAt": "2026-09-11T22:03:58.310Z"
41394134 },
41404135 {
41414136 "type": "VERSION_CONFLICT",
……@@ -4149,8 +4144,8 @@
41494144 "tree": "88b4779570e5113aa21b204989ac018d1628b387681f7e53428a7c99114fb701"
41504145 }
41514146 ],
4152 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4153 "acceptedAt": "2026-09-11T16:48:10.217Z"
4147 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4148 "acceptedAt": "2026-09-11T22:03:58.310Z"
41544149 },
41554150 {
41564151 "type": "VERSION_CONFLICT",
……@@ -4164,8 +4159,8 @@
41644159 "tree": "720b867ec4300a3caa2325eb1449499f9940bfb88341ecceb5992fa0fdf5f37d"
41654160 }
41664161 ],
4167 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4168 "acceptedAt": "2026-09-11T16:48:10.217Z"
4162 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4163 "acceptedAt": "2026-09-11T22:03:58.310Z"
41694164 },
41704165 {
41714166 "type": "VERSION_CONFLICT",
……@@ -4179,8 +4174,8 @@
41794174 "tree": "b697fe174482a708b49d0764cd4786291845f0034a31e0699381ca7e7b269d04"
41804175 }
41814176 ],
4182 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4183 "acceptedAt": "2026-09-11T16:48:10.217Z"
4177 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4178 "acceptedAt": "2026-09-11T22:03:58.310Z"
41844179 },
41854180 {
41864181 "type": "VERSION_CONFLICT",
……@@ -4194,8 +4189,8 @@
41944189 "tree": "767f07242277ae80bd395de5a4969fcdbf956c216158ae517fb2b562558ac1a4"
41954190 }
41964191 ],
4197 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4198 "acceptedAt": "2026-09-11T16:48:10.217Z"
4192 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4193 "acceptedAt": "2026-09-11T22:03:58.310Z"
41994194 },
42004195 {
42014196 "type": "VERSION_CONFLICT",
……@@ -4209,8 +4204,8 @@
42094204 "tree": "de7deed21f587ffc8bde4971abe00fca77bb6c71c4e7effb31ad34ee330815df"
42104205 }
42114206 ],
4212 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4213 "acceptedAt": "2026-09-11T16:48:10.217Z"
4207 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4208 "acceptedAt": "2026-09-11T22:03:58.310Z"
42144209 },
42154210 {
42164211 "type": "VERSION_CONFLICT",
……@@ -4224,8 +4219,8 @@
42244219 "tree": "2df42517ebf73cd9c11f2f652f75e87401fd4ed7b856c0adc15c33baf1d4f1ad"
42254220 }
42264221 ],
4227 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4228 "acceptedAt": "2026-09-11T16:48:10.217Z"
4222 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4223 "acceptedAt": "2026-09-11T22:03:58.310Z"
42294224 },
42304225 {
42314226 "type": "VERSION_CONFLICT",
……@@ -4239,8 +4234,8 @@
42394234 "tree": "325aca171cbff560487177584078c8066133e91176639ca47c98d70e195aa92e"
42404235 }
42414236 ],
4242 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4243 "acceptedAt": "2026-09-11T16:48:10.217Z"
4237 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4238 "acceptedAt": "2026-09-11T22:03:58.310Z"
42444239 },
42454240 {
42464241 "type": "VERSION_CONFLICT",
……@@ -4269,8 +4264,8 @@
42694264 "tree": "89a40400106bf4fef95fb5c44a66ee29aada6f76c7740bfba09d8aeeb841f39c"
42704265 }
42714266 ],
4272 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4273 "acceptedAt": "2026-09-11T16:48:10.217Z"
4267 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4268 "acceptedAt": "2026-09-11T22:03:58.310Z"
42744269 },
42754270 {
42764271 "type": "VERSION_CONFLICT",
……@@ -4284,8 +4279,8 @@
42844279 "tree": "1bd0eb382789c62cd4622d47bffb60984fdd766260c79f73a2fc7a136f3edbe9"
42854280 }
42864281 ],
4287 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4288 "acceptedAt": "2026-09-11T16:48:10.217Z"
4282 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4283 "acceptedAt": "2026-09-11T22:03:58.310Z"
42894284 },
42904285 {
42914286 "type": "VERSION_CONFLICT",
……@@ -4299,8 +4294,8 @@
42994294 "tree": "4526fe9dc5468264c7171ec16e9303726674b7b44ca0b3e09cb8b9a6a1be6169"
43004295 }
43014296 ],
4302 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4303 "acceptedAt": "2026-09-11T16:48:10.217Z"
4297 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4298 "acceptedAt": "2026-09-11T22:03:58.310Z"
43044299 },
43054300 {
43064301 "type": "VERSION_CONFLICT",
……@@ -4314,8 +4309,8 @@
43144309 "tree": "32b291cd756e142da18a6ed01c351e024fcedc3e2b1ecfbfb85f3106ada4419b"
43154310 }
43164311 ],
4317 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4318 "acceptedAt": "2026-09-11T16:48:10.217Z"
4312 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4313 "acceptedAt": "2026-09-11T22:03:58.310Z"
43194314 },
43204315 {
43214316 "type": "VERSION_CONFLICT",
……@@ -4329,8 +4324,8 @@
43294324 "tree": "4a8cd3b0093092b33088617dc0415e8179882d7f2f13b7649962ca20b08beeed"
43304325 }
43314326 ],
4332 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4333 "acceptedAt": "2026-09-11T16:48:10.217Z"
4327 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4328 "acceptedAt": "2026-09-11T22:03:58.310Z"
43344329 },
43354330 {
43364331 "type": "VERSION_CONFLICT",
……@@ -4344,8 +4339,8 @@
43444339 "tree": "4f63f165ee6a581f689b40c4a40df4df01ab6fba17d46bd29e40ef5bbdd9884f"
43454340 }
43464341 ],
4347 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4348 "acceptedAt": "2026-09-11T16:48:10.217Z"
4342 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4343 "acceptedAt": "2026-09-11T22:03:58.310Z"
43494344 },
43504345 {
43514346 "type": "VERSION_CONFLICT",
……@@ -4359,8 +4354,8 @@
43594354 "tree": "735109c736c0bd3413ab47414fd8b6ea9a99c401fe8e6d608459c79293806754"
43604355 }
43614356 ],
4362 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4363 "acceptedAt": "2026-09-11T16:48:10.217Z"
4357 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4358 "acceptedAt": "2026-09-11T22:03:58.310Z"
43644359 },
43654360 {
43664361 "type": "VERSION_CONFLICT",
……@@ -4374,8 +4369,8 @@
43744369 "tree": "b32b75bf1b2acdd18ed0cc370d01d477fba42ba09fd1f2f78af3ebad925e72d5"
43754370 }
43764371 ],
4377 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4378 "acceptedAt": "2026-09-11T16:48:10.217Z"
4372 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4373 "acceptedAt": "2026-09-11T22:03:58.310Z"
43794374 },
43804375 {
43814376 "type": "VERSION_CONFLICT",
……@@ -4394,8 +4389,8 @@
43944389 "tree": "189397178121e9a18430943345dab820691648a324229071ceb980bbb77a7393"
43954390 }
43964391 ],
4397 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4398 "acceptedAt": "2026-09-11T16:48:10.217Z"
4392 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4393 "acceptedAt": "2026-09-11T22:03:58.310Z"
43994394 },
44004395 {
44014396 "type": "VERSION_CONFLICT",
……@@ -4409,8 +4404,8 @@
44094404 "tree": "3bedca5287a9041fc2fd5ec5ace4df1fa86240e6afec0a105ba67398bafc09a5"
44104405 }
44114406 ],
4412 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4413 "acceptedAt": "2026-09-11T16:48:10.217Z"
4407 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4408 "acceptedAt": "2026-09-11T22:03:58.310Z"
44144409 },
44154410 {
44164411 "type": "VERSION_CONFLICT",
……@@ -4424,8 +4419,8 @@
44244419 "tree": "b9ca9b272008bd67672a1b252390e8b527095c46b46cf6b6725a7a187eb038b6"
44254420 }
44264421 ],
4427 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4428 "acceptedAt": "2026-09-11T16:48:10.217Z"
4422 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4423 "acceptedAt": "2026-09-11T22:03:58.310Z"
44294424 },
44304425 {
44314426 "type": "VERSION_CONFLICT",
……@@ -4439,8 +4434,8 @@
44394434 "tree": "7793645d7d4248d6d6533e3cbcd4bf40bed4f38c678b0ca0606c15640ba5e7b9"
44404435 }
44414436 ],
4442 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4443 "acceptedAt": "2026-09-11T16:48:10.217Z"
4437 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4438 "acceptedAt": "2026-09-11T22:03:58.310Z"
44444439 },
44454440 {
44464441 "type": "VERSION_CONFLICT",
……@@ -4454,8 +4449,8 @@
44544449 "tree": "79447513d4ac0357c8d60bdc7b81b3c5143d761b26700340cba266c4d1cb77b0"
44554450 }
44564451 ],
4457 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4458 "acceptedAt": "2026-09-11T16:48:10.217Z"
4452 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4453 "acceptedAt": "2026-09-11T22:03:58.310Z"
44594454 },
44604455 {
44614456 "type": "VERSION_CONFLICT",
……@@ -4469,8 +4464,8 @@
44694464 "tree": "279d4e0447eb79fd75816864ba7f41f2df03080c9da69ea4603fe2900c8d9e53"
44704465 }
44714466 ],
4472 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4473 "acceptedAt": "2026-09-11T16:48:10.217Z"
4467 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4468 "acceptedAt": "2026-09-11T22:03:58.310Z"
44744469 },
44754470 {
44764471 "type": "VERSION_CONFLICT",
……@@ -4484,8 +4479,8 @@
44844479 "tree": "ed4e0b480417610e195ff0b59e5b9a453abbf2db6d6ce72d7cd522269f835e9a"
44854480 }
44864481 ],
4487 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4488 "acceptedAt": "2026-09-11T16:48:10.217Z"
4482 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4483 "acceptedAt": "2026-09-11T22:03:58.310Z"
44894484 },
44904485 {
44914486 "type": "VERSION_CONFLICT",
……@@ -4504,8 +4499,8 @@
45044499 "tree": "a9e07d54e526c838dfe5e27df33936682aefb218f61a45b6c15289243999f648"
45054500 }
45064501 ],
4507 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4508 "acceptedAt": "2026-09-11T16:48:10.217Z"
4502 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4503 "acceptedAt": "2026-09-11T22:03:58.310Z"
45094504 },
45104505 {
45114506 "type": "VERSION_CONFLICT",
……@@ -4519,8 +4514,8 @@
45194514 "tree": "9d84c86441fe138b227c4fd01d1e948aa83bf8d13cfb1aa957838feea05fbb57"
45204515 }
45214516 ],
4522 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4523 "acceptedAt": "2026-09-11T16:48:10.217Z"
4517 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4518 "acceptedAt": "2026-09-11T22:03:58.310Z"
45244519 },
45254520 {
45264521 "type": "VERSION_CONFLICT",
……@@ -4534,8 +4529,8 @@
45344529 "tree": "4a3afbe2fc7a882e24454eb2e7e7cfe6816811ee5d73ae0f92e8b9ea9cfb8c78"
45354530 }
45364531 ],
4537 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4538 "acceptedAt": "2026-09-11T16:48:10.217Z"
4532 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4533 "acceptedAt": "2026-09-11T22:03:58.310Z"
45394534 },
45404535 {
45414536 "type": "VERSION_CONFLICT",
……@@ -4549,8 +4544,8 @@
45494544 "tree": "1e5c5810d8971a3c27fde7fb0e4d633dcabbe2cf98d8df86b473f8d67eead8b9"
45504545 }
45514546 ],
4552 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4553 "acceptedAt": "2026-09-11T16:48:10.217Z"
4547 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4548 "acceptedAt": "2026-09-11T22:03:58.310Z"
45544549 },
45554550 {
45564551 "type": "VERSION_CONFLICT",
……@@ -4564,8 +4559,8 @@
45644559 "tree": "e3478f50e85ba0ac288efed83e84c992483904897dc39ccf243d20a4ea25d972"
45654560 }
45664561 ],
4567 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4568 "acceptedAt": "2026-09-11T16:48:10.217Z"
4562 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4563 "acceptedAt": "2026-09-11T22:03:58.310Z"
45694564 },
45704565 {
45714566 "type": "VERSION_CONFLICT",
……@@ -4579,8 +4574,8 @@
45794574 "tree": "c05d8bf7eab5024ea98140f78c84d12fb494b4b7b10c7eedeb50b2be842fa519"
45804575 }
45814576 ],
4582 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4583 "acceptedAt": "2026-09-11T16:48:10.217Z"
4577 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4578 "acceptedAt": "2026-09-11T22:03:58.310Z"
45844579 },
45854580 {
45864581 "type": "VERSION_CONFLICT",
……@@ -4594,8 +4589,8 @@
45944589 "tree": "0697bb338bbcec517353cffcdb2345a8232b9b90cc627a549a306f0323576390"
45954590 }
45964591 ],
4597 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4598 "acceptedAt": "2026-09-11T16:48:10.218Z"
4592 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4593 "acceptedAt": "2026-09-11T22:03:58.310Z"
45994594 },
46004595 {
46014596 "type": "VERSION_CONFLICT",
……@@ -4609,8 +4604,8 @@
46094604 "tree": "a366a73e74f8d80f106465058083b62bb70b833c721047124e99c89880e5c706"
46104605 }
46114606 ],
4612 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4613 "acceptedAt": "2026-09-11T16:48:10.218Z"
4607 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4608 "acceptedAt": "2026-09-11T22:03:58.310Z"
46144609 },
46154610 {
46164611 "type": "VERSION_CONFLICT",
……@@ -4624,8 +4619,8 @@
46244619 "tree": "7912e790c9e87e9829a6daa037c5f5da0cc4355214f51d334f96e57678dfddeb"
46254620 }
46264621 ],
4627 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4628 "acceptedAt": "2026-09-11T16:48:10.218Z"
4622 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4623 "acceptedAt": "2026-09-11T22:03:58.310Z"
46294624 },
46304625 {
46314626 "type": "VERSION_CONFLICT",
……@@ -4639,8 +4634,8 @@
46394634 "tree": "003ac841449fcdda1abc3c87fee291a5d9596d245925a7298bd8ef5f8e423eae"
46404635 }
46414636 ],
4642 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4643 "acceptedAt": "2026-09-11T16:48:10.218Z"
4637 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4638 "acceptedAt": "2026-09-11T22:03:58.310Z"
46444639 },
46454640 {
46464641 "type": "VERSION_CONFLICT",
……@@ -4659,8 +4654,8 @@
46594654 "tree": "7b3f314cf92558edbc90295c573885276a6add925318998c829c03d335edc765"
46604655 }
46614656 ],
4662 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4663 "acceptedAt": "2026-09-11T16:48:10.218Z"
4657 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4658 "acceptedAt": "2026-09-11T22:03:58.310Z"
46644659 },
46654660 {
46664661 "type": "VERSION_CONFLICT",
……@@ -4674,8 +4669,8 @@
46744669 "tree": "c486fe73805bf013e9c5de91b5780d1aa5d2b2295306f411f7863757aaa05e60"
46754670 }
46764671 ],
4677 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4678 "acceptedAt": "2026-09-11T16:48:10.218Z"
4672 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4673 "acceptedAt": "2026-09-11T22:03:58.310Z"
46794674 },
46804675 {
46814676 "type": "VERSION_CONFLICT",
……@@ -4689,8 +4684,8 @@
46894684 "tree": "10193a86e2c6f80c2d4377379a0aae4a959f8a40fd9e852b85fee081bfe6675a"
46904685 }
46914686 ],
4692 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4693 "acceptedAt": "2026-09-11T16:48:10.218Z"
4687 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4688 "acceptedAt": "2026-09-11T22:03:58.310Z"
46944689 },
46954690 {
46964691 "type": "VERSION_CONFLICT",
……@@ -4704,8 +4699,8 @@
47044699 "tree": "20b1ecd2937f3e35102892288234b86a8ec6deb3711016d96ff3509474ac465b"
47054700 }
47064701 ],
4707 "reason": "version-splits only, 0 code-differs; favela-identity 0.0.3 is new in this release and no already-published app uses the bridge verbs",
4708 "acceptedAt": "2026-09-11T16:48:10.218Z"
4702 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4703 "acceptedAt": "2026-09-11T22:03:58.310Z"
47094704 }
47104705 ]
47114706}
index.js +27 -1
……@@ -10,6 +10,7 @@ import fs from 'bare-fs'
1010import process from 'bare-process'
1111import { join } from 'bare-path'
1212import { loadConfig, assertUsable } from './lib/config.js'
13import http from 'bare-http1'
1314import { createServer } from './lib/server.js'
1415import { actorDocFor } from './lib/router.js'
1516import { FollowerStore } from './lib/followers.js'
……@@ -55,7 +56,9 @@ async function main () {
5556 const followers = new FollowerStore(join(dataDir, 'followers.json'))
5657 // What the fediverse sends us — replies, mentions, likes, boosts. Until now these were verified,
5758 // acked with 202 and discarded, so ap-bridge could be replied to and never knew.
58 const inbound = new InboundStore({ file: join(dataDir, 'inbound.json') })
59 // ⛔ THE CAP IS THE OPERATOR'S. It was fixed at 500 here, and for the fediverse an eviction is not a
60 // cache miss — there is no backlink index to re-ask, so it can be the only copy. See config.js.
61 const inbound = new InboundStore({ file: join(dataDir, 'inbound.json'), cap: config.inboundCap })
5962 const resolver = new RemoteKeyResolver()
6063
6164 // ⛔ CUSTODY IS NAMED HERE, not implied by passing a key around. This is the primary actor and it
……@@ -481,6 +484,29 @@ async function main () {
481484 server.listen(config.port, config.bind, resolve)
482485 })
483486
487 // ⛔ THE OPERATOR SURFACE IS A SECOND LISTENER, ALWAYS LOOPBACK, AND OFF UNLESS ASKED FOR. It must
488 // not share the public port: that one is proxied to the internet, and this one has NO authentication
489 // in front of it — reach it over SSH. The same decision favela-surface states for itself: "exposing
490 // it must be a decision, never a default." It mints nothing; every action on it is a flow that
491 // already exists (invite, list, release).
492 if (config.adminPort) {
493 const { adminRoutes } = await import('./lib/admin.js')
494 const { parseForm } = await import('./lib/admin-serve.js')
495 const adminServer = http.createServer((req, res) => {
496 parseForm(req).then((form) => adminRoutes({ method: req.method, path: (req.url || '/').split('?')[0], form },
497 { registrations, inbound, config }))
498 .then((out) => { res.writeHead(out.status, out.headers); res.end(out.body) })
499 .catch((err) => { res.writeHead(500, { 'content-type': 'text/plain' }); res.end(String((err && err.message) || err)) })
500 })
501 await new Promise((resolve, reject) => {
502 adminServer.on('error', reject)
503 // ⛔ 127.0.0.1 HARDCODED, not config.bind. An operator who set AP_BIND to something routable for
504 // the public listener must not thereby publish the admin surface.
505 adminServer.listen(config.adminPort, '127.0.0.1', resolve)
506 })
507 log({ msg: 'admin surface (loopback only, no auth — reach it over SSH)', url: 'http://127.0.0.1:' + config.adminPort + '/admin' })
508 }
509
484510 // ★ The first pass picks up whatever the last run left owed — which is the entire point of the
485511 // queue being durable. Started after listen so a backlog drain never delays the port opening.
486512 outbound.start()
lib/admin-serve.js +37 -0
……@@ -0,0 +1,37 @@
1// The transport half of the admin surface — kept apart from admin.js so that file stays pure.
2//
3// ⛔ SMALL ON PURPOSE. An operator page posts two fields; pulling in a form parser (or reusing the
4// multipart one favela-surface needs for pictures) would be more moving parts than the thing it serves.
5// Bodies are bounded because a listener that buffers whatever it is handed is a memory bug with a form
6// in front of it, even on loopback.
7
8const MAX_BODY = 64 * 1024
9
10export function parseForm (req) {
11 return new Promise((resolve) => {
12 if (req.method !== 'POST') return resolve({})
13 let size = 0
14 const chunks = []
15 req.on('data', (c) => {
16 size += c.length
17 // ⚠ Stop COLLECTING but keep draining: destroying the stream mid-body makes the browser show a
18 // transport error instead of the page, for a form that was merely too big.
19 if (size <= MAX_BODY) chunks.push(c)
20 })
21 req.on('end', () => {
22 const body = Buffer.concat(chunks).toString('utf8')
23 const out = {}
24 for (const pair of body.split('&')) {
25 if (!pair) continue
26 const i = pair.indexOf('=')
27 const k = i === -1 ? pair : pair.slice(0, i)
28 const v = i === -1 ? '' : pair.slice(i + 1)
29 try { out[decodeURIComponent(k.replace(/\+/g, ' '))] = decodeURIComponent(v.replace(/\+/g, ' ')) } catch { /* skip a malformed pair */ }
30 }
31 resolve(out)
32 })
33 req.on('error', () => resolve({}))
34 })
35}
36
37export default { parseForm }
lib/admin.js +134 -0
……@@ -0,0 +1,134 @@
1// admin.js — the operator's read-out of this bridge, over loopback.
2//
3// ⛔ WHAT IT DOES NOT DO: mint anything. This bridge is keyless for atproto and holds only its own AP
4// signing key; an admin screen that created identities would make it a key holder for other people and
5// reverse the whole peer/bridge split. Everything here is a flow that ALREADY EXISTS — issue an invite,
6// see who is registered, release a name an operator owns the namespace for. No new authority.
7//
8// ⛔ LOOPBACK, ON ITS OWN LISTENER, AND OFF UNLESS CONFIGURED. The public listener serves the fediverse;
9// this serves the operator. They must not be the same port, because the public one is proxied. There is
10// NO authentication in front of this — reach it over SSH — which is exactly what favela-surface says of
11// its own surface: "exposing it must be a decision, never a default."
12//
13// ⚠ Pure of transport, like router.js: every handler returns `{ status, headers, body }` so the whole
14// surface is testable with no port bound.
15
16const esc = (s) => String(s == null ? '' : s)
17 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
18
19const html = (status, body) => ({ status, headers: { 'content-type': 'text/html; charset=utf-8' }, body })
20const redirect = (to) => ({ status: 303, headers: { location: to }, body: '' })
21
22const STYLE = `body{font:14px/1.5 system-ui,sans-serif;margin:0;padding:24px;max-width:1000px;color:#111}
23h1{font-size:20px}h2{font-size:15px;margin-top:28px;border-bottom:1px solid #ddd;padding-bottom:4px}
24table{border-collapse:collapse;width:100%;margin:8px 0}td,th{text-align:left;padding:5px 8px;border-bottom:1px solid #eee;vertical-align:top}
25th{font-weight:600;color:#555;font-size:12px;text-transform:uppercase;letter-spacing:.04em}
26code{font:12px ui-monospace,monospace;background:#f5f5f5;padding:1px 4px;border-radius:3px;word-break:break-all}
27.who{color:#666;font-size:13px}.warn{color:#a00}.none{color:#999;font-style:italic}
28button{font:inherit;padding:4px 10px}form{display:inline}`
29
30const page = (title, body) =>
31 `<!doctype html><meta charset="utf-8"><title>${esc(title)}</title><style>${STYLE}</style>` +
32 `<h1>${esc(title)}</h1>${body}`
33
34const when = (ms) => {
35 if (!ms) return '—'
36 const d = new Date(Number(ms))
37 return Number.isNaN(d.getTime()) ? '—' : d.toISOString().replace('T', ' ').slice(0, 19) + 'Z'
38}
39
40/** The registered names, the burned ones, and what the fediverse has sent us. */
41export function renderOverview ({ host, registrations = [], burned = [], inbound = [], cap = null, policy = null }) {
42 const names = registrations.length
43 ? '<table><tr><th>name</th><th>identity root</th><th>feed</th><th>gen</th><th>registered</th><th></th></tr>' +
44 registrations.map((r) =>
45 '<tr><td><code>' + esc(r.username) + '@' + esc(host) + '</code></td>' +
46 '<td><code>' + esc(String(r.root || '').slice(0, 16)) + '…</code></td>' +
47 '<td><code>' + esc(String(r.feedKey || '—').slice(0, 16)) + '…</code></td>' +
48 '<td>' + esc(r.generation) + '</td><td>' + esc(when(r.registeredAt)) + '</td>' +
49 '<td><form method="post" action="/admin/release"><input type="hidden" name="username" value="' +
50 esc(r.username) + '"><button>release</button></form></td></tr>').join('') +
51 '</table>'
52 : '<p class="none">Nobody has registered a name on this bridge.</p>'
53
54 // ⛔ SHOWN, NOT HIDDEN. A burned name is not a free name — the generation counter makes a reused one
55 // a DIFFERENT actor so it can never inherit the previous holder's audience. An operator seeing only
56 // the live list would read a burned name as simply absent and wonder why re-registering behaves oddly.
57 const dead = burned.length
58 ? '<table><tr><th>name</th><th>gen</th><th>released</th><th>by</th></tr>' +
59 burned.map((b) => '<tr><td><code>' + esc(b.username) + '</code></td><td>' + esc(b.generation) +
60 '</td><td>' + esc(when(b.releasedAt)) + '</td><td>' + esc(b.by || '—') + '</td></tr>').join('') +
61 '</table>'
62 : '<p class="none">No names have been released.</p>'
63
64 const feed = inbound.length
65 ? '<table><tr><th>type</th><th>from</th><th>at</th></tr>' +
66 inbound.slice(0, 50).map((i) => '<tr><td>' + esc(i.type || '?') + '</td><td><code>' +
67 esc(i.actor || i.actorUri || '—') + '</code></td><td>' + esc(when(i.receivedAt)) + '</td></tr>').join('') +
68 '</table>'
69 : '<p class="none">Nothing has arrived from the fediverse yet.</p>'
70
71 // ⛔ THE CAP IS SAID OUT LOUD, because for this network an eviction is not a cache miss. atproto
72 // inbound can be rebuilt from Constellation, a backlink index over the whole network; ActivityPub is
73 // push-only and no such index exists, so what is evicted here may be the only copy there ever was.
74 const capNote = cap
75 ? '<p class="who">Keeping the newest <strong>' + esc(cap) + '</strong>. This is a buffer for a peer ' +
76 'that is offline, not the system\'s memory — the peer holds the durable copy. ' +
77 '<span class="warn">The fediverse has no backlink index, so an eviction here cannot be backfilled.</span></p>'
78 : ''
79
80 return page('bridge · ' + host,
81 '<p class="who">Registration policy: <strong>' + esc(policy || 'off') + '</strong></p>' +
82 '<h2>Registered names</h2>' + names +
83 '<h2>Invites</h2><form method="post" action="/admin/invite"><button>issue an invite</button></form>' +
84 '<p class="who">Only meaningful on an invite-only bridge. An invite is single-use and is consumed ' +
85 'before the claim can fail on anything else.</p>' +
86 '<h2>Released names</h2>' + dead +
87 '<h2>Inbound from the fediverse</h2>' + capNote + feed)
88}
89
90export function renderInvite (token, host) {
91 return page('invite issued',
92 '<p>Give this to one person. It is single-use.</p><p><code>' + esc(token) + '</code></p>' +
93 '<p class="who">They enter it when claiming a name on <code>' + esc(host) + '</code>.</p>' +
94 '<p><a href="/admin">back</a></p>')
95}
96
97/**
98 * The whole admin surface as one function.
99 *
100 * ⚠ `release` is the OPERATOR authority, which is a different act from a holder releasing their own
101 * name: the names live on this operator's domain, and a bridge with no arbiter has permanent squatters.
102 * It still BURNS the name — see registrations.js — so it is not a way to hand a name to someone else.
103 */
104export async function adminRoutes (req, { registrations, inbound, config }) {
105 const path = req.path || '/'
106 if (!registrations) return html(404, page('not found', '<p>Registration is not configured on this bridge.</p>'))
107
108 if (req.method === 'GET' && (path === '/admin' || path === '/admin/' || path === '/')) {
109 return html(200, renderOverview({
110 host: config.host,
111 policy: config.registration || null,
112 cap: config.inboundCap,
113 registrations: await registrations.list(),
114 burned: typeof registrations.listBurned === 'function' ? await registrations.listBurned() : [],
115 inbound: inbound ? await inbound.list({ limit: 50 }) : []
116 }))
117 }
118
119 if (req.method === 'POST' && path === '/admin/invite') {
120 const token = await registrations.invite()
121 return html(200, renderInvite(typeof token === 'string' ? token : (token && token.invite) || String(token), config.host))
122 }
123
124 if (req.method === 'POST' && path === '/admin/release') {
125 const username = String((req.form && req.form.username) || '').trim()
126 if (!username) return redirect('/admin')
127 await registrations.release(username, { operator: true })
128 return redirect('/admin')
129 }
130
131 return html(404, page('not found', '<p><a href="/admin">admin</a></p>'))
132}
133
134export default { adminRoutes, renderOverview, renderInvite }
lib/config.js +22 -2
……@@ -27,7 +27,21 @@ const DEFAULTS = {
2727 privateKeyPath: null, // PEM on disk; minted once with openssl (see README)
2828 publicKeyPath: null,
2929 dataDir: null, // follower state etc.
30 maxSkewSeconds: 300
30 maxSkewSeconds: 300,
31 // ⛔ HOW LONG A PEER MAY BE AWAY, not how much history exists. The bridge pushes inbound to the peer
32 // that proved it holds the name, so the PEER is the durable copy and this is a buffer for peers that
33 // are offline. It was hardcoded at 500 in inbound-store.js.
34 //
35 // ⚠ AND FOR THE FEDIVERSE IT IS A REAL LOSS BOUNDARY, unlike atproto. atproto inbound can be rebuilt
36 // from Constellation, a backlink INDEX that answers "what links to this subject?" for any point in
37 // the past. ActivityPub is push-only delivery into an inbox and no such index exists — an evicted
38 // Like can be recovered only by asking the original object's own collection, which Mastodon serves
39 // partially or not at all. So evicting here can destroy the only copy, and the cap should be
40 // generous rather than tight.
41 inboundCap: 500,
42 // The loopback admin UI. ABSENT means it is not served at all — an operator surface a bridge starts
43 // offering because nobody said not to is the same mistake as registration defaulting to open.
44 adminPort: null
3145}
3246
3347function expand (p) {
……@@ -58,7 +72,13 @@ export function loadConfig (env = process.env) {
5872 // a bridge must not start handing out names on the operator's domain because nobody said not to.
5973 registration: 'AP_REGISTRATION'
6074 }),
61 ...pickInt(env, { port: 'AP_PORT', maxSkewSeconds: 'AP_MAX_SKEW', backfill: 'AP_BACKFILL' })
75 ...pickInt(env, {
76 port: 'AP_PORT',
77 maxSkewSeconds: 'AP_MAX_SKEW',
78 backfill: 'AP_BACKFILL',
79 inboundCap: 'AP_INBOUND_CAP',
80 adminPort: 'AP_ADMIN_PORT'
81 })
6282 }
6383
6484 // Comma-separated in env; an array in a config file.
lib/registrations.js +9 -0
……@@ -62,6 +62,15 @@ class BaseRegistrations {
6262
6363 async list () { return Object.values((await this._read()).names) }
6464
65 /**
66 * Every name that has been RELEASED, with its tombstone.
67 *
68 * ⛔ Operators need to see these, because a burned name is not a free name: the generation counter
69 * makes a reused name a DIFFERENT actor so it cannot inherit the previous holder's audience, and an
70 * operator looking at only the live list would read a burned name as simply absent.
71 */
72 async listBurned () { return Object.values((await this._read()).burned) }
73
6574 /** Mint a single-use invite. The operator's act; only meaningful when the policy is `invite`. */
6675 async invite () {
6776 const state = await this._read()
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.91",
3 "version": "0.9.92",
44 "description": "ActivityPub membrane — a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/admin-serve.test.js +70 -0
……@@ -0,0 +1,70 @@
1// The admin surface with a real port bound — the half admin.test.js deliberately cannot reach.
2//
3// ⛔ admin.js is pure and returns `{status, headers, body}`, which makes the ROUTING testable with no
4// socket. That leaves the wiring — bare-http1, the form parser, the loopback bind — as the part that
5// only fails in production. It is exactly the shape of thing that passes every unit test and then does
6// not start, so it gets a bound port here.
7
8import test from 'brittle'
9import http from 'bare-http1'
10import { adminRoutes } from '../lib/admin.js'
11import { parseForm } from '../lib/admin-serve.js'
12import { MemoryRegistrations } from '../lib/registrations.js'
13
14const ROOT = 'a'.repeat(64)
15
16async function serve (registrations, config) {
17 const server = http.createServer((req, res) => {
18 parseForm(req)
19 .then((form) => adminRoutes({ method: req.method, path: (req.url || '/').split('?')[0], form },
20 { registrations, inbound: null, config }))
21 .then((out) => { res.writeHead(out.status, out.headers); res.end(out.body) })
22 .catch((err) => { res.writeHead(500, {}); res.end(String(err && err.message)) })
23 })
24 await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
25 return { server, port: server.address().port }
26}
27
28const fetchText = (port, path, opts = {}) => new Promise((resolve, reject) => {
29 const req = http.request({ host: '127.0.0.1', port, path, method: opts.method || 'GET', headers: opts.headers || {} },
30 (res) => {
31 let body = ''
32 res.on('data', (c) => { body += c })
33 res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
34 })
35 req.on('error', reject)
36 if (opts.body) req.write(opts.body)
37 req.end()
38})
39
40test('★★★ it binds loopback and serves the overview', async (t) => {
41 const registrations = new MemoryRegistrations({ policy: 'open' })
42 await registrations.claim({ username: 'fleeky', root: ROOT, feedKey: 'd'.repeat(64) })
43
44 const { server, port } = await serve(registrations, { host: 'ap.7rnx.net', registration: 'open', inboundCap: 500 })
45 t.teardown(() => new Promise((r) => server.close(r)))
46
47 const res = await fetchText(port, '/admin')
48 t.is(res.status, 200)
49 t.ok(/fleeky@ap\.7rnx\.net/.test(res.body), 'the operator can see who holds a name')
50})
51
52test('★★★ a posted form reaches the handler and takes effect', async (t) => {
53 // ⛔ THE PARSER IS THE POINT. A release that silently parsed no username would 303 back to a page
54 // that still lists the name, which reads as "the button does nothing" — the failure mode this repo
55 // has now hit twice on other surfaces.
56 const registrations = new MemoryRegistrations({ policy: 'open' })
57 await registrations.claim({ username: 'squatted', root: ROOT })
58
59 const { server, port } = await serve(registrations, { host: 'ap.7rnx.net', registration: 'open', inboundCap: 500 })
60 t.teardown(() => new Promise((r) => server.close(r)))
61
62 const body = 'username=squatted'
63 const res = await fetchText(port, '/admin/release', {
64 method: 'POST',
65 headers: { 'content-type': 'application/x-www-form-urlencoded', 'content-length': String(body.length) },
66 body
67 })
68 t.is(res.status, 303, 'redirected back')
69 t.absent(await registrations.get('squatted'), 'and the name really was released')
70})
test/admin.test.js +82 -0
……@@ -0,0 +1,82 @@
1// The operator's read-out. Loopback, read-mostly, and it mints nothing.
2//
3// ⛔ WHAT THIS PINS. The bridge is keyless for atproto and holds only its own AP signing key. An admin
4// screen that created identities would make it a key holder for other people and reverse the peer/bridge
5// split the whole architecture rests on. So every action here is a flow that ALREADY EXISTS — issue an
6// invite, list who registered, release a name whose namespace the operator owns.
7
8import test from 'brittle'
9import { adminRoutes, renderOverview } from '../lib/admin.js'
10import { MemoryRegistrations } from '../lib/registrations.js'
11
12const ROOT_A = 'a'.repeat(64)
13const ROOT_B = 'b'.repeat(64)
14const config = { host: 'ap.7rnx.net', registration: 'open', inboundCap: 500 }
15
16const get = (path) => ({ method: 'GET', path })
17const post = (path, form) => ({ method: 'POST', path, form })
18
19test('★★ the overview lists who holds a name on this bridge', async (t) => {
20 const registrations = new MemoryRegistrations({ policy: 'open' })
21 await registrations.claim({ username: 'fleeky', root: ROOT_A, feedKey: 'd'.repeat(64) })
22 await registrations.claim({ username: 'ashatron', root: ROOT_B })
23
24 const res = await adminRoutes(get('/admin'), { registrations, inbound: null, config })
25 t.is(res.status, 200)
26 t.ok(/fleeky@ap\.7rnx\.net/.test(res.body), 'the name as a person actually types it')
27 t.ok(/ashatron@ap\.7rnx\.net/.test(res.body))
28 t.ok(/Registration policy.*open/s.test(res.body), 'and the policy, which decides who may join')
29})
30
31test('★★★ a released name is shown, not hidden', async (t) => {
32 // ⛔ A burned name is NOT a free name: the generation counter makes a reused one a different actor so
33 // it cannot inherit the previous holder's audience. An operator seeing only the live list would read
34 // a burned name as merely absent.
35 const registrations = new MemoryRegistrations({ policy: 'open' })
36 await registrations.claim({ username: 'gone', root: ROOT_A })
37 await registrations.release('gone', { root: ROOT_A })
38
39 const res = await adminRoutes(get('/admin'), { registrations, inbound: null, config })
40 t.ok(/Released names/.test(res.body))
41 t.ok(/>gone</.test(res.body), 'the tombstone is visible')
42})
43
44test('★★★ the operator may release a name, and it burns', async (t) => {
45 const registrations = new MemoryRegistrations({ policy: 'open' })
46 await registrations.claim({ username: 'squatted', root: ROOT_A })
47
48 const res = await adminRoutes(post('/admin/release', { username: 'squatted' }), { registrations, inbound: null, config })
49 t.is(res.status, 303, 'back to the overview')
50 t.absent(await registrations.get('squatted'), 'the name is no longer held')
51
52 // ⚠ Burned, not freed — so this is not a way to hand somebody else's name to a third party.
53 const dead = await registrations.released('squatted')
54 t.ok(dead, 'a tombstone remains')
55 t.is(dead.generation, 1)
56})
57
58test('★★ issuing an invite is the existing flow, surfaced', async (t) => {
59 const registrations = new MemoryRegistrations({ policy: 'invite' })
60 const res = await adminRoutes(post('/admin/invite', {}), { registrations, inbound: null, config })
61 t.is(res.status, 200)
62 t.ok(/single-use/.test(res.body), 'and it says what an invite is')
63})
64
65test('★★★ the cap is stated, and so is the fact that it cannot be backfilled', (t) => {
66 // atproto inbound can be rebuilt from Constellation — a backlink index answering "what links to this
67 // subject?" for any point in the past. ActivityPub is push-only delivery and has no such index, so an
68 // eviction here may destroy the only copy. An operator choosing a cap has to be told which of the two
69 // they are choosing.
70 const body = renderOverview({ host: 'ap.7rnx.net', cap: 500, inbound: [] })
71 t.ok(/Keeping the newest/.test(body))
72 t.ok(/no backlink index/.test(body), 'the fediverse asymmetry is said out loud')
73 t.ok(/peer holds the durable copy/.test(body), 'and that the cap is a buffer, not the memory')
74})
75
76test('★★ nothing here mints an identity', async (t) => {
77 const registrations = new MemoryRegistrations({ policy: 'open' })
78 const res = await adminRoutes(get('/admin'), { registrations, inbound: null, config })
79 t.absent(/mint/i.test(res.body), 'no minting offered — the bridge holds no key for anybody else')
80 const missing = await adminRoutes(post('/admin/mint', {}), { registrations, inbound: null, config })
81 t.is(missing.status, 404, 'and no such route exists')
82})