From 8e3b2314655172dd2b8f4f988008c887a482d090 Mon Sep 17 00:00:00 2001
From: sloane <git@sloanelybutsurely.com>
Date: Fri, 2 May 2025 07:32:02 -0400
Subject: [PATCH] add support for bluesky syndication

---
 .formatter.exs                                |  2 +-
 lib/core/posts.ex                             |  6 +-
 lib/core/posts/post.ex                        |  7 +-
 lib/core/syndication.ex                       | 96 ++++++++++++++++++-
 lib/core/syndication/bluesky_account.ex       | 47 +++++++++
 lib/core/syndication/bluesky_client.ex        | 50 ++++++++++
 lib/core/syndication/bluesky_post.ex          |  9 ++
 lib/schema.ex                                 |  4 +-
 lib/schema/bluesky_account.ex                 | 20 ++++
 lib/schema/bluesky_post.ex                    | 13 +++
 lib/schema/post.ex                            |  2 +
 lib/schema/user.ex                            |  1 +
 .../controllers/status_html/show.html.heex    | 21 +++-
 lib/web/live/admin_post_live.html.heex        |  6 ++
 lib/web/live/admin_syndication_live.ex        | 15 +++
 lib/web/live/admin_syndication_live.html.heex | 13 ++-
 mix.exs                                       |  1 +
 mix.lock                                      |  2 +
 .../20250502113428_add_bluesky_tables.exs     | 44 +++++++++
 19 files changed, 342 insertions(+), 17 deletions(-)
 create mode 100644 lib/core/syndication/bluesky_account.ex
 create mode 100644 lib/core/syndication/bluesky_client.ex
 create mode 100644 lib/core/syndication/bluesky_post.ex
 create mode 100644 lib/schema/bluesky_account.ex
 create mode 100644 lib/schema/bluesky_post.ex
 create mode 100644 priv/repo/migrations/20250502113428_add_bluesky_tables.exs

diff --git a/.formatter.exs b/.formatter.exs
index 2f55c0e..98279b9 100644
--- a/.formatter.exs
+++ b/.formatter.exs
@@ -1,5 +1,5 @@
 [
-  import_deps: [:oban, :ecto, :ecto_sql, :phoenix],
+  import_deps: [:oban, :ecto, :ecto_sql, :phoenix, :tesla],
   subdirectories: ["priv/*/migrations"],
   plugins: [Phoenix.LiveView.HTMLFormatter],
   inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
diff --git a/lib/core/posts.ex b/lib/core/posts.ex
index ba4754c..92b8f88 100644
--- a/lib/core/posts.ex
+++ b/lib/core/posts.ex
@@ -88,9 +88,9 @@ defmodule Core.Posts do
     with {:ok, post} <-
            post
            |> Post.publish_changeset(published_at)
-           |> Core.Repo.update() do
-      Core.Syndication.syndicate_to_mastodon(post)
-
+           |> Core.Repo.update(),
+         {:ok, post} <- Core.Syndication.syndicate_to_mastodon(post),
+         {:ok, post} <- Core.Syndication.syndicate_to_bluesky(post) do
       {:ok, post}
     end
   end
diff --git a/lib/core/posts/post.ex b/lib/core/posts/post.ex
index 5aa6a28..da9203a 100644
--- a/lib/core/posts/post.ex
+++ b/lib/core/posts/post.ex
@@ -14,7 +14,8 @@ defmodule Core.Posts.Post do
         :body,
         :deleted_at,
         :published_at,
-        :syndicate_to_mastodon
+        :syndicate_to_mastodon,
+        :syndicate_to_bluesky
       ])
       |> validate_required([:kind], message: "must have a kind")
       |> validate_required([:body], message: "must have a body")
@@ -90,7 +91,9 @@ defmodule Core.Posts.Post do
         as: :posts,
         left_join: mp in assoc(p, :mastodon_post),
         as: :mastodon_posts,
