One AT Protocol request, named once
`frq.atproto.dart` was almost all composition already: `resolve-handle`, `pds-endpoint` and `create-session` are core's `-req`/`-parse` pairs with an await between them and nothing of dart:io in them. So they move to `frq.atproto.http` and only `fetch` is installed — one function per backend, rather than a second answer to what a handle resolves to. `frq.atproto.web` is that function over XMLHttpRequest. It resolves on a 4xx instead of throwing, because core's `-parse` reads the error JSON a PDS sends and turns it into the message the connect screen shows; throwing on status would replace a good message with a worse one. This is the sign-in that never involves freeq's broker — an app password reaches the reader's own PDS, and freeq is handed only the token that PDS minted. What it cannot do anything about is CORS, which is the far side's decision, so the error says so by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a0dbf2e parent: 30d5fae modified
flutter/src/frq/atproto/dart.cljd +9 -24 | @@ -12,7 +12,7 @@ | ||
| 12 | 12 | the answers are portable, and only who waits for them is not." |
| 13 | 13 | (:require ["dart:convert" :as conv] |
| 14 | 14 | ["dart:io" :as io] |
| 15 | - [frq.atproto.core :as core])) | |
| 15 | + [frq.atproto.http :as http])) | |
| 16 | 16 | |
| 17 | 17 | (defonce ^:private client (io/HttpClient.)) |
| 18 | 18 | |
| @@ -37,26 +37,11 @@ | ||
| 37 | 37 | (let [^io/HttpClientResponse resp (await (.close req))] |
| 38 | 38 | (await (.join (.transform resp (.-decoder conv/utf8))))))) |
| 39 | 39 | |
| 40 | -(defn ^:async resolve-handle | |
| 41 | - "A handle (alice.bsky.social) to its DID. A DID passes through untouched." | |
| 42 | - [handle] | |
| 43 | - (let [req (core/resolve-handle-req handle)] | |
| 44 | - (core/resolve-handle-parse handle (when req (await (fetch req)))))) | |
| 45 | - | |
| 46 | -(defn ^:async pds-endpoint | |
| 47 | - "The DID's PDS service endpoint, from its DID document." | |
| 48 | - [did] | |
| 49 | - (core/pds-endpoint-parse did (await (fetch (core/pds-doc-req did))))) | |
| 50 | - | |
| 51 | -(defn ^:async create-session | |
| 52 | - "Sign in to the PDS with an app password. Returns | |
| 53 | - {:did :handle :access-jwt :pds}. | |
| 54 | - | |
| 55 | - The password goes to the user's own PDS and nowhere else — freeq never sees | |
| 56 | - it, and verifies the token it gets by asking that same PDS." | |
| 57 | - [identifier password] | |
| 58 | - (let [did (await (resolve-handle identifier)) | |
| 59 | - pds (await (pds-endpoint did))] | |
| 60 | - (core/create-session-parse | |
| 61 | - identifier did pds | |
| 62 | - (await (fetch (core/create-session-req pds identifier password)))))) | |
| 40 | +(defn install! | |
| 41 | + "Register the dart:io transport. The three functions that used to live below | |
| 42 | + this are `frq.atproto.http`'s now: they were `frq.atproto.core`'s `-req` and | |
| 43 | + `-parse` with an await between them and nothing of dart:io in them, so a | |
| 44 | + second copy under the browser backend would have been a second answer to | |
| 45 | + what a handle resolves to." | |
| 46 | + [] | |
| 47 | + (http/install! {:fetch fetch})) | |
| @@ -12,7 +12,7 @@ | |||
| 12 | the answers are portable, and only who waits for them is not." | 12 | the answers are portable, and only who waits for them is not." |
| 13 | (:require ["dart:convert" :as conv] | 13 | (:require ["dart:convert" :as conv] |
| 14 | ["dart:io" :as io] | 14 | ["dart:io" :as io] |
| 15 | - [frq.atproto.core :as core])) | 15 | + [frq.atproto.http :as http])) |
| 16 | 16 | ||
| 17 | (defonce ^:private client (io/HttpClient.)) | 17 | (defonce ^:private client (io/HttpClient.)) |
| 18 | 18 | ||
| @@ -37,26 +37,11 @@ | |||
| 37 | (let [^io/HttpClientResponse resp (await (.close req))] | 37 | (let [^io/HttpClientResponse resp (await (.close req))] |
| 38 | (await (.join (.transform resp (.-decoder conv/utf8))))))) | 38 | (await (.join (.transform resp (.-decoder conv/utf8))))))) |
| 39 | 39 | ||
| 40 | -(defn ^:async resolve-handle | 40 | +(defn install! |
| 41 | - "A handle (alice.bsky.social) to its DID. A DID passes through untouched." | 41 | + "Register the dart:io transport. The three functions that used to live below |
| 42 | - [handle] | 42 | + this are `frq.atproto.http`'s now: they were `frq.atproto.core`'s `-req` and |
| 43 | - (let [req (core/resolve-handle-req handle)] | 43 | + `-parse` with an await between them and nothing of dart:io in them, so a |
| 44 | - (core/resolve-handle-parse handle (when req (await (fetch req)))))) | 44 | + second copy under the browser backend would have been a second answer to |
| 45 | - | 45 | + what a handle resolves to." |
| 46 | -(defn ^:async pds-endpoint | 46 | + [] |
| 47 | - "The DID's PDS service endpoint, from its DID document." | 47 | + (http/install! {:fetch fetch})) |
| 48 | - [did] | ||
| 49 | - (core/pds-endpoint-parse did (await (fetch (core/pds-doc-req did))))) | ||
| 50 | - | ||
| 51 | -(defn ^:async create-session | ||
| 52 | - "Sign in to the PDS with an app password. Returns | ||
| 53 | - {:did :handle :access-jwt :pds}. | ||
| 54 | - | ||
| 55 | - The password goes to the user's own PDS and nowhere else — freeq never sees | ||
| 56 | - it, and verifies the token it gets by asking that same PDS." | ||
| 57 | - [identifier password] | ||
| 58 | - (let [did (await (resolve-handle identifier)) | ||
| 59 | - pds (await (pds-endpoint did))] | ||
| 60 | - (core/create-session-parse | ||
| 61 | - identifier did pds | ||
| 62 | - (await (fetch (core/create-session-req pds identifier password)))))) | ||
added
flutter/src/frq/atproto/http.cljd +57 -0 | new file mode 100644 | ||
| @@ -0,0 +1,57 @@ | ||
| 1 | +(ns frq.atproto.http | |
| 2 | + "One AT Protocol request, named once so both Flutter targets can make it. | |
| 3 | + | |
| 4 | + The fourth seam, and the thinnest: `frq.atproto.dart` was already almost all | |
| 5 | + composition. `resolve-handle`, `pds-endpoint` and `create-session` are | |
| 6 | + `frq.atproto.core`'s `-req`/`-parse` pairs with an await in the middle and | |
| 7 | + nothing platform-specific in them at all, so they live here and only `fetch` | |
| 8 | + is installed. dart:io reaches a PDS with `HttpClient`; a browser reaches it | |
| 9 | + with XMLHttpRequest, and is subject to CORS where the native client is not. | |
| 10 | + | |
| 11 | + This is the sign-in path that does not involve freeq's auth broker: an app | |
| 12 | + password goes to the reader's own PDS, and what freeq is handed is the token | |
| 13 | + that PDS minted. `frq.oauth.handoff` is the other half — the one that does | |
| 14 | + involve the broker, and therefore the one the broker's return_to allowlist | |
| 15 | + has an opinion about." | |
| 16 | + (:require [frq.atproto.core :as core])) | |
| 17 | + | |
| 18 | +(defonce ^:private impl (atom {})) | |
| 19 | + | |
| 20 | +(defn install! [m] (swap! impl merge m) nil) | |
| 21 | + | |
| 22 | +(defn ^:async fetch | |
| 23 | + "Perform one `-req` descriptor and return the body as a string. | |
| 24 | + | |
| 25 | + `body` nil is a GET. Always https: every host core names is an AT Protocol | |
| 26 | + service, and there is no plaintext variant of any of them. | |
| 27 | + | |
| 28 | + Awaited here rather than handed back, for `frq.net/connect!`'s reason: cljd | |
| 29 | + cannot see that a dynamic call's return is awaitable." | |
| 30 | + [req] | |
| 31 | + (if-let [f (:fetch @impl)] | |
| 32 | + (await (f req)) | |
| 33 | + (throw (ex-info "frq.atproto: no transport installed" {:op :fetch})))) | |
| 34 | + | |
| 35 | +(defn ^:async resolve-handle | |
| 36 | + "A handle (alice.bsky.social) to its DID. A DID passes through untouched." | |
| 37 | + [handle] | |
| 38 | + (let [req (core/resolve-handle-req handle)] | |
| 39 | + (core/resolve-handle-parse handle (when req (await (fetch req)))))) | |
| 40 | + | |
| 41 | +(defn ^:async pds-endpoint | |
| 42 | + "The DID's PDS service endpoint, from its DID document." | |
| 43 | + [did] | |
| 44 | + (core/pds-endpoint-parse did (await (fetch (core/pds-doc-req did))))) | |
| 45 | + | |
| 46 | +(defn ^:async create-session | |
| 47 | + "Sign in to the PDS with an app password. Returns | |
| 48 | + {:did :handle :access-jwt :pds}. | |
| 49 | + | |
| 50 | + The password goes to the user's own PDS and nowhere else — freeq never sees | |
| 51 | + it, and verifies the token it gets by asking that same PDS." | |
| 52 | + [identifier password] | |
| 53 | + (let [did (await (resolve-handle identifier)) | |
| 54 | + pds (await (pds-endpoint did))] | |
| 55 | + (core/create-session-parse | |
| 56 | + identifier did pds | |
| 57 | + (await (fetch (core/create-session-req pds identifier password)))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,57 @@ | |||
| 1 | +(ns frq.atproto.http | ||
| 2 | + "One AT Protocol request, named once so both Flutter targets can make it. | ||
| 3 | + | ||
| 4 | + The fourth seam, and the thinnest: `frq.atproto.dart` was already almost all | ||
| 5 | + composition. `resolve-handle`, `pds-endpoint` and `create-session` are | ||
| 6 | + `frq.atproto.core`'s `-req`/`-parse` pairs with an await in the middle and | ||
| 7 | + nothing platform-specific in them at all, so they live here and only `fetch` | ||
| 8 | + is installed. dart:io reaches a PDS with `HttpClient`; a browser reaches it | ||
| 9 | + with XMLHttpRequest, and is subject to CORS where the native client is not. | ||
| 10 | + | ||
| 11 | + This is the sign-in path that does not involve freeq's auth broker: an app | ||
| 12 | + password goes to the reader's own PDS, and what freeq is handed is the token | ||
| 13 | + that PDS minted. `frq.oauth.handoff` is the other half — the one that does | ||
| 14 | + involve the broker, and therefore the one the broker's return_to allowlist | ||
| 15 | + has an opinion about." | ||
| 16 | + (:require [frq.atproto.core :as core])) | ||
| 17 | + | ||
| 18 | +(defonce ^:private impl (atom {})) | ||
| 19 | + | ||
| 20 | +(defn install! [m] (swap! impl merge m) nil) | ||
| 21 | + | ||
| 22 | +(defn ^:async fetch | ||
| 23 | + "Perform one `-req` descriptor and return the body as a string. | ||
| 24 | + | ||
| 25 | + `body` nil is a GET. Always https: every host core names is an AT Protocol | ||
| 26 | + service, and there is no plaintext variant of any of them. | ||
| 27 | + | ||
| 28 | + Awaited here rather than handed back, for `frq.net/connect!`'s reason: cljd | ||
| 29 | + cannot see that a dynamic call's return is awaitable." | ||
| 30 | + [req] | ||
| 31 | + (if-let [f (:fetch @impl)] | ||
| 32 | + (await (f req)) | ||
| 33 | + (throw (ex-info "frq.atproto: no transport installed" {:op :fetch})))) | ||
| 34 | + | ||
| 35 | +(defn ^:async resolve-handle | ||
| 36 | + "A handle (alice.bsky.social) to its DID. A DID passes through untouched." | ||
| 37 | + [handle] | ||
| 38 | + (let [req (core/resolve-handle-req handle)] | ||
| 39 | + (core/resolve-handle-parse handle (when req (await (fetch req)))))) | ||
| 40 | + | ||
| 41 | +(defn ^:async pds-endpoint | ||
| 42 | + "The DID's PDS service endpoint, from its DID document." | ||
| 43 | + [did] | ||
| 44 | + (core/pds-endpoint-parse did (await (fetch (core/pds-doc-req did))))) | ||
| 45 | + | ||
| 46 | +(defn ^:async create-session | ||
| 47 | + "Sign in to the PDS with an app password. Returns | ||
| 48 | + {:did :handle :access-jwt :pds}. | ||
| 49 | + | ||
| 50 | + The password goes to the user's own PDS and nowhere else — freeq never sees | ||
| 51 | + it, and verifies the token it gets by asking that same PDS." | ||
| 52 | + [identifier password] | ||
| 53 | + (let [did (await (resolve-handle identifier)) | ||
| 54 | + pds (await (pds-endpoint did))] | ||
| 55 | + (core/create-session-parse | ||
| 56 | + identifier did pds | ||
| 57 | + (await (fetch (core/create-session-req pds identifier password)))))) | ||
added
flutter/src/frq/atproto/web.cljd +63 -0 | new file mode 100644 | ||
| @@ -0,0 +1,63 @@ | ||
| 1 | +(ns frq.atproto.web | |
| 2 | + "The browser's AT Protocol transport: one XMLHttpRequest per request. | |
| 3 | + | |
| 4 | + `frq.atproto.dart` with `HttpClient` swapped for the only HTTP a page has. | |
| 5 | + Built by hand rather than through `HttpRequest.request`, for the reason | |
| 6 | + `frq.io.web/fetch-text!` is: that helper wants its headers as a Dart | |
| 7 | + `Map<String,String>` and a ClojureDart map is not one, where | |
| 8 | + `setRequestHeader` per pair needs no conversion. | |
| 9 | + | |
| 10 | + Two differences from the native client that are the browser's and not ours. | |
| 11 | + | |
| 12 | + `user-agent` is not sent: browsers reserve it and throw if told otherwise, | |
| 13 | + where the dart:io client sends `frq`. No AT Protocol endpoint cares. | |
| 14 | + | |
| 15 | + CORS decides whether this works at all, and it is the far side's decision. | |
| 16 | + Bluesky's own PDS answers browser clients — their app is a web app — so | |
| 17 | + `com.atproto.server.createSession` against bsky.social is reachable. A | |
| 18 | + self-hosted PDS that sends no `Access-Control-Allow-Origin` is not, and the | |
| 19 | + failure arrives as a request that never happened rather than as a status | |
| 20 | + code: the browser refuses it before it leaves. That is why the error here | |
| 21 | + says CORS out loud rather than reporting a network fault — it is the | |
| 22 | + likeliest cause by far, and the one the console will confirm." | |
| 23 | + (:require ["dart:async" :as async] | |
| 24 | + ["dart:html" :as html] | |
| 25 | + [frq.atproto.http :as http])) | |
| 26 | + | |
| 27 | +(defn ^:private ^:async fetch | |
| 28 | + "One request, its body as a string. `body` nil is a GET. | |
| 29 | + | |
| 30 | + Resolves with the body for any status the far side actually returned, | |
| 31 | + including a 4xx: `frq.atproto.core`'s `-parse` functions read the error JSON | |
| 32 | + a PDS sends and turn it into the message the connect screen shows, so | |
| 33 | + throwing on status here would replace a good message with a worse one." | |
| 34 | + [{:keys [host path body]}] | |
| 35 | + (let [url (str "https://" host path) | |
| 36 | + req (html/HttpRequest.) | |
| 37 | + done (async/Completer.)] | |
| 38 | + (.open req (if body "POST" "GET") url) | |
| 39 | + ;; No user-agent: the browser reserves it. `accept` and `content-type` are | |
| 40 | + ;; ours to set and are what the PDS reads. | |
| 41 | + (.setRequestHeader req "accept" "application/json") | |
| 42 | + (when body | |
| 43 | + (.setRequestHeader req "content-type" "application/json")) | |
| 44 | + (.addEventListener req "load" | |
| 45 | + (fn [_] | |
| 46 | + (when-not (.-isCompleted done) | |
| 47 | + (.complete done (str (.-responseText req)))) | |
| 48 | + nil)) | |
| 49 | + (.addEventListener req "error" | |
| 50 | + (fn [_] | |
| 51 | + (when-not (.-isCompleted done) | |
| 52 | + (.completeError | |
| 53 | + done | |
| 54 | + (Exception. | |
| 55 | + (str "Could not reach " host | |
| 56 | + " — the browser refused the request," | |
| 57 | + " which for a PDS is usually CORS")))) | |
| 58 | + nil)) | |
| 59 | + (if body (.send req (str body)) (.send req)) | |
| 60 | + (await (.-future done)))) | |
| 61 | + | |
| 62 | +(defn install! [] | |
| 63 | + (http/install! {:fetch fetch})) | |
| new file mode 100644 | |||
| @@ -0,0 +1,63 @@ | |||
| 1 | +(ns frq.atproto.web | ||
| 2 | + "The browser's AT Protocol transport: one XMLHttpRequest per request. | ||
| 3 | + | ||
| 4 | + `frq.atproto.dart` with `HttpClient` swapped for the only HTTP a page has. | ||
| 5 | + Built by hand rather than through `HttpRequest.request`, for the reason | ||
| 6 | + `frq.io.web/fetch-text!` is: that helper wants its headers as a Dart | ||
| 7 | + `Map<String,String>` and a ClojureDart map is not one, where | ||
| 8 | + `setRequestHeader` per pair needs no conversion. | ||
| 9 | + | ||
| 10 | + Two differences from the native client that are the browser's and not ours. | ||
| 11 | + | ||
| 12 | + `user-agent` is not sent: browsers reserve it and throw if told otherwise, | ||
| 13 | + where the dart:io client sends `frq`. No AT Protocol endpoint cares. | ||
| 14 | + | ||
| 15 | + CORS decides whether this works at all, and it is the far side's decision. | ||
| 16 | + Bluesky's own PDS answers browser clients — their app is a web app — so | ||
| 17 | + `com.atproto.server.createSession` against bsky.social is reachable. A | ||
| 18 | + self-hosted PDS that sends no `Access-Control-Allow-Origin` is not, and the | ||
| 19 | + failure arrives as a request that never happened rather than as a status | ||
| 20 | + code: the browser refuses it before it leaves. That is why the error here | ||
| 21 | + says CORS out loud rather than reporting a network fault — it is the | ||
| 22 | + likeliest cause by far, and the one the console will confirm." | ||
| 23 | + (:require ["dart:async" :as async] | ||
| 24 | + ["dart:html" :as html] | ||
| 25 | + [frq.atproto.http :as http])) | ||
| 26 | + | ||
| 27 | +(defn ^:private ^:async fetch | ||
| 28 | + "One request, its body as a string. `body` nil is a GET. | ||
| 29 | + | ||
| 30 | + Resolves with the body for any status the far side actually returned, | ||
| 31 | + including a 4xx: `frq.atproto.core`'s `-parse` functions read the error JSON | ||
| 32 | + a PDS sends and turn it into the message the connect screen shows, so | ||
| 33 | + throwing on status here would replace a good message with a worse one." | ||
| 34 | + [{:keys [host path body]}] | ||
| 35 | + (let [url (str "https://" host path) | ||
| 36 | + req (html/HttpRequest.) | ||
| 37 | + done (async/Completer.)] | ||
| 38 | + (.open req (if body "POST" "GET") url) | ||
| 39 | + ;; No user-agent: the browser reserves it. `accept` and `content-type` are | ||
| 40 | + ;; ours to set and are what the PDS reads. | ||
| 41 | + (.setRequestHeader req "accept" "application/json") | ||
| 42 | + (when body | ||
| 43 | + (.setRequestHeader req "content-type" "application/json")) | ||
| 44 | + (.addEventListener req "load" | ||
| 45 | + (fn [_] | ||
| 46 | + (when-not (.-isCompleted done) | ||
| 47 | + (.complete done (str (.-responseText req)))) | ||
| 48 | + nil)) | ||
| 49 | + (.addEventListener req "error" | ||
| 50 | + (fn [_] | ||
| 51 | + (when-not (.-isCompleted done) | ||
| 52 | + (.completeError | ||
| 53 | + done | ||
| 54 | + (Exception. | ||
| 55 | + (str "Could not reach " host | ||
| 56 | + " — the browser refused the request," | ||
| 57 | + " which for a PDS is usually CORS")))) | ||
| 58 | + nil)) | ||
| 59 | + (if body (.send req (str body)) (.send req)) | ||
| 60 | + (await (.-future done)))) | ||
| 61 | + | ||
| 62 | +(defn install! [] | ||
| 63 | + (http/install! {:fetch fetch})) | ||
modified
flutter/src/frq/avatars/dart.cljd +1 -1 | @@ -18,7 +18,7 @@ | ||
| 18 | 18 | guest: `frq.profile/actor` answers nil for a nick that is not handle-shaped, |
| 19 | 19 | and nil never reaches here." |
| 20 | 20 | (:require ["dart:async" :as async] |
| 21 | - [frq.atproto.dart :as atproto] | |
| 21 | + [frq.atproto.http :as atproto] | |
| 22 | 22 | [frq.profile :as profile])) |
| 23 | 23 | |
| 24 | 24 | ;; actor -> {:status :loading | :ready | :failed, :url when there is one}. |
| @@ -18,7 +18,7 @@ | |||
| 18 | guest: `frq.profile/actor` answers nil for a nick that is not handle-shaped, | 18 | guest: `frq.profile/actor` answers nil for a nick that is not handle-shaped, |
| 19 | and nil never reaches here." | 19 | and nil never reaches here." |
| 20 | (:require ["dart:async" :as async] | 20 | (:require ["dart:async" :as async] |
| 21 | - [frq.atproto.dart :as atproto] | 21 | + [frq.atproto.http :as atproto] |
| 22 | [frq.profile :as profile])) | 22 | [frq.profile :as profile])) |
| 23 | 23 | ||
| 24 | ;; actor -> {:status :loading | :ready | :failed, :url when there is one}. | 24 | ;; actor -> {:status :loading | :ready | :failed, :url when there is one}. |