| … | … | @@ -1,56 +1,196 @@ |
| 1 | 1 | # favela-bridge |
| 2 | 2 | |
| 3 | | Projects a node's own favela records onto the networks its identity has turned on. |
| 3 | Projects a node's own favela records onto the networks its identity has turned on. Reads |
| 4 | `favela-peer`, decides, and drives the sinks. |
| 4 | 5 | |
| 5 | | favela-bridge plan and report; publish nothing |
| 6 | | favela-bridge --send project once |
| 7 | | favela-bridge --watch project continuously (implies --send) |
| 8 | | |
| 9 | | FAVELA_PEER_SOCKET favela-peer's socket |
| 10 | | FAVELA_BSKY_SOCKET hyper-proto-peer's control socket — required to publish |
| 11 | | FAVELA_BRIDGE_INTERVAL seconds between cycles when watching (default 15) |
| 6 | ``` |
| 7 | favela-bridge plan every record and report; publish nothing |
| 8 | favela-bridge --send project once |
| 9 | favela-bridge --watch project continuously, every cycle |
| 10 | favela-bridge --limit <n> a ceiling on one cycle |
| 11 | favela-bridge --reproject re-derive records already in the repo and update what changed |
| 12 | ``` |
| 12 | 13 | |
| 13 | 14 | ## Install |
| 14 | 15 | |
| 15 | 16 | ```sh |
| 16 | | pgh app install favela-bridge |
| 17 | pgh publish . |
| 18 | pgh app install pgh://<registry-key>/favela-bridge --name favela-bridge |
| 17 | 19 | ``` |
| 18 | 20 | |
| 19 | | Needs a running `favela-peer` (read) and, to publish, a running `hyper-proto-peer` |
| 20 | | (`FAVELA_BSKY_SOCKET`). With no `--send` it plans and reports and writes nothing. |
| 21 | A `pgh.type: daemon` app, deployed with `args: ["--watch"]` and `autostart: true`. |
| 22 | |
| 23 | | var | default | | |
| 24 | |---|---|---| |
| 25 | | `FAVELA_PEER_SOCKET` | `${HOME}/.config/favela-peer/favela.sock` | favela-peer's socket, read | |
| 26 | | `FAVELA_BSKY_SOCKET` | `${HOME}/.config/hyper-proto-peer/data/control.sock` | hpp's control socket; required to publish | |
| 27 | | `FAVELA_BRIDGE_INTERVAL` | `15` | seconds between cycles when watching, floor 5 | |
| 28 | | `FAVELA_ROOM_TYPE` | `public` | what the node's own feed is treated as | |
| 29 | |
| 30 | Dry by default. Projection is publication: it puts words under an identity on a network that does not |
| 31 | forget, so a bare run says what would happen and does nothing. |
| 32 | |
| 33 | `--watch` always sends, and `--send` has no bearing on it. The two flags read as orthogonal and are |
| 34 | not — the deployed pgh args are `["--watch"]` with no `--send`. Use `--limit 0` for a watch that sends |
| 35 | nothing. This is deliberately not "fixed" by making watch honour `--send`, because both deployed |
| 36 | daemons rely on it and would silently stop publishing. |
| 37 | |
| 38 | `--limit` exists for the first send into a repo that has followers: a feed with a history projects all |
| 39 | of it the first time, and "16 posts at once" is a different act from "post". |
| 40 | |
| 41 | `--reproject` is reconciliation, the counterpart to the event path. A post is skipped once its rkey is |
| 42 | in the repo, which is right for delivery and wrong the moment the projection improves — every post |
| 43 | made before images worked would keep its text-only version for ever, and no event revisits it. It |
| 44 | compares against the repo and writes only a real difference. |
| 21 | 45 | |
| 22 | 46 | ## Why it is its own component |
| 23 | 47 | |
| 24 | | favela-peer must not know what atproto is — `favela-peer/test/boundary.test.js` enforces it, from |
| 48 | `favela-peer` must not know what atproto is; `favela-peer/test/boundary.test.js` enforces it, from |
| 25 | 49 | hyper-social #61 §10: *"the moment the daemon imports the projection the dependency has been |
| 26 | | inverted"*. And hyper-proto-peer signs a production DID. So the projection lives in neither: this |
| 27 | | reads the peer, decides, and drives the sinks. |
| 50 | inverted"*. And `hyper-proto-peer` signs a production DID. So the projection lives in neither. |
| 28 | 51 | |
| 29 | | It is also where the reach decision belongs. `favela-markdown/lib/reach.js` exists because that |
| 30 | | check "would otherwise be written three times… and the third one would eventually be written |
| 31 | | wrong". One component asks once and fans out. |
| 52 | It is also where the reach decision belongs. `favela-markdown/lib/reach.js` exists because that check |
| 53 | would otherwise be written three times and the third one would eventually be written wrong. One |
| 54 | component asks once and fans out. |
| 32 | 55 | |
| 33 | 56 | ## Consent, then capability |
| 34 | 57 | |
| 35 | 58 | Two independent gates, and both must pass: |
| 36 | 59 | |
| 37 | | - **`maxReach(roomType, verb)`** — what a room MAY reach. A followers room cannot reach bsky at all, |
| 60 | - **`maxReach(roomType, verb)`** — what a room *may* reach. A followers room cannot reach bsky at all, |
| 38 | 61 | because atproto has no addressing and a projection it cannot restrict is a leak. |
| 39 | | - **the identity's `reach` preference** — what its owner has ASKED for, set in favela-surface's |
| 40 | | settings and stored in the peer's own feed. Read every cycle: unticking a network withdraws |
| 41 | | consent, and a cached copy would keep publishing. |
| 62 | - **the identity's `reach` preference** — what its owner has *asked* for, set in favela-surface's |
| 63 | settings and stored in the peer's own feed. Read every cycle: unticking a network withdraws consent, |
| 64 | and a cached copy would keep publishing. |
| 65 | |
| 66 | A failure to read either is off. Unknown consent is not consent. |
| 67 | |
| 68 | ## API |
| 69 | |
| 70 | Every planner is pure — no network, no store — so what gets published is testable on its own. The |
| 71 | caller does the I/O. |
| 72 | |
| 73 | ### `lib/plan.js` |
| 74 | |
| 75 | `planRecord(row, ctx) -> { networks, bsky, fediverse }`. One reach decision, two projections; each |
| 76 | network derives from the markdown, never from the other's output. |
| 77 | |
| 78 | | | | |
| 79 | |---|---| |
| 80 | | `MAX_IMAGES` | 4 | |
| 81 | | `MAX_IMAGE_BYTES` | 2000000 | |
| 82 | | `selectImages(described)` | which described images may be sent | |
| 83 | | `samePost(desired, actual)` | compared against the repo, never a local note of what was last sent | |
| 84 | |
| 85 | Withheld, never truncated, and withheld, never resized. Cutting a post at 300 graphemes publishes |
| 86 | something the author did not write, under their name, with nothing to say it was cut; shrinking a |
| 87 | photo publishes a degraded copy the same way. The canonical record stays whole on favela and the loss |
| 88 | is named. bsky's cap is 300 **graphemes** and 3000 bytes — an emoji is one grapheme and four bytes, |
| 89 | and counting the wrong one rejects valid posts. |
| 90 | |
| 91 | Images are named off the same parse `render` uses, never off a regex over the stripped text, because |
| 92 | `strip()` removes an image entirely and by then there is no evidence a picture was in the post. They |
| 93 | are named, not uploaded: resolving an oid to a blob ref is I/O. |
| 94 | |
| 95 | bsky gets plain text plus facets; the fediverse gets HTML, because AS2 `content` is HTML and Mastodon |
| 96 | renders a useful subset of it. Rendering from the markdown is the entire point of planning both — |
| 97 | headings, lists, emphasis and links cannot survive a trip through bsky's plain `text`. |
| 98 | |
| 99 | `createdAt` is the author's time, unchanged. Stamping projection time would make every backfilled |
| 100 | record claim to be new. |
| 101 | |
| 102 | ### `lib/plan-edge.js` |
| 103 | |
| 104 | `planEdge(edge, ctx) -> { project, collection, rkey, record, losses }`. |
| 105 | |
| 106 | | constant | | |
| 107 | |---|---| |
| 108 | | `LIKE_COLLECTION` | `app.bsky.feed.like` | |
| 109 | | `REPOST_COLLECTION` | `app.bsky.feed.repost` | |
| 110 | | `POST_COLLECTION` | `app.bsky.feed.post` — replies | |
| 42 | 111 | |
| 43 | | A failure to read either is OFF. Unknown consent is not consent. |
| 112 | The signed set is frozen at like, repost, follow, block, reply. This handles the three whose subject is |
| 113 | a **record**. `follow` and `block` name an actor, and on atproto an actor is a DID — a favela feed key |
| 114 | is not one, so those wait on the cross-network identity edge. |
| 44 | 115 | |
| 45 | | ## What it does not do yet |
| 116 | Our own records only. A subject we authored resolves by pure function and its CID is computable from |
| 117 | the record's own bytes; a foreign subject needs the naming decision hyper-social #71 §8 calls "the one |
| 118 | irreversible decision", and nothing here forces it. |
| 119 | |
| 120 | `ctx.subject` is supplied by the caller and must be **synchronous** — `planEdge` is pure and calls it |
| 121 | inline, so an async resolver would put a Promise where the lexicon wants `{uri, cid}` and publish a |
| 122 | like pointing at nothing. |
| 123 | |
| 124 | Every refusal is named. A `planEdge` returning a bare `null` is a silence the design forbids. |
| 125 | |
| 126 | ### `lib/profile.js` |
| 127 | |
| 128 | `planProfile({ name, bio, avatar, banner })`, `sameProfile(desired, actual)`. |
| 129 | |
| 130 | | | | |
| 131 | |---|---| |
| 132 | | `MAX_NAME_GRAPHEMES` | 64 | |
| 133 | | `MAX_DESCRIPTION_GRAPHEMES` | 256 | |
| 134 | | `MAX_PROFILE_IMAGE_BYTES` | 1000000 | |
| 135 | | `BSKY_IMAGE_TYPES` | `image/png`, `image/jpeg` | |
| 136 | |
| 137 | It decides, it does not upload: images are named by oid and resolved to blob refs by the caller. |
| 138 | |
| 139 | ### `lib/project.js` |
| 140 | |
| 141 | The pass itself, with no transport in it — it has to run in two places, here against unix sockets and |
| 142 | inside the handset worklet, where bsky records are authored and signed next to the DID key because that |
| 143 | key is on the phone. It used to live in `index.js` next to argv parsing and `process.exit`, so the only |
| 144 | way to run it on a phone was to write it a second time. |
| 145 | |
| 146 | | | | |
| 147 | |---|---| |
| 148 | | `projectCycle({ peer, bsky, roomType, send, limit, reproject, log })` | one full pass | |
| 149 | | `reconcileProfile(peer, bsky, reach, send, log)` | bring the bsky profile into line | |
| 150 | | `buildEmbed(peer, bsky, imgs)` | turn the oids `planBsky` named into `app.bsky.embed.images` | |
| 151 | |
| 152 | `index.js` owns the process and this owns the projection. The split is exactly the exit code: a refusal |
| 153 | to send must end a CLI run non-zero and must **not** end a worklet, where exiting kills the app someone |
| 154 | is holding. One cycle never throws the watch loop — a peer restarting, a sink respawning on OTA, a |
| 155 | socket that vanished are all ordinary. |
| 156 | |
| 157 | ### `lib/bsky.js` |
| 158 | |
| 159 | `class BskySink(sock)` — hpp's control socket. `send`, `have`, `getRecord`, `putRecord`, `getProfile`, |
| 160 | `putProfile`, `uploadBlob`, and a `configured` getter. Unconfigured, every method refuses rather than |
| 161 | throwing. |
| 162 | |
| 163 | ### `lib/peer.js`, `lib/tid.js` |
| 164 | |
| 165 | `FavelaPeer` is re-exported from `favela-identity/lib/peer-client.js` — one copy of the wire format, |
| 166 | because ap-bridge reads the same records to project the fediverse and a protocol client written twice |
| 167 | drifts. `tidFor(name)` pairs a favela record name with an atproto rkey, which is what makes the two |
| 168 | names pure functions of each other. |
| 169 | |
| 170 | ## What it does not do |
| 171 | |
| 172 | - **Fediverse delivery.** The plan is computed and reported, and this component sends to bsky only. |
| 173 | The network itself is not unreached: `ap-bridge` depends on `favela-peer` directly, reads the same |
| 174 | records through the same client, holds the RSA key and signs at delivery. The gap is that one |
| 175 | component plans a projection another performs, and the plan here is reported rather than omitted so |
| 176 | that stays visible. |
| 177 | - **Facets.** Ours are `{byteStart, byteEnd, type, value}` where value is a `favela://` URI; atproto |
| 178 | wants `{index, features:[{$type, did}]}`, so projecting one means resolving a favela identity root to |
| 179 | a `did:plc:`. Mentions travel as plain text and the loss is named. |
| 180 | - **Fediverse attachments.** ap-bridge has `projectAttachments`; wiring it needs the blob to be |
| 181 | reachable from there. bsky images do project — `buildEmbed` uploads them through |
| 182 | `BskySink.uploadBlob`. |
| 183 | - **`follow` and `block`.** See `lib/plan-edge.js`. |
| 184 | |
| 185 | ## Test |
| 186 | |
| 187 | ```sh |
| 188 | npm test # brittle-bare test/*.test.js |
| 189 | ``` |
| 46 | 190 | |
| 47 | | - **fediverse delivery.** The plan is computed and reported; ap-bridge holds the RSA key and signs |
| 48 | | at delivery, and that wiring is the next step. Reported rather than omitted so the gap is visible. |
| 49 | | - **facets.** Ours carry `favela://<root>`; atproto wants a `did:plc:`. Mentions travel as plain |
| 50 | | text and say so. |
| 51 | | - **blobs.** hpp exposes `upload-blob`; nothing calls it. A post with a picture sends its text and |
| 52 | | reports the picture lost. |
| 191 | 52 tests, 154 asserts, ~0.3 s. Every planner is pure, so the whole decision surface is covered without |
| 192 | a socket. |
| 53 | 193 | |
| 54 | 194 | ## License |
| 55 | 195 | |
| 56 | | Not yet declared. |
| 196 | Not yet declared — `package.json` carries no `license` field and there is no LICENSE file. |