-        preload: [mastodon_post: mp]
+        left_join: bp in assoc(p, :bluesky_post),
+        as: :bluesky_posts,
+        preload: [mastodon_post: mp, bluesky_post: bp]
     end
 
     def current(query \\ base()) do
diff --git a/lib/core/syndication.ex b/lib/core/syndication.ex
index 02bc49d..981b95b 100644
--- a/lib/core/syndication.ex
+++ b/lib/core/syndication.ex
@@ -1,6 +1,8 @@
 defmodule Core.Syndication do
   alias __MODULE__
 
+  ## mastodon
+
   @mastodon_instance Application.compile_env!(:sloanely_but_surely, [
                        Core.Syndication,
                        :mastodon_instance
@@ -33,13 +35,13 @@ defmodule Core.Syndication do
       })
       |> Core.Repo.insert()
 
-      post
+      {:ok, post}
     else
-      post
+      {:ok, post}
     end
   end
 
-  defp get_mastodon_access_token do
+  defp get_mastodon_access_token! do
     mastodon_account = Core.Repo.one!(Schema.MastodonAccount)
     mastodon_account.access_token
   end
@@ -47,7 +49,93 @@ defmodule Core.Syndication do
   defp build_mastodon_client_conn do
     %MastodonClient.Conn{
       instance: @mastodon_instance,
-      access_token: get_mastodon_access_token()
+      access_token: get_mastodon_access_token!()
     }
   end
+
+  ## bluesky
+
+  def get_bluesky_account(%Schema.User{} = user) do
+    Core.Repo.get_by(Schema.BlueskyAccount, user_id: user.id)
+  end
+
+  def save_bluesky_account(user, identifier, password) do
+    with {:ok, session_resp} <-
+           Syndication.BlueskyClient.create_session(identifier, password),
+         {:ok, attrs} <- parse_bluesky_token_resp(session_resp) do
+      user
+      |> Ecto.build_assoc(:bluesky_account)
+      |> Syndication.BlueskyAccount.create_changeset(attrs)
+      |> Core.Repo.insert(
+        conflict_target: [:user_id],
+        on_conflict: {:replace_all_except, [:id, :inserted_at]}
+      )
+    end
+  end
+
+  def refresh_bluesky_account(%Schema.BlueskyAccount{} = bluesky_account) do
+    with {:ok, refresh_resp} <-
+           Syndication.BlueskyClient.refresh_session(bluesky_account),
+         {:ok, attrs} <- parse_bluesky_token_resp(refresh_resp) do
+      bluesky_account
+      |> Syndication.BlueskyAccount.refresh_changeset(attrs)
+      |> Core.Repo.update()
+    end
+  end
+
+  def syndicate_to_bluesky(%Schema.Post{} = post) do
+    post = Core.Repo.preload(post, [:bluesky_post])
+
+    if post.syndicate_to_bluesky && is_nil(post.bluesky_post) do
+      bluesky_account = get_bluesky_account!()
+
+      with {:ok, resp} <- Syndication.BlueskyClient.post_status(bluesky_account, post.body) do
+        post
+        |> Ecto.build_assoc(:bluesky_post)
+        |> Syndication.BlueskyPost.changeset(resp.body)
+        |> Core.Repo.insert()
+
+        {:ok, post}
+      end
+    else
+      {:ok, post}
+    end
+  end
+
+  def bsky_app_url(%Schema.BlueskyPost{uri: uri}) do
+    [did, id] = Regex.run(~r|at://(did:.*)/app.bsky.feed.post/(.*)|, uri, capture: :all_but_first)
+
+    "https://bsky.app/profile/#{did}/post/#{id}"
+  end
+
+  defp parse_bluesky_token_resp(resp) do
+    with {:ok, %{iat: access_jwt_iat, exp: access_jwt_exp}} <-
+           parse_bluesky_jwt(resp.body["accessJwt"]),
+         {:ok, %{iat: refresh_jwt_iat, exp: refresh_jwt_exp}} <-
+           parse_bluesky_jwt(resp.body["refreshJwt"]) do
+      {:ok,
+       %{
+         handle: resp.body["handle"],
+         did: resp.body["did"],
+         access_jwt: resp.body["accessJwt"],
+         access_jwt_iat: access_jwt_iat,
+         access_jwt_exp: access_jwt_exp,
+         refresh_jwt: resp.body["refreshJwt"],
+         refresh_jwt_iat: refresh_jwt_iat,
+         refresh_jwt_exp: refresh_jwt_exp
+       }}
+    end
+  end
+
+  defp parse_bluesky_jwt(jwt) do
+    with {:ok, claims} <- Joken.peek_claims(jwt),
+         {:ok, iat} <- DateTime.from_unix(claims["iat"]),
+         {:ok, exp} <- DateTime.from_unix(claims["exp"]) do
+      {:ok, %{iat: iat, exp: exp}}
+    end
+  end
+
+  defp get_bluesky_account! do
+    Core.Repo.one!(Schema.BlueskyAccount)
+  end
 end
diff --git a/lib/core/syndication/bluesky_account.ex b/lib/core/syndication/bluesky_account.ex
new file mode 100644
index 0000000..815a2c5
--- /dev/null
+++ b/lib/core/syndication/bluesky_account.ex
@@ -0,0 +1,47 @@
+defmodule Core.Syndication.BlueskyAccount do
+  import Ecto.Changeset
+
+  def create_changeset(%Schema.BlueskyAccount{} = bluesky_account, attrs) do
+    bluesky_account
+    |> cast(attrs, [
+      :handle,
+      :did,
+      :access_jwt,
+      :access_jwt_iat,
+      :access_jwt_exp,
+      :refresh_jwt,
+      :refresh_jwt_iat,
+      :refresh_jwt_exp
+    ])
+    |> validate_required([
+      :handle,
+      :did,
+      :access_jwt,
+      :access_jwt_iat,
+      :access_jwt_exp,
+      :refresh_jwt,
+      :refresh_jwt_iat,
+      :refresh_jwt_exp
+    ])
+  end
+
+  def refresh_changeset(%Schema.BlueskyAccount{} = bluesky_account, attrs) do
+    bluesky_account
+    |> cast(attrs, [
+      :access_jwt,
+      :access_jwt_iat,
+      :access_jwt_exp,
+      :refresh_jwt,
+      :refresh_jwt_iat,
+      :refresh_jwt_exp
+    ])
+    |> validate_required([
+      :access_jwt,
+      :access_jwt_iat,
+      :access_jwt_exp,
+      :refresh_jwt,
+      :refresh_jwt_iat,
+      :refresh_jwt_exp
+    ])
+  end
+end
diff --git a/lib/core/syndication/bluesky_client.ex b/lib/core/syndication/bluesky_client.ex
new file mode 100644
index 0000000..0d54ae0
--- /dev/null
+++ b/lib/core/syndication/bluesky_client.ex
@@ -0,0 +1,50 @@
+defmodule Core.Syndication.BlueskyClient do
+  @middleware [
+    Tesla.Middleware.Logger,
+    {Tesla.Middleware.BaseUrl, "https://bsky.social/xrpc"},
+    Tesla.Middleware.JSON
+  ]
+
+  def new(opts \\ []) do
+    middleware = @middleware
+
+    middleware =
+      case Keyword.fetch(opts, :token) do
+        {:ok, token} ->
+          middleware ++ [{Tesla.Middleware.BearerAuth, token: token}]
+
+        :error ->
+          middleware
+      end
+
+    Tesla.client(middleware)
+  end
+
+  def create_session(identifier, password) do
+    client = new()
+
+    Tesla.post(client, "/com.atproto.server.createSession", %{
+      identifier: identifier,
+      password: password
+    })
+  end
+
+  def refresh_session(%Schema.BlueskyAccount{refresh_jwt: token}) do
+    client = new(token: token)
+
+    Tesla.post(client, "/com.atproto.server.refreshSession", nil)
+  end
+
+  def post_status(%Schema.BlueskyAccount{} = bluesky_account, text) do
+    client = new(token: bluesky_account.access_jwt)
+
+    Tesla.post(client, "/com.atproto.repo.createRecord", %{
+      repo: bluesky_account.did,
+      collection: "app.bsky.feed.post",
+      record: %{
+        text: text,
+        createdAt: DateTime.utc_now() |> DateTime.to_iso8601()
+      }
+    })
+  end
+end
diff --git a/lib/core/syndication/bluesky_post.ex b/lib/core/syndication/bluesky_post.ex
new file mode 100644
index 0000000..6f8bdae
--- /dev/null
+++ b/lib/core/syndication/bluesky_post.ex
@@ -0,0 +1,9 @@
+defmodule Core.Syndication.BlueskyPost do
+  import Ecto.Changeset
+
+  def changeset(%Schema.BlueskyPost{} = bluesky_post, attrs) do
+    bluesky_post
+    |> cast(attrs, [:cid, :uri, :commit])
+    |> validate_required([:cid, :uri, :commit])
+  end
+end
diff --git a/lib/schema.ex b/lib/schema.ex
index bd4adfc..5f27a60 100644
--- a/lib/schema.ex
+++ b/lib/schema.ex
@@ -1,6 +1,8 @@
 defmodule Schema do
   @moduledoc false
-  use Boundary, deps: [], exports: [Post, User, UserToken, MastodonAccount]
+  use Boundary,
+    deps: [],
+    exports: [Post, User, UserToken, MastodonAccount, BlueskyAccount, BlueskyPost]
 
   defmacro __using__(_) do
     quote do
diff --git a/lib/schema/bluesky_account.ex b/lib/schema/bluesky_account.ex
new file mode 100644
index 0000000..74ac573
--- /dev/null
+++ b/lib/schema/bluesky_account.ex
@@ -0,0 +1,20 @@
+defmodule Schema.BlueskyAccount do
+  use Schema
+
+  schema "bluesky_accounts" do
+    field :handle, :string
+    field :did, :string
+
+    field :access_jwt, :string, redact: true
+    field :access_jwt_iat, :utc_datetime
+    field :access_jwt_exp, :utc_datetime
+
+    field :refresh_jwt, :string, redact: true
+    field :refresh_jwt_iat, :utc_datetime
+    field :refresh_jwt_exp, :utc_datetime
+
+    belongs_to :user, Schema.User
+
+    timestamps()
+  end
+end
diff --git a/lib/schema/bluesky_post.ex b/lib/schema/bluesky_post.ex
new file mode 100644
index 0000000..9750117
--- /dev/null
+++ b/lib/schema/bluesky_post.ex
@@ -0,0 +1,13 @@
+defmodule Schema.BlueskyPost do
+  use Schema
+
+  schema "bluesky_posts" do
+    field :cid, :string
+    field :uri, :string
+    field :commit, :map
+
+    belongs_to :post, Schema.Post
+
+    timestamps()
+  end
+end
diff --git a/lib/schema/post.ex b/lib/schema/post.ex
index e913fb4..84b29ed 100644
--- a/lib/schema/post.ex
+++ b/lib/schema/post.ex
@@ -29,8 +29,10 @@ defmodule Schema.Post do
     field :deleted_at, :utc_datetime_usec
 
     field :syndicate_to_mastodon, :boolean
+    field :syndicate_to_bluesky, :boolean
 
     has_one :mastodon_post, Schema.MastodonPost
+    has_one :bluesky_post, Schema.BlueskyPost
 
     timestamps()
   end
diff --git a/lib/schema/user.ex b/lib/schema/user.ex
index 21c4e49..29e294e 100644
--- a/lib/schema/user.ex
+++ b/lib/schema/user.ex
@@ -9,6 +9,7 @@ defmodule Schema.User do
     field :current_password, :string, virtual: true, redact: true
 
     has_one :mastodon_account, Schema.MastodonAccount
+    has_one :bluesky_account, Schema.BlueskyAccount
 
     timestamps(type: :utc_datetime_usec)
   end
diff --git a/lib/web/controllers/status_html/show.html.heex b/lib/web/controllers/status_html/show.html.heex
index bb4743d..a0c6fda 100644
--- a/lib/web/controllers/status_html/show.html.heex
+++ b/lib/web/controllers/status_html/show.html.heex
@@ -55,11 +55,22 @@
         <% end %>
       </div>
       <div class="flex flex-row gap-x-2">
-        <%= if @status.mastodon_post do %>
-          <.link href={@status.mastodon_post.url} target="_blank" class="u-syndication">
-            tech.lgbt
-          </.link>
-        <% end %>
+        <.link
+          :if={@status.mastodon_post}
+          href={@status.mastodon_post.url}
+          target="_blank"
+          class="u-syndication"
+        >
+          tech.lgbt
+        </.link>
+        <.link
+          :if={@status.bluesky_post}
+          href={Core.Syndication.bsky_app_url(@status.bluesky_post)}
+          target="_blank"
+          class="u-syndication"
+        >
+          bsky.app
+        </.link>
       </div>
     </footer>
   </article>
diff --git a/lib/web/live/admin_post_live.html.heex b/lib/web/live/admin_post_live.html.heex
index 92408bf..2bfaf52 100644
--- a/lib/web/live/admin_post_live.html.heex
+++ b/lib/web/live/admin_post_live.html.heex
@@ -31,6 +31,12 @@
       class="self-end"
       field={@form[:syndicate_to_mastodon]}
     />
+    <.input
+      type="checkbox"
+      label="syndicate to bluesky"
+      class="self-end"
+      field={@form[:syndicate_to_bluesky]}
+    />
     <%= if @post.deleted_at do %>
       <.button phx-click="undelete" class="self-end">undelete</.button>
     <% else %>
diff --git a/lib/web/live/admin_syndication_live.ex b/lib/web/live/admin_syndication_live.ex
index d2eb034..23f8c27 100644
--- a/lib/web/live/admin_syndication_live.ex
+++ b/lib/web/live/admin_syndication_live.ex
@@ -3,11 +3,26 @@ defmodule Web.AdminSyndicationLive do
 
   def mount(_params, _session, socket) do
     mastodon_account = Core.Syndication.get_mastodon_account(socket.assigns.current_user)
+    bluesky_account = Core.Syndication.get_bluesky_account(socket.assigns.current_user)
 
     socket =
       socket
       |> assign(:mastodon_account, mastodon_account)
+      |> assign(:bluesky_account, bluesky_account)
 
     {:ok, socket}
   end
+
+  def handle_event(
+        "save_bluesky_account",
+        %{"bluesky_account" => %{"identifier" => identifier, "password" => password}},
+        socket
+      ) do
+    {:ok, bluesky_account} =
+      Core.Syndication.save_bluesky_account(socket.assigns.current_user, identifier, password)
+
+    socket = assign(socket, bluesky_account: bluesky_account)
+
+    {:noreply, socket}
+  end
 end
diff --git a/lib/web/live/admin_syndication_live.html.heex b/lib/web/live/admin_syndication_live.html.heex
index 9522e66..4d9af31 100644
--- a/lib/web/live/admin_syndication_live.html.heex
+++ b/lib/web/live/admin_syndication_live.html.heex
@@ -31,7 +31,18 @@
       <% end %>
     </div>
     <div>
-      <strong>Bluesky: </strong>Coming soon!
+      <strong>Bluesky: </strong>
+      <%= if @bluesky_account do %>
+        <.link href={"https://bsky.app/profile/#{@bluesky_account.handle}"} target="_blank">
+          @{@bluesky_account.handle}
+        </.link>
+      <% else %>
+        <.form :let={f} for={%{}} as={:bluesky_account} phx-submit="save_bluesky_account">
+          <.input field={f[:identifier]} label="email" />
+          <.input field={f[:password]} type="password" />
+          <.button type="submit">Connect account</.button>
+        </.form>
+      <% end %>
     </div>
   </main>
 </div>
diff --git a/mix.exs b/mix.exs
index 2c11260..9492cf2 100644
--- a/mix.exs
+++ b/mix.exs
@@ -70,6 +70,7 @@ defmodule SlaonelyButSurely.MixProject do
       {:ueberauth_mastodon, "~> 0.3.0"},
       {:tesla, "~> 1.14"},
       {:mint, "~> 1.7"},
+      {:joken, "~> 2.6"},
 
       # Added dev and/or test dependencies
       {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
diff --git a/mix.lock b/mix.lock
index 3b392d2..b34d993 100644
--- a/mix.lock
+++ b/mix.lock
@@ -31,6 +31,8 @@
   "igniter": {:hex, :igniter, "0.5.47", "7a1041d5e38303e526fa6b6de37c9e78013f5cb573833ed51183d18e3a152f10", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:inflex, "~> 2.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "53a900909e20f217a25d15a34fef629c562b4822c1fb39cfa5d6999bc72992ed"},
   "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"},
   "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
+  "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
+  "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"},
   "mastodon_client": {:hex, :mastodon_client, "0.1.0", "7f1a9e54367d0e126c76d0bb1097de346fb9c6da6d682a6b57bc936c92d643eb", [:mix], [{:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "3daa37fc7a95430eb0b77cdb168af58b8db84efea6e0f3244d15d45f39a87160"},
   "mdex": {:hex, :mdex, "0.5.0", "252c83cebc6a089801dfc1e142b4d98c9c358378ec7096a94796bce8bd13b0fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "73e3ddee03130267e3be6aaf47a7f423c6f86add4bb5c62b352465cd9fb87d95"},
   "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
diff --git a/priv/repo/migrations/20250502113428_add_bluesky_tables.exs b/priv/repo/migrations/20250502113428_add_bluesky_tables.exs
new file mode 100644
index 0000000..8c1f432
--- /dev/null
+++ b/priv/repo/migrations/20250502113428_add_bluesky_tables.exs
@@ -0,0 +1,44 @@
+defmodule Core.Repo.Migrations.AddBlueskyTables do
+  use Ecto.Migration
+
+  def change do
+    create table(:bluesky_accounts, primary_key: false) do
+      add :id, :uuid, primary_key: true
+
+      add :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false
+
+      add :handle, :text, null: false
+      add :did, :text, null: false
+
+      add :access_jwt, :text, null: false
+      add :access_jwt_iat, :utc_datetime, null: false
+      add :access_jwt_exp, :utc_datetime, null: false
+
+      add :refresh_jwt, :text, null: false
+      add :refresh_jwt_iat, :utc_datetime, null: false
+      add :refresh_jwt_exp, :utc_datetime, null: false
+
+      timestamps(type: :utc_datetime_usec)
+    end
+
+    create unique_index(:bluesky_accounts, [:user_id])
+
+    create table(:bluesky_posts, primary_key: false) do
+      add :id, :uuid, primary_key: true
+
+      add :post_id, references(:posts, type: :uuid, on_delete: :delete_all), null: false
+
+      add :cid, :text, null: false
+      add :uri, :text, null: false
+      add :commit, :jsonb, null: false
+
+      timestamps(type: :utc_datetime_usec)
+    end
+
+    create unique_index(:bluesky_posts, [:post_id])
+
+    alter table(:posts) do
+      add :syndicate_to_bluesky, :boolean, default: false, null: false
+    end
+  end
+end