this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Rewrite appview from Rust to Elixir/Phoenix with PostgreSQL [CL-290]

Read-only Jetstream indexer + query API for encrypted sharing metadata.
Supervision tree: Repo, KeyCache (GenServer+ETS), Endpoint, Consumer (WebSockex).
Three tables (cursor, grants, keyring_members), three API endpoints
(health, inbox, keyrings), Ed25519 auth via Erlang :crypto, Hammer rate
limiting. Includes Dockerfile with auto-migrate entrypoint, docker-compose
for dev, and 37 tests.

+2505 -2
+2
.gitignore
··· 1 1 /target 2 + appview/_build/ 3 + appview/deps/ 2 4 node_modules/ 3 5 web/dist/ 4 6 web/src/wasm/
+19 -2
Makefile
··· 11 11 12 12 .PHONY: build wasm wasm-dev install-web-devs web-build setup \ 13 13 validate lint test rust-test fmt clippy web-lint web-typecheck web-test \ 14 + appview appview-test appview-release \ 14 15 images push-images 15 16 16 17 ## Build all Rust crates ··· 55 56 clippy: 56 57 cargo clippy --workspace --all-targets -- -D warnings 57 58 58 - ## Run all tests (Rust + web) 59 - test: rust-test web-test 59 + ## Run all tests (Rust + web + appview) 60 + test: rust-test web-test appview-test 60 61 61 62 ## Run Rust tests 62 63 rust-test: ··· 76 77 77 78 ## Run all lints (Rust + web) 78 79 lint: fmt clippy web-lint web-typecheck 80 + 81 + # --------------------------------------------------------------------------- 82 + # Elixir appview 83 + # --------------------------------------------------------------------------- 84 + 85 + ## Run appview tests 86 + appview-test: 87 + cd appview && mix test 88 + 89 + ## Start appview dev server 90 + appview: 91 + cd appview && mix phx.server 92 + 93 + ## Build appview release 94 + appview-release: 95 + cd appview && MIX_ENV=prod mix release 79 96 80 97 # --------------------------------------------------------------------------- 81 98 # Container images
+9
appview/.dockerignore
··· 1 + _build/ 2 + deps/ 3 + .elixir_ls/ 4 + *.ez 5 + .git 6 + test/ 7 + docker-compose.yml 8 + AGENTS.md 9 + README.md
+5
appview/.formatter.exs
··· 1 + [ 2 + import_deps: [:ecto, :ecto_sql, :phoenix], 3 + subdirectories: ["priv/*/migrations"], 4 + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"] 5 + ]
+27
appview/.gitignore
··· 1 + # The directory Mix will write compiled artifacts to. 2 + /_build/ 3 + 4 + # If you run "mix test --cover", coverage assets end up here. 5 + /cover/ 6 + 7 + # The directory Mix downloads your dependencies sources to. 8 + /deps/ 9 + 10 + # Where 3rd-party dependencies like ExDoc output generated docs. 11 + /doc/ 12 + 13 + # Ignore .fetch files in case you like to edit your project deps locally. 14 + /.fetch 15 + 16 + # If the VM crashes, it generates a dump, let's ignore it too. 17 + erl_crash.dump 18 + 19 + # Also ignore archive artifacts (built via "mix archive.build"). 20 + *.ez 21 + 22 + # Temporary files, for example, from tests. 23 + /tmp/ 24 + 25 + # Ignore package tarball (built via "mix hex.build"). 26 + opake_appview-*.tar 27 +
+44
appview/Dockerfile
··· 1 + FROM hexpm/elixir:1.18.3-erlang-27.3-debian-bookworm-20250317-slim AS build 2 + 3 + RUN apt-get update -y && apt-get install -y build-essential git && \ 4 + apt-get clean && rm -rf /var/lib/apt/lists/* 5 + 6 + WORKDIR /app 7 + 8 + RUN mix local.hex --force && mix local.rebar --force 9 + 10 + ENV MIX_ENV=prod 11 + 12 + COPY mix.exs mix.lock ./ 13 + RUN mix deps.get --only $MIX_ENV && \ 14 + mkdir config && \ 15 + mix deps.compile 16 + 17 + COPY config/config.exs config/prod.exs config/runtime.exs config/ 18 + COPY lib lib 19 + COPY priv priv 20 + 21 + RUN mix compile && mix release 22 + 23 + # --- Runtime --- 24 + 25 + FROM debian:bookworm-slim 26 + 27 + RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates && \ 28 + apt-get clean && rm -rf /var/lib/apt/lists/* && \ 29 + sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 30 + 31 + ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 32 + 33 + RUN groupadd -g 1000 opake && useradd -u 1000 -g opake -m opake 34 + 35 + WORKDIR /app 36 + COPY --from=build --chown=1000:1000 /app/_build/prod/rel/opake_appview ./ 37 + COPY --chown=1000:1000 rel/entrypoint.sh ./ 38 + 39 + USER 1000:1000 40 + EXPOSE 6100 41 + 42 + ENV PHX_SERVER=true 43 + 44 + ENTRYPOINT ["./entrypoint.sh"]
+24
appview/config/config.exs
··· 1 + import Config 2 + 3 + config :opake_appview, 4 + ecto_repos: [OpakeAppview.Repo], 5 + generators: [timestamp_type: :utc_datetime_usec] 6 + 7 + config :opake_appview, OpakeAppviewWeb.Endpoint, 8 + url: [host: "localhost"], 9 + adapter: Bandit.PhoenixAdapter, 10 + render_errors: [ 11 + formats: [json: OpakeAppviewWeb.ErrorJSON], 12 + layout: false 13 + ] 14 + 15 + config :logger, :default_formatter, 16 + format: "$time $metadata[$level] $message\n", 17 + metadata: [:request_id] 18 + 19 + config :phoenix, :json_library, Jason 20 + 21 + config :hammer, 22 + backend: {Hammer.Backend.ETS, [expiry_ms: :timer.minutes(2), cleanup_interval_ms: :timer.minutes(1)]} 23 + 24 + import_config "#{config_env()}.exs"
+27
appview/config/dev.exs
··· 1 + import Config 2 + 3 + config :opake_appview, OpakeAppview.Repo, 4 + username: "postgres", 5 + password: "postgres", 6 + hostname: "localhost", 7 + database: "opake_appview_dev", 8 + stacktrace: true, 9 + show_sensitive_data_on_connection_error: true, 10 + pool_size: 10 11 + 12 + config :opake_appview, OpakeAppviewWeb.Endpoint, 13 + http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "6100")], 14 + check_origin: false, 15 + code_reloader: true, 16 + debug_errors: true, 17 + secret_key_base: "BFc2e5YOPLjLIEFGAPZ2kemmnuc7VOwv5ctKtiaYOX/r2bTLG8X2sVVcI6cYPjK1", 18 + watchers: [] 19 + 20 + config :opake_appview, 21 + jetstream_url: "wss://jetstream2.us-east.bsky.network/subscribe" 22 + 23 + config :opake_appview, dev_routes: true 24 + 25 + config :logger, :default_formatter, format: "[$level] $message\n" 26 + config :phoenix, :stacktrace_depth, 20 27 + config :phoenix, :plug_init_mode, :runtime
+7
appview/config/prod.exs
··· 1 + import Config 2 + 3 + # Do not print debug messages in production 4 + config :logger, level: :info 5 + 6 + # Runtime production configuration, including reading 7 + # of environment variables, is done on config/runtime.exs.
+38
appview/config/runtime.exs
··· 1 + import Config 2 + 3 + if System.get_env("PHX_SERVER") not in [nil, "false", "0"] do 4 + config :opake_appview, OpakeAppviewWeb.Endpoint, server: true 5 + end 6 + 7 + if System.get_env("INDEXER_ENABLED") in ["false", "0"] do 8 + config :opake_appview, :indexer_enabled, false 9 + end 10 + 11 + if jetstream_url = System.get_env("JETSTREAM_URL") do 12 + config :opake_appview, :jetstream_url, jetstream_url 13 + end 14 + 15 + if config_env() == :prod do 16 + database_url = 17 + System.get_env("DATABASE_URL") || 18 + raise "environment variable DATABASE_URL is missing" 19 + 20 + maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] 21 + 22 + config :opake_appview, OpakeAppview.Repo, 23 + url: database_url, 24 + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 25 + socket_options: maybe_ipv6 26 + 27 + secret_key_base = 28 + System.get_env("SECRET_KEY_BASE") || 29 + raise "environment variable SECRET_KEY_BASE is missing" 30 + 31 + host = System.get_env("PHX_HOST") || "localhost" 32 + port = String.to_integer(System.get_env("PORT") || "6100") 33 + 34 + config :opake_appview, OpakeAppviewWeb.Endpoint, 35 + url: [host: host, port: 443, scheme: "https"], 36 + http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: port], 37 + secret_key_base: secret_key_base 38 + end
+19
appview/config/test.exs
··· 1 + import Config 2 + 3 + config :opake_appview, OpakeAppview.Repo, 4 + username: "postgres", 5 + password: "postgres", 6 + hostname: "localhost", 7 + database: "opake_appview_test#{System.get_env("MIX_TEST_PARTITION")}", 8 + pool: Ecto.Adapters.SQL.Sandbox, 9 + pool_size: System.schedulers_online() * 2 10 + 11 + config :opake_appview, OpakeAppviewWeb.Endpoint, 12 + http: [ip: {127, 0, 0, 1}, port: 4002], 13 + secret_key_base: "qLFGZ/oib3gMumgPqHDETV3VM0klUHbVSFijSctQvjmTDRoshDjL+HyCbHS8IS3k", 14 + server: false 15 + 16 + config :opake_appview, :indexer_enabled, false 17 + 18 + config :logger, level: :warning 19 + config :phoenix, :plug_init_mode, :runtime
+34
appview/docker-compose.yml
··· 1 + services: 2 + db: 3 + image: postgres:17-alpine 4 + environment: 5 + POSTGRES_USER: postgres 6 + POSTGRES_PASSWORD: postgres 7 + ports: 8 + - "5432:5432" 9 + volumes: 10 + - pgdata:/var/lib/postgresql/data 11 + healthcheck: 12 + test: ["CMD-SHELL", "pg_isready -U postgres"] 13 + interval: 2s 14 + timeout: 5s 15 + retries: 5 16 + 17 + appview: 18 + build: . 19 + depends_on: 20 + db: 21 + condition: service_healthy 22 + environment: 23 + DATABASE_URL: ecto://postgres:postgres@db/opake_appview_prod 24 + SECRET_KEY_BASE: dev-only-please-change-in-production-64-chars-aaaaaaaaaaaaaaaa 25 + PHX_HOST: localhost 26 + PHX_SERVER: "true" 27 + JETSTREAM_URL: wss://jetstream2.us-east.bsky.network/subscribe 28 + ports: 29 + - "6100:6100" 30 + profiles: 31 + - full 32 + 33 + volumes: 34 + pgdata:
+44
appview/lib/opake_appview/application.ex
··· 1 + defmodule OpakeAppview.Application do 2 + @moduledoc """ 3 + OTP application for the Opake AppView — a read-only Jetstream indexer and 4 + query API for encrypted sharing metadata on the AT Protocol. 5 + 6 + Supervision tree: 7 + - Repo (Ecto/Postgres connection pool) 8 + - Auth.KeyCache (GenServer + ETS for Ed25519 public key caching) 9 + - Endpoint (Phoenix/Bandit HTTP server — always started, `server: false` skips binding) 10 + - Jetstream.Consumer (WebSockex — conditional on `:indexer_enabled` config) 11 + """ 12 + 13 + use Application 14 + 15 + @impl true 16 + def start(_type, _args) do 17 + OpakeAppview.Indexer.init_state() 18 + 19 + children = 20 + [ 21 + OpakeAppview.Repo, 22 + OpakeAppview.Auth.KeyCache, 23 + OpakeAppviewWeb.Endpoint 24 + ] ++ 25 + maybe_consumer() 26 + 27 + opts = [strategy: :one_for_one, name: OpakeAppview.Supervisor] 28 + Supervisor.start_link(children, opts) 29 + end 30 + 31 + defp maybe_consumer do 32 + if Application.get_env(:opake_appview, :indexer_enabled, true) do 33 + [OpakeAppview.Jetstream.Consumer] 34 + else 35 + [] 36 + end 37 + end 38 + 39 + @impl true 40 + def config_change(changed, _new, removed) do 41 + OpakeAppviewWeb.Endpoint.config_change(changed, removed) 42 + :ok 43 + end 44 + end
+19
appview/lib/opake_appview/auth/base64.ex
··· 1 + defmodule OpakeAppview.Auth.Base64 do 2 + @moduledoc """ 3 + Flexible base64 decoding that handles both padded and unpadded input. 4 + AT Protocol encodes keys and signatures inconsistently. 5 + """ 6 + 7 + def decode(str) do 8 + case Base.decode64(str) do 9 + {:ok, bytes} -> 10 + {:ok, bytes} 11 + 12 + :error -> 13 + case Base.decode64(str, padding: false) do 14 + {:ok, bytes} -> {:ok, bytes} 15 + :error -> {:error, "invalid base64 encoding"} 16 + end 17 + end 18 + end 19 + end
+76
appview/lib/opake_appview/auth/key_cache.ex
··· 1 + defmodule OpakeAppview.Auth.KeyCache do 2 + @moduledoc """ 3 + In-memory cache for Ed25519 signing public keys, keyed by DID with a 5-minute 4 + TTL. Reads go directly to ETS (no bottleneck). Writes are serialized through 5 + the GenServer to prevent thundering-herd fetches for the same DID. 6 + """ 7 + 8 + use GenServer 9 + require Logger 10 + 11 + @table :key_cache 12 + @ttl_ms :timer.minutes(5) 13 + 14 + # Public API — direct ETS reads, no bottleneck 15 + 16 + def start_link(_opts) do 17 + GenServer.start_link(__MODULE__, [], name: __MODULE__) 18 + end 19 + 20 + def get_key(did) do 21 + case ets_lookup(did) do 22 + {:ok, pubkey} -> {:ok, pubkey} 23 + _ -> GenServer.call(__MODULE__, {:fetch, did}, 15_000) 24 + end 25 + end 26 + 27 + # GenServer — serializes concurrent fetches for the same DID 28 + 29 + @impl true 30 + def init(_) do 31 + :ets.new(@table, [:named_table, :set, :public, read_concurrency: true]) 32 + {:ok, %{}} 33 + end 34 + 35 + @impl true 36 + def handle_call({:fetch, did}, _from, state) do 37 + result = 38 + case ets_lookup(did) do 39 + {:ok, pubkey} -> 40 + {:ok, pubkey} 41 + 42 + _ -> 43 + case key_fetcher().fetch_signing_key(did) do 44 + {:ok, pubkey} -> 45 + now = System.monotonic_time(:millisecond) 46 + :ets.insert(@table, {did, pubkey, now}) 47 + {:ok, pubkey} 48 + 49 + {:error, reason} -> 50 + {:error, reason} 51 + end 52 + end 53 + 54 + {:reply, result, state} 55 + end 56 + 57 + defp ets_lookup(did) do 58 + case :ets.lookup(@table, did) do 59 + [{^did, pubkey, inserted_at}] -> 60 + now = System.monotonic_time(:millisecond) 61 + 62 + if now - inserted_at < @ttl_ms do 63 + {:ok, pubkey} 64 + else 65 + :stale 66 + end 67 + 68 + [] -> 69 + :miss 70 + end 71 + end 72 + 73 + defp key_fetcher do 74 + Application.get_env(:opake_appview, :key_fetcher, OpakeAppview.Auth.KeyFetcher) 75 + end 76 + end
+97
appview/lib/opake_appview/auth/key_fetcher.ex
··· 1 + defmodule OpakeAppview.Auth.KeyFetcher do 2 + @moduledoc """ 3 + Resolves a DID to its Ed25519 signing public key by walking the chain: 4 + DID → DID document → PDS service endpoint → `app.opake.publicKey/self` record 5 + → `signingKey.$bytes` (32-byte Ed25519 key). 6 + 7 + Supports `did:plc:` (via plc.directory) and `did:web:` (via .well-known). 8 + Implements `KeyFetcherBehaviour` so tests can substitute a Mox mock. 9 + """ 10 + 11 + @behaviour OpakeAppview.Auth.KeyFetcherBehaviour 12 + 13 + require Logger 14 + 15 + @impl true 16 + def fetch_signing_key(did) do 17 + with {:ok, pds_url} <- resolve_pds(did), 18 + {:ok, pubkey_bytes} <- fetch_public_key_record(pds_url, did) do 19 + {:ok, pubkey_bytes} 20 + end 21 + end 22 + 23 + defp resolve_pds(did) do 24 + with {:ok, did_doc} <- resolve_did_document(did) do 25 + extract_pds_url(did_doc) 26 + end 27 + end 28 + 29 + defp resolve_did_document("did:plc:" <> _ = did) do 30 + url = "https://plc.directory/#{did}" 31 + fetch_json(url) 32 + end 33 + 34 + defp resolve_did_document("did:web:" <> host) do 35 + url = "https://#{host}/.well-known/did.json" 36 + fetch_json(url) 37 + end 38 + 39 + defp resolve_did_document(_did), do: {:error, "unsupported DID method"} 40 + 41 + defp extract_pds_url(did_doc) do 42 + services = did_doc["service"] || [] 43 + 44 + pds_service = 45 + Enum.find(services, fn svc -> 46 + svc["id"] == "#atproto_pds" or svc["type"] == "AtprotoPersonalDataServer" 47 + end) 48 + 49 + case pds_service do 50 + %{"serviceEndpoint" => endpoint} when is_binary(endpoint) -> {:ok, endpoint} 51 + _ -> {:error, "no PDS service in DID document"} 52 + end 53 + end 54 + 55 + defp fetch_public_key_record(pds_url, did) do 56 + url = 57 + "#{pds_url}/xrpc/com.atproto.repo.getRecord?" <> 58 + URI.encode_query(repo: did, collection: "app.opake.publicKey", rkey: "self") 59 + 60 + with {:ok, response} <- fetch_json(url) do 61 + extract_signing_key(response) 62 + end 63 + end 64 + 65 + defp extract_signing_key(response) do 66 + case get_in(response, ["value", "signingKey", "$bytes"]) do 67 + bytes_b64 when is_binary(bytes_b64) -> 68 + decode_pubkey_bytes(bytes_b64) 69 + 70 + _ -> 71 + {:error, "signingKey.$bytes not found in publicKey record"} 72 + end 73 + end 74 + 75 + defp decode_pubkey_bytes(bytes_b64) do 76 + with {:ok, bytes} <- OpakeAppview.Auth.Base64.decode(bytes_b64) do 77 + if byte_size(bytes) == 32 do 78 + {:ok, bytes} 79 + else 80 + {:error, "signing key must be 32 bytes, got #{byte_size(bytes)}"} 81 + end 82 + end 83 + end 84 + 85 + defp fetch_json(url) do 86 + case Req.get(url) do 87 + {:ok, %Req.Response{status: 200, body: body}} when is_map(body) -> 88 + {:ok, body} 89 + 90 + {:ok, %Req.Response{status: status}} -> 91 + {:error, "HTTP #{status} from #{url}"} 92 + 93 + {:error, reason} -> 94 + {:error, "failed to fetch #{url}: #{inspect(reason)}"} 95 + end 96 + end 97 + end
+8
appview/lib/opake_appview/auth/key_fetcher_behaviour.ex
··· 1 + defmodule OpakeAppview.Auth.KeyFetcherBehaviour do 2 + @moduledoc """ 3 + Behaviour for resolving a DID to a 32-byte Ed25519 signing public key. 4 + The real implementation hits the network; tests use a Mox mock. 5 + """ 6 + 7 + @callback fetch_signing_key(did :: String.t()) :: {:ok, binary()} | {:error, String.t()} 8 + end
+122
appview/lib/opake_appview/auth/plug.ex
··· 1 + defmodule OpakeAppview.Auth.Plug do 2 + @moduledoc """ 3 + Plug that verifies `Opake-Ed25519` authentication headers. 4 + 5 + Header format: `Opake-Ed25519 <did>:<unix-timestamp>:<base64-signature>` 6 + Signature covers: `<METHOD>:<path>:<timestamp>:<did>` 7 + 8 + Parsing splits from the right because DIDs contain colons. Enforces a 60-second 9 + replay window and optional `?did=` scope check. The Ed25519 public key is 10 + resolved via the KeyCache (which fetches from the user's PDS on cache miss). 11 + """ 12 + 13 + import Plug.Conn 14 + 15 + @behaviour Plug 16 + 17 + @max_timestamp_drift_secs 60 18 + @auth_prefix "Opake-Ed25519 " 19 + 20 + @impl true 21 + def init(opts), do: opts 22 + 23 + @impl true 24 + def call(conn, _opts) do 25 + case authenticate(conn) do 26 + {:ok, did} -> 27 + assign(conn, :authenticated_did, did) 28 + 29 + {:error, message} -> 30 + conn 31 + |> put_status(401) 32 + |> Phoenix.Controller.json(%{error: message}) 33 + |> halt() 34 + end 35 + end 36 + 37 + defp authenticate(conn) do 38 + with {:ok, payload} <- extract_auth_header(conn), 39 + {:ok, did, timestamp_str, signature_b64} <- parse_auth_payload(payload), 40 + {:ok, timestamp} <- parse_timestamp(timestamp_str), 41 + :ok <- check_timestamp_drift(timestamp), 42 + :ok <- check_did_scope(conn, did), 43 + {:ok, signature} <- OpakeAppview.Auth.Base64.decode(signature_b64), 44 + {:ok, pubkey} <- OpakeAppview.Auth.KeyCache.get_key(did) do 45 + method = conn.method 46 + path = conn.request_path 47 + message = "#{method}:#{path}:#{timestamp}:#{did}" 48 + 49 + case verify_signature(pubkey, message, signature) do 50 + :ok -> {:ok, did} 51 + {:error, _} = err -> err 52 + end 53 + end 54 + end 55 + 56 + defp extract_auth_header(conn) do 57 + case get_req_header(conn, "authorization") do 58 + [header] -> 59 + if String.starts_with?(header, @auth_prefix) do 60 + {:ok, String.trim_leading(header, @auth_prefix)} 61 + else 62 + {:error, "invalid authorization scheme"} 63 + end 64 + 65 + [] -> 66 + {:error, "missing authorization header"} 67 + 68 + _ -> 69 + {:error, "multiple authorization headers"} 70 + end 71 + end 72 + 73 + # Parse from right: DIDs contain colons, so we reverse-split to separate 74 + # the signature (last), timestamp (second-to-last), and DID (everything else) 75 + defp parse_auth_payload(payload) do 76 + parts = String.split(payload, ":") |> Enum.reverse() 77 + 78 + case parts do 79 + [signature, timestamp | did_parts] when did_parts != [] -> 80 + did = did_parts |> Enum.reverse() |> Enum.join(":") 81 + {:ok, did, timestamp, signature} 82 + 83 + _ -> 84 + {:error, "malformed auth payload"} 85 + end 86 + end 87 + 88 + defp parse_timestamp(timestamp_str) do 89 + case Integer.parse(timestamp_str) do 90 + {ts, ""} -> {:ok, ts} 91 + _ -> {:error, "invalid timestamp"} 92 + end 93 + end 94 + 95 + defp check_timestamp_drift(timestamp) do 96 + now = System.system_time(:second) 97 + drift = abs(now - timestamp) 98 + 99 + if drift <= @max_timestamp_drift_secs do 100 + :ok 101 + else 102 + {:error, "timestamp drift too large (#{drift}s)"} 103 + end 104 + end 105 + 106 + defp check_did_scope(conn, authenticated_did) do 107 + case conn.query_params["did"] do 108 + nil -> :ok 109 + did when did == authenticated_did -> :ok 110 + _ -> {:error, "DID scope mismatch"} 111 + end 112 + end 113 + 114 + defp verify_signature(pubkey, message, signature) do 115 + case :crypto.verify(:eddsa, :none, message, signature, [pubkey, :ed25519]) do 116 + true -> :ok 117 + false -> {:error, "invalid signature"} 118 + end 119 + rescue 120 + _ -> {:error, "signature verification failed"} 121 + end 122 + end
+86
appview/lib/opake_appview/indexer.ex
··· 1 + defmodule OpakeAppview.Indexer do 2 + @moduledoc """ 3 + Dispatches parsed Jetstream events to the appropriate query module. 4 + Tracks indexer connection state via ETS (read by the health endpoint) 5 + and saves the cursor to Postgres every 100 events. 6 + """ 7 + 8 + require Logger 9 + 10 + alias OpakeAppview.Queries.{CursorQueries, GrantQueries, KeyringQueries} 11 + alias OpakeAppview.Jetstream.Event 12 + 13 + @cursor_save_interval 100 14 + @state_table :indexer_state 15 + 16 + def init_state do 17 + :ets.new(@state_table, [:named_table, :set, :public, read_concurrency: true]) 18 + :ets.insert(@state_table, {:connected, false}) 19 + end 20 + 21 + def set_connected(connected) when is_boolean(connected) do 22 + :ets.insert(@state_table, {:connected, connected}) 23 + end 24 + 25 + def connected? do 26 + case :ets.lookup(@state_table, :connected) do 27 + [{:connected, val}] -> val 28 + [] -> false 29 + end 30 + end 31 + 32 + def process_message(json, event_count) do 33 + case Event.parse(json) do 34 + {:upsert_grant, attrs} -> 35 + handle_upsert_grant(attrs) 36 + maybe_save_cursor(attrs.time_us, event_count + 1) 37 + 38 + {:delete_grant, %{uri: uri, time_us: time_us}} -> 39 + GrantQueries.delete_grant(uri) 40 + maybe_save_cursor(time_us, event_count + 1) 41 + 42 + {:upsert_keyring, attrs} -> 43 + handle_upsert_keyring(attrs) 44 + maybe_save_cursor(attrs.time_us, event_count + 1) 45 + 46 + {:delete_keyring, %{uri: uri, time_us: time_us}} -> 47 + KeyringQueries.delete_keyring(uri) 48 + maybe_save_cursor(time_us, event_count + 1) 49 + 50 + :ignore -> 51 + event_count 52 + end 53 + end 54 + 55 + defp handle_upsert_grant(attrs) do 56 + now = DateTime.utc_now() 57 + 58 + case GrantQueries.upsert_grant(%{ 59 + uri: attrs.uri, 60 + owner_did: attrs.owner_did, 61 + recipient_did: attrs.recipient_did, 62 + document_uri: attrs.document_uri, 63 + created_at: attrs.created_at, 64 + indexed_at: now 65 + }) do 66 + {:ok, _} -> :ok 67 + {:error, changeset} -> Logger.warning("Failed to upsert grant #{attrs.uri}: #{inspect(changeset)}") 68 + end 69 + end 70 + 71 + defp handle_upsert_keyring(attrs) do 72 + case KeyringQueries.upsert_keyring(attrs.uri, attrs.owner_did, attrs.member_dids) do 73 + {:ok, _} -> :ok 74 + {:error, reason} -> Logger.warning("Failed to upsert keyring #{attrs.uri}: #{inspect(reason)}") 75 + end 76 + end 77 + 78 + defp maybe_save_cursor(time_us, event_count) do 79 + if rem(event_count, @cursor_save_interval) == 0 do 80 + CursorQueries.save_cursor(time_us) 81 + Logger.debug("Saved cursor at #{time_us} (#{event_count} events)") 82 + end 83 + 84 + event_count 85 + end 86 + end
+80
appview/lib/opake_appview/jetstream/consumer.ex
··· 1 + defmodule OpakeAppview.Jetstream.Consumer do 2 + @moduledoc """ 3 + WebSocket client that subscribes to the Jetstream firehose for 4 + `app.opake.grant` and `app.opake.keyring` events. Resumes from the last 5 + saved cursor on startup. Reconnects with exponential backoff (1s → 60s max). 6 + Started conditionally — only when `:indexer_enabled` is true. 7 + """ 8 + 9 + use WebSockex 10 + require Logger 11 + 12 + alias OpakeAppview.Indexer 13 + alias OpakeAppview.Queries.CursorQueries 14 + 15 + @initial_backoff_ms 1_000 16 + @max_backoff_ms 60_000 17 + @wanted_collections ["app.opake.grant", "app.opake.keyring"] 18 + 19 + defstruct [:event_count, :backoff_ms] 20 + 21 + def start_link(_opts) do 22 + state = %__MODULE__{event_count: 0, backoff_ms: @initial_backoff_ms} 23 + 24 + url = build_subscription_url() 25 + Logger.info("Jetstream connecting to #{url}") 26 + 27 + WebSockex.start_link(url, __MODULE__, state, name: __MODULE__) 28 + end 29 + 30 + @impl true 31 + def handle_connect(_conn, state) do 32 + Logger.info("Jetstream connected") 33 + Indexer.set_connected(true) 34 + {:ok, %{state | backoff_ms: @initial_backoff_ms}} 35 + end 36 + 37 + @impl true 38 + def handle_frame({:text, msg}, state) do 39 + event_count = Indexer.process_message(msg, state.event_count) 40 + {:ok, %{state | event_count: event_count}} 41 + end 42 + 43 + def handle_frame(_frame, state), do: {:ok, state} 44 + 45 + @impl true 46 + def handle_disconnect(%{reason: reason}, state) do 47 + Indexer.set_connected(false) 48 + Logger.warning("Jetstream disconnected: #{inspect(reason)}, reconnecting in #{state.backoff_ms}ms") 49 + 50 + Process.sleep(state.backoff_ms) 51 + 52 + next_backoff = min(state.backoff_ms * 2, @max_backoff_ms) 53 + {:reconnect, %{state | backoff_ms: next_backoff, event_count: 0}} 54 + end 55 + 56 + @impl true 57 + def handle_info(_msg, state), do: {:ok, state} 58 + 59 + defp build_subscription_url do 60 + base_url = jetstream_url() 61 + cursor = CursorQueries.load_cursor() 62 + 63 + collection_params = 64 + @wanted_collections 65 + |> Enum.map(&"wantedCollections=#{&1}") 66 + |> Enum.join("&") 67 + 68 + cursor_param = 69 + case cursor do 70 + %{time_us: time_us} -> "&cursor=#{time_us}" 71 + nil -> "" 72 + end 73 + 74 + "#{base_url}?#{collection_params}#{cursor_param}" 75 + end 76 + 77 + defp jetstream_url do 78 + Application.fetch_env!(:opake_appview, :jetstream_url) 79 + end 80 + end
+95
appview/lib/opake_appview/jetstream/event.ex
··· 1 + defmodule OpakeAppview.Jetstream.Event do 2 + @moduledoc """ 3 + Parses raw Jetstream JSON messages into tagged tuples for the indexer. 4 + 5 + Only `app.opake.grant` and `app.opake.keyring` commit events are recognized. 6 + Everything else (identity events, unknown collections, malformed JSON) returns 7 + `:ignore`. No full record validation — the appview indexes metadata fields, 8 + not crypto payloads. 9 + """ 10 + 11 + @grant_collection "app.opake.grant" 12 + @keyring_collection "app.opake.keyring" 13 + 14 + def parse(json) when is_binary(json) do 15 + case Jason.decode(json) do 16 + {:ok, payload} -> parse_payload(payload) 17 + {:error, _} -> :ignore 18 + end 19 + end 20 + 21 + defp parse_payload(%{"kind" => "commit", "did" => did, "time_us" => time_us, "commit" => commit}) do 22 + parse_commit(did, time_us, commit) 23 + end 24 + 25 + defp parse_payload(_), do: :ignore 26 + 27 + defp parse_commit(did, time_us, %{ 28 + "operation" => operation, 29 + "collection" => collection, 30 + "rkey" => rkey 31 + } = commit) do 32 + uri = "at://#{did}/#{collection}/#{rkey}" 33 + 34 + case {collection, operation} do 35 + {@grant_collection, op} when op in ["create", "update"] -> 36 + parse_grant_upsert(uri, did, time_us, commit) 37 + 38 + {@grant_collection, "delete"} -> 39 + {:delete_grant, %{uri: uri, time_us: time_us}} 40 + 41 + {@keyring_collection, op} when op in ["create", "update"] -> 42 + parse_keyring_upsert(uri, did, time_us, commit) 43 + 44 + {@keyring_collection, "delete"} -> 45 + {:delete_keyring, %{uri: uri, time_us: time_us}} 46 + 47 + _ -> 48 + :ignore 49 + end 50 + end 51 + 52 + defp parse_commit(_, _, _), do: :ignore 53 + 54 + defp parse_grant_upsert(uri, did, time_us, %{"record" => record}) when is_map(record) do 55 + recipient = record["recipient"] 56 + document = record["document"] 57 + created_at = record["createdAt"] 58 + 59 + if is_binary(recipient) and is_binary(document) and is_binary(created_at) do 60 + {:upsert_grant, 61 + %{ 62 + uri: uri, 63 + owner_did: did, 64 + recipient_did: recipient, 65 + document_uri: document, 66 + created_at: created_at, 67 + time_us: time_us 68 + }} 69 + else 70 + :ignore 71 + end 72 + end 73 + 74 + defp parse_grant_upsert(_, _, _, _), do: :ignore 75 + 76 + defp parse_keyring_upsert(uri, did, time_us, %{"record" => record}) when is_map(record) do 77 + members = record["members"] || [] 78 + 79 + member_dids = 80 + members 81 + |> Enum.filter(&is_map/1) 82 + |> Enum.map(& &1["did"]) 83 + |> Enum.filter(&is_binary/1) 84 + 85 + {:upsert_keyring, 86 + %{ 87 + uri: uri, 88 + owner_did: did, 89 + member_dids: member_dids, 90 + time_us: time_us 91 + }} 92 + end 93 + 94 + defp parse_keyring_upsert(_, _, _, _), do: :ignore 95 + end
+37
appview/lib/opake_appview/queries/cursor_queries.ex
··· 1 + defmodule OpakeAppview.Queries.CursorQueries do 2 + @moduledoc """ 3 + Read/write the singleton Jetstream cursor. The cursor tracks how far 4 + the indexer has consumed the firehose, enabling resumption after restarts. 5 + """ 6 + 7 + alias OpakeAppview.Repo 8 + alias OpakeAppview.Schemas.Cursor 9 + 10 + @micros_per_second 1_000_000 11 + 12 + def load_cursor do 13 + Repo.get(Cursor, 1) 14 + end 15 + 16 + def save_cursor(time_us) do 17 + now = DateTime.utc_now() 18 + 19 + %Cursor{id: 1} 20 + |> Cursor.changeset(%{id: 1, time_us: time_us, updated_at: now}) 21 + |> Repo.insert( 22 + on_conflict: [set: [time_us: time_us, updated_at: now]], 23 + conflict_target: :id 24 + ) 25 + end 26 + 27 + def cursor_age_secs do 28 + case load_cursor() do 29 + nil -> 30 + nil 31 + 32 + %Cursor{time_us: time_us} -> 33 + now_us = DateTime.utc_now() |> DateTime.to_unix(:microsecond) 34 + div(now_us - time_us, @micros_per_second) 35 + end 36 + end 37 + end
+61
appview/lib/opake_appview/queries/grant_queries.ex
··· 1 + defmodule OpakeAppview.Queries.GrantQueries do 2 + @moduledoc """ 3 + Grant CRUD and inbox queries. Grants are upserted on create/update events 4 + and deleted on delete events. `list_inbox/3` returns grants for a recipient 5 + DID with cursor-based pagination (ordered by indexed_at DESC, uri DESC). 6 + """ 7 + 8 + import Ecto.Query 9 + 10 + alias OpakeAppview.Repo 11 + alias OpakeAppview.Schemas.Grant 12 + alias OpakeAppview.Queries.Pagination 13 + 14 + def upsert_grant(attrs) do 15 + %Grant{} 16 + |> Grant.changeset(attrs) 17 + |> Repo.insert( 18 + on_conflict: {:replace_all_except, [:uri]}, 19 + conflict_target: :uri 20 + ) 21 + end 22 + 23 + def delete_grant(uri) do 24 + from(g in Grant, where: g.uri == ^uri) 25 + |> Repo.delete_all() 26 + end 27 + 28 + def list_inbox(recipient_did, opts \\ []) do 29 + limit = Keyword.get(opts, :limit, 50) 30 + cursor = Keyword.get(opts, :cursor) 31 + 32 + query = 33 + from(g in Grant, 34 + where: g.recipient_did == ^recipient_did, 35 + order_by: [desc: g.indexed_at, desc: g.uri], 36 + limit: ^limit 37 + ) 38 + 39 + query = 40 + case Pagination.parse_cursor(cursor) do 41 + {:ok, cursor_time, cursor_uri} -> 42 + from(g in query, 43 + where: 44 + g.indexed_at < ^cursor_time or 45 + (g.indexed_at == ^cursor_time and g.uri < ^cursor_uri) 46 + ) 47 + 48 + :none -> 49 + query 50 + end 51 + 52 + grants = Repo.all(query) 53 + next_cursor = Pagination.build_next_cursor(grants) 54 + 55 + {grants, next_cursor} 56 + end 57 + 58 + def grant_count do 59 + Repo.aggregate(Grant, :count) 60 + end 61 + end
+81
appview/lib/opake_appview/queries/keyring_queries.ex
··· 1 + defmodule OpakeAppview.Queries.KeyringQueries do 2 + @moduledoc """ 3 + Keyring membership CRUD and queries. Upserts are transactional 4 + delete-all-then-reinsert to match the "full member list" semantics of 5 + the `app.opake.keyring` record. `list_keyrings_for_member/3` returns 6 + distinct keyrings containing a given DID, with cursor-based pagination. 7 + """ 8 + 9 + import Ecto.Query 10 + 11 + alias OpakeAppview.Repo 12 + alias OpakeAppview.Schemas.KeyringMember 13 + alias OpakeAppview.Queries.Pagination 14 + 15 + def upsert_keyring(keyring_uri, owner_did, member_dids) do 16 + now = DateTime.utc_now() 17 + 18 + Repo.transaction(fn -> 19 + from(km in KeyringMember, where: km.keyring_uri == ^keyring_uri) 20 + |> Repo.delete_all() 21 + 22 + rows = 23 + Enum.map(member_dids, fn member_did -> 24 + %{ 25 + keyring_uri: keyring_uri, 26 + member_did: member_did, 27 + owner_did: owner_did, 28 + indexed_at: now 29 + } 30 + end) 31 + 32 + Repo.insert_all(KeyringMember, rows) 33 + end) 34 + end 35 + 36 + def delete_keyring(keyring_uri) do 37 + from(km in KeyringMember, where: km.keyring_uri == ^keyring_uri) 38 + |> Repo.delete_all() 39 + end 40 + 41 + def list_keyrings_for_member(member_did, opts \\ []) do 42 + limit = Keyword.get(opts, :limit, 50) 43 + cursor = Keyword.get(opts, :cursor) 44 + 45 + query = 46 + from(km in KeyringMember, 47 + where: km.member_did == ^member_did, 48 + distinct: km.keyring_uri, 49 + order_by: [desc: km.indexed_at, desc: km.keyring_uri], 50 + select: %{ 51 + uri: km.keyring_uri, 52 + owner_did: km.owner_did, 53 + indexed_at: km.indexed_at 54 + }, 55 + limit: ^limit 56 + ) 57 + 58 + query = 59 + case Pagination.parse_cursor(cursor) do 60 + {:ok, cursor_time, cursor_uri} -> 61 + from(km in query, 62 + where: 63 + km.indexed_at < ^cursor_time or 64 + (km.indexed_at == ^cursor_time and km.keyring_uri < ^cursor_uri) 65 + ) 66 + 67 + :none -> 68 + query 69 + end 70 + 71 + keyrings = Repo.all(query) 72 + next_cursor = Pagination.build_next_cursor(keyrings) 73 + 74 + {keyrings, next_cursor} 75 + end 76 + 77 + def keyring_count do 78 + from(km in KeyringMember, select: count(km.keyring_uri, :distinct)) 79 + |> Repo.one() 80 + end 81 + end
+38
appview/lib/opake_appview/queries/pagination.ex
··· 1 + defmodule OpakeAppview.Queries.Pagination do 2 + @moduledoc """ 3 + Shared cursor-based pagination helpers for query modules. 4 + 5 + Cursor format: `"{iso8601_indexed_at}::{uri}"`. The cursor encodes the 6 + position of the last item returned, enabling keyset pagination without offsets. 7 + """ 8 + 9 + @doc """ 10 + Parses a cursor string into `{:ok, datetime, uri}` or `:none`. 11 + """ 12 + def parse_cursor(nil), do: :none 13 + def parse_cursor(""), do: :none 14 + 15 + def parse_cursor(cursor) do 16 + case String.split(cursor, "::", parts: 2) do 17 + [time_str, uri] -> 18 + case DateTime.from_iso8601(time_str) do 19 + {:ok, datetime, _offset} -> {:ok, datetime, uri} 20 + _ -> :none 21 + end 22 + 23 + _ -> 24 + :none 25 + end 26 + end 27 + 28 + @doc """ 29 + Builds the next cursor from a list of results. Returns `nil` for empty lists. 30 + Items must have `:indexed_at` (DateTime) and `:uri` (string) fields. 31 + """ 32 + def build_next_cursor([]), do: nil 33 + 34 + def build_next_cursor(items) do 35 + last = List.last(items) 36 + "#{DateTime.to_iso8601(last.indexed_at)}::#{last.uri}" 37 + end 38 + end
+86
appview/lib/opake_appview/release.ex
··· 1 + defmodule OpakeAppview.Release do 2 + @moduledoc """ 3 + Release tasks for running outside of Mix (e.g. in a Docker container). 4 + 5 + - `create_db/0` — creates the database if it doesn't exist 6 + - `migrate/0` — runs pending Ecto migrations 7 + - `rollback/2` — rolls back to a specific migration version 8 + - `status/0` — prints cursor position, lag, and indexed record counts 9 + """ 10 + 11 + require Ecto.Query 12 + 13 + @app :opake_appview 14 + 15 + def create_db do 16 + load_app() 17 + 18 + for repo <- repos() do 19 + case repo.__adapter__().storage_up(repo.config()) do 20 + :ok -> IO.puts("Database created") 21 + {:error, :already_up} -> IO.puts("Database already exists") 22 + {:error, reason} -> raise "Could not create database: #{inspect(reason)}" 23 + end 24 + end 25 + end 26 + 27 + def migrate do 28 + load_app() 29 + 30 + for repo <- repos() do 31 + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 32 + end 33 + end 34 + 35 + def rollback(repo, version) do 36 + load_app() 37 + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 38 + end 39 + 40 + def status do 41 + load_app() 42 + 43 + {:ok, _, _} = 44 + Ecto.Migrator.with_repo(OpakeAppview.Repo, fn repo -> 45 + cursor = repo.get(OpakeAppview.Schemas.Cursor, 1) 46 + 47 + grant_count = 48 + repo.aggregate(OpakeAppview.Schemas.Grant, :count) 49 + 50 + keyring_count = 51 + repo.one( 52 + Ecto.Query.from(km in OpakeAppview.Schemas.KeyringMember, 53 + select: count(km.keyring_uri, :distinct) 54 + ) 55 + ) 56 + 57 + IO.puts("Cursor:") 58 + 59 + case cursor do 60 + nil -> 61 + IO.puts(" (none)") 62 + 63 + %{time_us: time_us, updated_at: updated_at} -> 64 + cursor_time = DateTime.from_unix!(time_us, :microsecond) 65 + now = DateTime.utc_now() 66 + lag_secs = DateTime.diff(now, cursor_time) 67 + IO.puts(" Position: #{time_us}") 68 + IO.puts(" Time: #{DateTime.to_iso8601(cursor_time)}") 69 + IO.puts(" Lag: #{lag_secs}s") 70 + IO.puts(" Updated: #{DateTime.to_iso8601(updated_at)}") 71 + end 72 + 73 + IO.puts("\nCounts:") 74 + IO.puts(" Grants: #{grant_count}") 75 + IO.puts(" Keyrings: #{keyring_count}") 76 + end) 77 + end 78 + 79 + defp repos do 80 + Application.fetch_env!(@app, :ecto_repos) 81 + end 82 + 83 + defp load_app do 84 + Application.ensure_all_started(@app) 85 + end 86 + end
+5
appview/lib/opake_appview/repo.ex
··· 1 + defmodule OpakeAppview.Repo do 2 + use Ecto.Repo, 3 + otp_app: :opake_appview, 4 + adapter: Ecto.Adapters.Postgres 5 + end
+23
appview/lib/opake_appview/schemas/cursor.ex
··· 1 + defmodule OpakeAppview.Schemas.Cursor do 2 + @moduledoc """ 3 + Singleton row tracking the Jetstream cursor position. The `id = 1` CHECK 4 + constraint enforces exactly one row. `time_us` is the Jetstream event 5 + timestamp in Unix microseconds. 6 + """ 7 + 8 + use Ecto.Schema 9 + import Ecto.Changeset 10 + 11 + @primary_key {:id, :integer, autogenerate: false} 12 + schema "cursor" do 13 + field :time_us, :integer 14 + field :updated_at, :utc_datetime_usec 15 + end 16 + 17 + def changeset(cursor, attrs) do 18 + cursor 19 + |> cast(attrs, [:id, :time_us, :updated_at]) 20 + |> validate_required([:id, :time_us, :updated_at]) 21 + |> validate_inclusion(:id, [1]) 22 + end 23 + end
+26
appview/lib/opake_appview/schemas/grant.ex
··· 1 + defmodule OpakeAppview.Schemas.Grant do 2 + @moduledoc """ 3 + An indexed `app.opake.grant` record. Grants are sharing permissions — the 4 + owner authorizes a recipient to decrypt a specific document. The appview 5 + indexes these from the Jetstream firehose so recipients can discover incoming 6 + grants via the `/api/inbox` endpoint. 7 + """ 8 + 9 + use Ecto.Schema 10 + import Ecto.Changeset 11 + 12 + @primary_key {:uri, :string, autogenerate: false} 13 + schema "grants" do 14 + field :owner_did, :string 15 + field :recipient_did, :string 16 + field :document_uri, :string 17 + field :created_at, :string 18 + field :indexed_at, :utc_datetime_usec 19 + end 20 + 21 + def changeset(grant, attrs) do 22 + grant 23 + |> cast(attrs, [:uri, :owner_did, :recipient_did, :document_uri, :created_at, :indexed_at]) 24 + |> validate_required([:uri, :owner_did, :recipient_did, :document_uri, :created_at, :indexed_at]) 25 + end 26 + end
+26
appview/lib/opake_appview/schemas/keyring_member.ex
··· 1 + defmodule OpakeAppview.Schemas.KeyringMember do 2 + @moduledoc """ 3 + A denormalized (keyring_uri, member_did) row. Keyrings group encrypted 4 + document keys for a set of members. The appview flattens the members array 5 + into individual rows so it can efficiently answer "which keyrings include 6 + this DID?" via the `/api/keyrings` endpoint. Upserts delete-and-reinsert 7 + all members atomically. 8 + """ 9 + 10 + use Ecto.Schema 11 + import Ecto.Changeset 12 + 13 + @primary_key false 14 + schema "keyring_members" do 15 + field :keyring_uri, :string, primary_key: true 16 + field :member_did, :string, primary_key: true 17 + field :owner_did, :string 18 + field :indexed_at, :utc_datetime_usec 19 + end 20 + 21 + def changeset(member, attrs) do 22 + member 23 + |> cast(attrs, [:keyring_uri, :member_did, :owner_did, :indexed_at]) 24 + |> validate_required([:keyring_uri, :member_did, :owner_did, :indexed_at]) 25 + end 26 + end
+22
appview/lib/opake_appview_web.ex
··· 1 + defmodule OpakeAppviewWeb do 2 + def router do 3 + quote do 4 + use Phoenix.Router, helpers: false 5 + 6 + import Plug.Conn 7 + import Phoenix.Controller 8 + end 9 + end 10 + 11 + def controller do 12 + quote do 13 + use Phoenix.Controller, formats: [:json] 14 + 15 + import Plug.Conn 16 + end 17 + end 18 + 19 + defmacro __using__(which) when is_atom(which) do 20 + apply(__MODULE__, which, []) 21 + end 22 + end
+11
appview/lib/opake_appview_web/controllers/error_json.ex
··· 1 + defmodule OpakeAppviewWeb.ErrorJSON do 2 + def render("400.json", _assigns), do: %{error: "bad request"} 3 + def render("401.json", _assigns), do: %{error: "unauthorized"} 4 + def render("404.json", _assigns), do: %{error: "not found"} 5 + def render("429.json", _assigns), do: %{error: "rate limit exceeded"} 6 + def render("500.json", _assigns), do: %{error: "internal server error"} 7 + 8 + def render(template, _assigns) do 9 + %{error: Phoenix.Controller.status_message_from_template(template)} 10 + end 11 + end
+41
appview/lib/opake_appview_web/controllers/health_controller.ex
··· 1 + defmodule OpakeAppviewWeb.HealthController do 2 + @moduledoc """ 3 + Unauthenticated health endpoint. Returns indexer connection state, cursor 4 + position, and cursor lag. Intentionally omits row counts — those are internal 5 + metrics, not public health signals. 6 + """ 7 + 8 + use OpakeAppviewWeb, :controller 9 + 10 + alias OpakeAppview.Indexer 11 + alias OpakeAppview.Queries.CursorQueries 12 + 13 + @micros_per_second 1_000_000 14 + 15 + def index(conn, _params) do 16 + cursor = CursorQueries.load_cursor() 17 + 18 + response = %{ 19 + indexerConnected: Indexer.connected?(), 20 + cursorTime: format_cursor_time(cursor), 21 + cursorAgeSecs: cursor_age_secs(cursor) 22 + } 23 + 24 + json(conn, response) 25 + end 26 + 27 + defp format_cursor_time(nil), do: nil 28 + 29 + defp format_cursor_time(%{time_us: time_us}) do 30 + time_us 31 + |> DateTime.from_unix!(:microsecond) 32 + |> DateTime.to_iso8601() 33 + end 34 + 35 + defp cursor_age_secs(nil), do: nil 36 + 37 + defp cursor_age_secs(%{time_us: time_us}) do 38 + now_us = DateTime.utc_now() |> DateTime.to_unix(:microsecond) 39 + div(now_us - time_us, @micros_per_second) 40 + end 41 + end
+41
appview/lib/opake_appview_web/controllers/inbox_controller.ex
··· 1 + defmodule OpakeAppviewWeb.InboxController do 2 + @moduledoc """ 3 + Returns incoming grants for the authenticated DID. The `?did=` parameter 4 + must match the authenticated DID (enforced by the auth plug's scope check). 5 + Supports cursor-based pagination with configurable limit (1-100, default 50). 6 + """ 7 + 8 + use OpakeAppviewWeb, :controller 9 + 10 + alias OpakeAppview.Queries.GrantQueries 11 + import OpakeAppviewWeb.PaginationHelpers 12 + 13 + def index(conn, params) do 14 + with {:ok, did} <- require_did(params), 15 + {:ok, limit} <- parse_limit(params) do 16 + cursor = params["cursor"] 17 + {grants, next_cursor} = GrantQueries.list_inbox(did, limit: limit, cursor: cursor) 18 + 19 + response = 20 + %{ 21 + grants: 22 + Enum.map(grants, fn g -> 23 + %{ 24 + uri: g.uri, 25 + ownerDid: g.owner_did, 26 + documentUri: g.document_uri, 27 + createdAt: g.created_at 28 + } 29 + end) 30 + } 31 + |> maybe_put_cursor(next_cursor) 32 + 33 + json(conn, response) 34 + else 35 + {:error, message} -> 36 + conn 37 + |> put_status(400) 38 + |> json(%{error: message}) 39 + end 40 + end 41 + end
+40
appview/lib/opake_appview_web/controllers/keyrings_controller.ex
··· 1 + defmodule OpakeAppviewWeb.KeyringsController do 2 + @moduledoc """ 3 + Returns keyrings that include the authenticated DID as a member. Used by 4 + clients to discover which keyrings they need to fetch wrapped keys from. 5 + Same pagination pattern as the inbox endpoint. 6 + """ 7 + 8 + use OpakeAppviewWeb, :controller 9 + 10 + alias OpakeAppview.Queries.KeyringQueries 11 + import OpakeAppviewWeb.PaginationHelpers 12 + 13 + def index(conn, params) do 14 + with {:ok, did} <- require_did(params), 15 + {:ok, limit} <- parse_limit(params) do 16 + cursor = params["cursor"] 17 + {keyrings, next_cursor} = KeyringQueries.list_keyrings_for_member(did, limit: limit, cursor: cursor) 18 + 19 + response = 20 + %{ 21 + keyrings: 22 + Enum.map(keyrings, fn k -> 23 + %{ 24 + uri: k.uri, 25 + ownerDid: k.owner_did, 26 + indexedAt: DateTime.to_iso8601(k.indexed_at) 27 + } 28 + end) 29 + } 30 + |> maybe_put_cursor(next_cursor) 31 + 32 + json(conn, response) 33 + else 34 + {:error, message} -> 35 + conn 36 + |> put_status(400) 37 + |> json(%{error: message}) 38 + end 39 + end 40 + end
+24
appview/lib/opake_appview_web/controllers/pagination_helpers.ex
··· 1 + defmodule OpakeAppviewWeb.PaginationHelpers do 2 + @moduledoc """ 3 + Shared parameter parsing for paginated API endpoints. 4 + """ 5 + 6 + @default_limit 50 7 + @max_limit 100 8 + 9 + def require_did(%{"did" => did}) when is_binary(did) and byte_size(did) > 0, do: {:ok, did} 10 + def require_did(_), do: {:error, "did parameter is required"} 11 + 12 + def parse_limit(%{"limit" => limit_str}) when is_binary(limit_str) do 13 + case Integer.parse(limit_str) do 14 + {n, ""} when n >= 1 and n <= @max_limit -> {:ok, n} 15 + {_, ""} -> {:error, "limit must be between 1 and #{@max_limit}"} 16 + _ -> {:error, "invalid limit"} 17 + end 18 + end 19 + 20 + def parse_limit(_), do: {:ok, @default_limit} 21 + 22 + def maybe_put_cursor(response, nil), do: response 23 + def maybe_put_cursor(response, cursor), do: Map.put(response, :cursor, cursor) 24 + end
+18
appview/lib/opake_appview_web/endpoint.ex
··· 1 + defmodule OpakeAppviewWeb.Endpoint do 2 + use Phoenix.Endpoint, otp_app: :opake_appview 3 + 4 + if code_reloading? do 5 + plug Phoenix.CodeReloader 6 + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :opake_appview 7 + end 8 + 9 + plug Plug.RequestId 10 + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 11 + 12 + plug Plug.Parsers, 13 + parsers: [:urlencoded, :json], 14 + pass: ["*/*"], 15 + json_decoder: Phoenix.json_library() 16 + 17 + plug OpakeAppviewWeb.Router 18 + end
+53
appview/lib/opake_appview_web/plugs/rate_limit.ex
··· 1 + defmodule OpakeAppviewWeb.Plugs.RateLimit do 2 + @moduledoc """ 3 + Per-IP rate limiting via Hammer (ETS backend). Allows 30 requests per second 4 + burst. Respects `X-Forwarded-For` and `X-Real-IP` headers for clients behind 5 + a reverse proxy. 6 + """ 7 + 8 + import Plug.Conn 9 + 10 + @behaviour Plug 11 + 12 + @burst_size 30 13 + @scale_ms :timer.seconds(1) 14 + 15 + @impl true 16 + def init(opts), do: opts 17 + 18 + @impl true 19 + def call(conn, _opts) do 20 + client_ip = client_ip(conn) 21 + bucket = "rate_limit:#{client_ip}" 22 + 23 + case Hammer.check_rate(bucket, @scale_ms, @burst_size) do 24 + {:allow, _count} -> 25 + conn 26 + 27 + {:deny, _limit} -> 28 + conn 29 + |> put_status(429) 30 + |> Phoenix.Controller.json(%{error: "rate limit exceeded"}) 31 + |> halt() 32 + end 33 + end 34 + 35 + defp client_ip(conn) do 36 + forwarded_for = 37 + get_req_header(conn, "x-forwarded-for") 38 + |> List.first() 39 + 40 + real_ip = get_req_header(conn, "x-real-ip") |> List.first() 41 + 42 + cond do 43 + forwarded_for -> 44 + forwarded_for |> String.split(",") |> List.first() |> String.trim() 45 + 46 + real_ip -> 47 + real_ip 48 + 49 + true -> 50 + conn.remote_ip |> :inet.ntoa() |> to_string() 51 + end 52 + end 53 + end
+28
appview/lib/opake_appview_web/router.ex
··· 1 + defmodule OpakeAppviewWeb.Router do 2 + @moduledoc """ 3 + API router. Health is public; inbox and keyrings require Opake-Ed25519 auth. 4 + All routes are rate-limited per IP. 5 + """ 6 + 7 + use OpakeAppviewWeb, :router 8 + 9 + pipeline :api do 10 + plug :accepts, ["json"] 11 + plug OpakeAppviewWeb.Plugs.RateLimit 12 + end 13 + 14 + pipeline :authenticated do 15 + plug OpakeAppview.Auth.Plug 16 + end 17 + 18 + scope "/api", OpakeAppviewWeb do 19 + pipe_through :api 20 + 21 + get "/health", HealthController, :index 22 + 23 + pipe_through :authenticated 24 + 25 + get "/inbox", InboxController, :index 26 + get "/keyrings", KeyringsController, :index 27 + end 28 + end
+49
appview/mix.exs
··· 1 + defmodule OpakeAppview.MixProject do 2 + use Mix.Project 3 + 4 + def project do 5 + [ 6 + app: :opake_appview, 7 + version: "0.1.0", 8 + elixir: "~> 1.15", 9 + elixirc_paths: elixirc_paths(Mix.env()), 10 + start_permanent: Mix.env() == :prod, 11 + aliases: aliases(), 12 + deps: deps() 13 + ] 14 + end 15 + 16 + def application do 17 + [ 18 + mod: {OpakeAppview.Application, []}, 19 + extra_applications: [:logger, :runtime_tools, :crypto] 20 + ] 21 + end 22 + 23 + defp elixirc_paths(:test), do: ["lib", "test/support"] 24 + defp elixirc_paths(_), do: ["lib"] 25 + 26 + defp deps do 27 + [ 28 + {:phoenix, "~> 1.8"}, 29 + {:phoenix_ecto, "~> 4.6"}, 30 + {:ecto_sql, "~> 3.12"}, 31 + {:postgrex, "~> 0.19"}, 32 + {:jason, "~> 1.4"}, 33 + {:bandit, "~> 1.6"}, 34 + {:websockex, "~> 0.4.3"}, 35 + {:hammer, "~> 6.2"}, 36 + {:req, "~> 0.5"}, 37 + {:mox, "~> 1.2", only: :test} 38 + ] 39 + end 40 + 41 + defp aliases do 42 + [ 43 + setup: ["deps.get", "ecto.setup"], 44 + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 45 + "ecto.reset": ["ecto.drop", "ecto.setup"], 46 + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] 47 + ] 48 + end 49 + end
+39
appview/mix.lock
··· 1 + %{ 2 + "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, 3 + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, 4 + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 5 + "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, 6 + "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, 7 + "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, 8 + "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, 9 + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, 10 + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, 11 + "hammer": {:hex, :hammer, "6.2.1", "5ae9c33e3dceaeb42de0db46bf505bd9c35f259c8defb03390cd7556fea67ee2", [:mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b9476d0c13883d2dc0cc72e786bac6ac28911fba7cc2e04b70ce6a6d9c4b2bdc"}, 12 + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 13 + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 14 + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 15 + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 16 + "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, 17 + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 18 + "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, 19 + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 20 + "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, 21 + "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, 22 + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, 23 + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, 24 + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"}, 25 + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, 26 + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 27 + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, 28 + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 29 + "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 30 + "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, 31 + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, 32 + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, 33 + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, 34 + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, 35 + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, 36 + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 37 + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, 38 + "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, 39 + }
+4
appview/priv/repo/migrations/.formatter.exs
··· 1 + [ 2 + import_deps: [:ecto_sql], 3 + inputs: ["*.exs"] 4 + ]
+13
appview/priv/repo/migrations/20260310000001_create_cursor.exs
··· 1 + defmodule OpakeAppview.Repo.Migrations.CreateCursor do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:cursor, primary_key: false) do 6 + add :id, :integer, primary_key: true 7 + add :time_us, :bigint, null: false 8 + add :updated_at, :utc_datetime_usec, null: false 9 + end 10 + 11 + execute "ALTER TABLE cursor ADD CONSTRAINT cursor_singleton CHECK (id = 1)", "" 12 + end 13 + end
+17
appview/priv/repo/migrations/20260310000002_create_grants.exs
··· 1 + defmodule OpakeAppview.Repo.Migrations.CreateGrants do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:grants, primary_key: false) do 6 + add :uri, :text, primary_key: true 7 + add :owner_did, :text, null: false 8 + add :recipient_did, :text, null: false 9 + add :document_uri, :text, null: false 10 + add :created_at, :text, null: false 11 + add :indexed_at, :utc_datetime_usec, null: false 12 + end 13 + 14 + create index(:grants, [:recipient_did]) 15 + create index(:grants, [:owner_did]) 16 + end 17 + end
+19
appview/priv/repo/migrations/20260310000003_create_keyring_members.exs
··· 1 + defmodule OpakeAppview.Repo.Migrations.CreateKeyringMembers do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:keyring_members, primary_key: false) do 6 + add :keyring_uri, :text, null: false 7 + add :member_did, :text, null: false 8 + add :owner_did, :text, null: false 9 + add :indexed_at, :utc_datetime_usec, null: false 10 + end 11 + 12 + execute( 13 + "ALTER TABLE keyring_members ADD PRIMARY KEY (keyring_uri, member_did)", 14 + "" 15 + ) 16 + 17 + create index(:keyring_members, [:member_did]) 18 + end 19 + end
+15
appview/priv/repo/migrations/20260311000001_add_composite_pagination_indexes.exs
··· 1 + defmodule OpakeAppview.Repo.Migrations.AddCompositePaginationIndexes do 2 + use Ecto.Migration 3 + 4 + def change do 5 + # Covers list_inbox filter + sort: WHERE recipient_did = ? ORDER BY indexed_at DESC, uri DESC 6 + # Subsumes the single-column recipient_did index 7 + drop index(:grants, [:recipient_did]) 8 + create index(:grants, [:recipient_did, :indexed_at, :uri]) 9 + 10 + # Covers list_keyrings_for_member: WHERE member_did = ? ORDER BY indexed_at DESC, keyring_uri DESC 11 + # Subsumes the single-column member_did index 12 + drop index(:keyring_members, [:member_did]) 13 + create index(:keyring_members, [:member_did, :indexed_at, :keyring_uri]) 14 + end 15 + end
+11
appview/priv/repo/seeds.exs
··· 1 + # Script for populating the database. You can run it as: 2 + # 3 + # mix run priv/repo/seeds.exs 4 + # 5 + # Inside the script, you can read and write to any of your 6 + # repositories directly: 7 + # 8 + # OpakeAppview.Repo.insert!(%OpakeAppview.SomeSchema{}) 9 + # 10 + # We recommend using the bang functions (`insert!`, `update!` 11 + # and so on) as they will fail if something goes wrong.
+11
appview/rel/entrypoint.sh
··· 1 + #!/bin/sh 2 + set -e 3 + 4 + echo "Creating database (if needed)..." 5 + bin/opake_appview eval "OpakeAppview.Release.create_db()" 6 + 7 + echo "Running migrations..." 8 + bin/opake_appview eval "OpakeAppview.Release.migrate()" 9 + 10 + echo "Starting appview..." 11 + exec bin/opake_appview start
+95
appview/test/opake_appview/auth/plug_test.exs
··· 1 + defmodule OpakeAppview.Auth.PlugTest do 2 + use OpakeAppviewWeb.ConnCase, async: false 3 + import Mox 4 + 5 + setup :set_mox_global 6 + setup :verify_on_exit! 7 + 8 + setup do 9 + :ets.delete_all_objects(:key_cache) 10 + :ok 11 + end 12 + 13 + test "rejects missing authorization header", %{conn: conn} do 14 + conn = get(conn, "/api/inbox?did=did:plc:test") 15 + 16 + assert json_response(conn, 401)["error"] =~ "authorization" 17 + end 18 + 19 + test "rejects bearer token auth", %{conn: conn} do 20 + conn = 21 + conn 22 + |> put_req_header("authorization", "Bearer some-token") 23 + |> get("/api/inbox?did=did:plc:test") 24 + 25 + assert json_response(conn, 401)["error"] =~ "scheme" 26 + end 27 + 28 + test "rejects basic auth", %{conn: conn} do 29 + conn = 30 + conn 31 + |> put_req_header("authorization", "Basic dXNlcjpwYXNz") 32 + |> get("/api/inbox?did=did:plc:test") 33 + 34 + assert json_response(conn, 401)["error"] =~ "scheme" 35 + end 36 + 37 + test "accepts valid ed25519 signed request", %{conn: conn} do 38 + did = "did:plc:testuser" 39 + 40 + conn = 41 + conn 42 + |> authed_conn(did, "/api/inbox") 43 + |> get("/api/inbox?did=#{did}") 44 + 45 + assert json_response(conn, 200) 46 + end 47 + 48 + test "rejects expired timestamp", %{conn: conn} do 49 + old_timestamp = System.system_time(:second) - 120 50 + did = "did:plc:testuser" 51 + 52 + {_pubkey, privkey} = :crypto.generate_key(:eddsa, :ed25519) 53 + message = "GET:/api/inbox:#{old_timestamp}:#{did}" 54 + 55 + signature = :crypto.sign(:eddsa, :none, message, [privkey, :ed25519]) 56 + sig_b64 = Base.encode64(signature) 57 + 58 + conn = 59 + conn 60 + |> put_req_header("authorization", "Opake-Ed25519 #{did}:#{old_timestamp}:#{sig_b64}") 61 + |> get("/api/inbox?did=#{did}") 62 + 63 + assert json_response(conn, 401)["error"] =~ "drift" 64 + end 65 + 66 + test "rejects DID scope mismatch", %{conn: conn} do 67 + did = "did:plc:testuser" 68 + 69 + # DID scope check happens before key fetch, so no mock expectation needed. 70 + # Use a manually-signed request since authed_conn sets up a mock. 71 + {_pubkey, privkey} = :crypto.generate_key(:eddsa, :ed25519) 72 + timestamp = System.system_time(:second) 73 + message = "GET:/api/inbox:#{timestamp}:#{did}" 74 + signature = :crypto.sign(:eddsa, :none, message, [privkey, :ed25519]) 75 + sig_b64 = Base.encode64(signature) 76 + 77 + conn = 78 + conn 79 + |> put_req_header("authorization", "Opake-Ed25519 #{did}:#{timestamp}:#{sig_b64}") 80 + |> get("/api/inbox?did=did:plc:other") 81 + 82 + assert json_response(conn, 401)["error"] =~ "mismatch" 83 + end 84 + 85 + test "health endpoint works without auth", %{conn: conn} do 86 + conn = get(conn, "/api/health") 87 + 88 + response = json_response(conn, 200) 89 + assert is_boolean(response["indexerConnected"]) 90 + assert Map.has_key?(response, "cursorTime") 91 + assert Map.has_key?(response, "cursorAgeSecs") 92 + refute Map.has_key?(response, "grantCount") 93 + refute Map.has_key?(response, "keyringCount") 94 + end 95 + end
+132
appview/test/opake_appview/indexer_test.exs
··· 1 + defmodule OpakeAppview.IndexerTest do 2 + use OpakeAppview.DataCase, async: true 3 + 4 + alias OpakeAppview.Indexer 5 + alias OpakeAppview.Queries.{GrantQueries, KeyringQueries} 6 + 7 + defp grant_create_json do 8 + Jason.encode!(%{ 9 + "did" => "did:plc:owner", 10 + "time_us" => 1_709_330_400_000_000, 11 + "kind" => "commit", 12 + "commit" => %{ 13 + "rev" => "abc", 14 + "operation" => "create", 15 + "collection" => "app.opake.grant", 16 + "rkey" => "3abc", 17 + "record" => %{ 18 + "recipient" => "did:plc:recipient", 19 + "document" => "at://did:plc:owner/app.opake.document/3xyz", 20 + "createdAt" => "2026-03-01T12:00:00Z" 21 + } 22 + } 23 + }) 24 + end 25 + 26 + defp grant_delete_json do 27 + Jason.encode!(%{ 28 + "did" => "did:plc:owner", 29 + "time_us" => 1_709_330_500_000_000, 30 + "kind" => "commit", 31 + "commit" => %{ 32 + "rev" => "abc", 33 + "operation" => "delete", 34 + "collection" => "app.opake.grant", 35 + "rkey" => "3abc" 36 + } 37 + }) 38 + end 39 + 40 + defp keyring_create_json(members) do 41 + Jason.encode!(%{ 42 + "did" => "did:plc:owner", 43 + "time_us" => 1_709_330_400_000_000, 44 + "kind" => "commit", 45 + "commit" => %{ 46 + "rev" => "abc", 47 + "operation" => "create", 48 + "collection" => "app.opake.keyring", 49 + "rkey" => "3def", 50 + "record" => %{ 51 + "members" => Enum.map(members, &%{"did" => &1, "wrappedKey" => %{"$bytes" => "AAAA"}}) 52 + } 53 + } 54 + }) 55 + end 56 + 57 + defp keyring_update_json(members) do 58 + Jason.encode!(%{ 59 + "did" => "did:plc:owner", 60 + "time_us" => 1_709_330_500_000_000, 61 + "kind" => "commit", 62 + "commit" => %{ 63 + "rev" => "abc", 64 + "operation" => "update", 65 + "collection" => "app.opake.keyring", 66 + "rkey" => "3def", 67 + "record" => %{ 68 + "members" => Enum.map(members, &%{"did" => &1, "wrappedKey" => %{"$bytes" => "AAAA"}}) 69 + } 70 + } 71 + }) 72 + end 73 + 74 + defp keyring_delete_json do 75 + Jason.encode!(%{ 76 + "did" => "did:plc:owner", 77 + "time_us" => 1_709_330_500_000_000, 78 + "kind" => "commit", 79 + "commit" => %{ 80 + "rev" => "abc", 81 + "operation" => "delete", 82 + "collection" => "app.opake.keyring", 83 + "rkey" => "3def" 84 + } 85 + }) 86 + end 87 + 88 + test "indexes grant create" do 89 + Indexer.process_message(grant_create_json(), 0) 90 + 91 + {grants, _} = GrantQueries.list_inbox("did:plc:recipient") 92 + assert length(grants) == 1 93 + assert hd(grants).owner_did == "did:plc:owner" 94 + end 95 + 96 + test "indexes grant delete" do 97 + Indexer.process_message(grant_create_json(), 0) 98 + Indexer.process_message(grant_delete_json(), 1) 99 + 100 + {grants, _} = GrantQueries.list_inbox("did:plc:recipient") 101 + assert grants == [] 102 + end 103 + 104 + test "indexes keyring create" do 105 + Indexer.process_message(keyring_create_json(["did:plc:alice", "did:plc:bob"]), 0) 106 + 107 + {alice, _} = KeyringQueries.list_keyrings_for_member("did:plc:alice") 108 + assert length(alice) == 1 109 + 110 + {bob, _} = KeyringQueries.list_keyrings_for_member("did:plc:bob") 111 + assert length(bob) == 1 112 + end 113 + 114 + test "indexes keyring update replaces members" do 115 + Indexer.process_message(keyring_create_json(["did:plc:alice", "did:plc:bob"]), 0) 116 + Indexer.process_message(keyring_update_json(["did:plc:alice", "did:plc:charlie"]), 1) 117 + 118 + {bob, _} = KeyringQueries.list_keyrings_for_member("did:plc:bob") 119 + assert bob == [] 120 + 121 + {charlie, _} = KeyringQueries.list_keyrings_for_member("did:plc:charlie") 122 + assert length(charlie) == 1 123 + end 124 + 125 + test "indexes keyring delete" do 126 + Indexer.process_message(keyring_create_json(["did:plc:alice"]), 0) 127 + Indexer.process_message(keyring_delete_json(), 1) 128 + 129 + {alice, _} = KeyringQueries.list_keyrings_for_member("did:plc:alice") 130 + assert alice == [] 131 + end 132 + end
+156
appview/test/opake_appview/jetstream/event_test.exs
··· 1 + defmodule OpakeAppview.Jetstream.EventTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias OpakeAppview.Jetstream.Event 5 + 6 + defp grant_event_json(operation) do 7 + Jason.encode!(%{ 8 + "did" => "did:plc:owner123", 9 + "time_us" => 1_709_330_400_000_000, 10 + "kind" => "commit", 11 + "commit" => %{ 12 + "rev" => "3l3qo2vutsw2b", 13 + "operation" => operation, 14 + "collection" => "app.opake.grant", 15 + "rkey" => "3abc", 16 + "cid" => "bafyabc", 17 + "record" => %{ 18 + "recipient" => "did:plc:recipient456", 19 + "document" => "at://did:plc:owner123/app.opake.document/3xyz", 20 + "createdAt" => "2026-03-01T12:00:00Z", 21 + "wrappedKey" => %{ 22 + "$bytes" => "AAAA", 23 + "algo" => "x25519-hkdf-a256kw" 24 + }, 25 + "encryptedMetadata" => %{ 26 + "ciphertext" => "AAAA", 27 + "nonce" => "AAAAAAAAAAAAAAAA" 28 + } 29 + } 30 + } 31 + }) 32 + end 33 + 34 + defp keyring_event_json(operation) do 35 + Jason.encode!(%{ 36 + "did" => "did:plc:owner123", 37 + "time_us" => 1_709_330_500_000_000, 38 + "kind" => "commit", 39 + "commit" => %{ 40 + "rev" => "3l3qo2vutsw2b", 41 + "operation" => operation, 42 + "collection" => "app.opake.keyring", 43 + "rkey" => "3def", 44 + "cid" => "bafydef", 45 + "record" => %{ 46 + "members" => [ 47 + %{"did" => "did:plc:alice", "wrappedKey" => %{"$bytes" => "AAAA"}}, 48 + %{"did" => "did:plc:bob", "wrappedKey" => %{"$bytes" => "BBBB"}} 49 + ], 50 + "rotation" => 0 51 + } 52 + } 53 + }) 54 + end 55 + 56 + defp delete_event_json(collection, rkey) do 57 + Jason.encode!(%{ 58 + "did" => "did:plc:owner123", 59 + "time_us" => 1_709_330_600_000_000, 60 + "kind" => "commit", 61 + "commit" => %{ 62 + "rev" => "3l3qo2vutsw2b", 63 + "operation" => "delete", 64 + "collection" => collection, 65 + "rkey" => rkey, 66 + "cid" => "bafydel" 67 + } 68 + }) 69 + end 70 + 71 + test "parses grant create" do 72 + json = grant_event_json("create") 73 + 74 + assert {:upsert_grant, attrs} = Event.parse(json) 75 + assert attrs.time_us == 1_709_330_400_000_000 76 + assert attrs.uri == "at://did:plc:owner123/app.opake.grant/3abc" 77 + assert attrs.owner_did == "did:plc:owner123" 78 + assert attrs.recipient_did == "did:plc:recipient456" 79 + assert attrs.document_uri == "at://did:plc:owner123/app.opake.document/3xyz" 80 + assert attrs.created_at == "2026-03-01T12:00:00Z" 81 + end 82 + 83 + test "parses grant update" do 84 + json = grant_event_json("update") 85 + assert {:upsert_grant, _attrs} = Event.parse(json) 86 + end 87 + 88 + test "parses grant delete" do 89 + json = delete_event_json("app.opake.grant", "3abc") 90 + 91 + assert {:delete_grant, %{uri: uri}} = Event.parse(json) 92 + assert uri == "at://did:plc:owner123/app.opake.grant/3abc" 93 + end 94 + 95 + test "parses keyring create" do 96 + json = keyring_event_json("create") 97 + 98 + assert {:upsert_keyring, attrs} = Event.parse(json) 99 + assert attrs.time_us == 1_709_330_500_000_000 100 + assert attrs.uri == "at://did:plc:owner123/app.opake.keyring/3def" 101 + assert attrs.owner_did == "did:plc:owner123" 102 + assert attrs.member_dids == ["did:plc:alice", "did:plc:bob"] 103 + end 104 + 105 + test "parses keyring delete" do 106 + json = delete_event_json("app.opake.keyring", "3def") 107 + 108 + assert {:delete_keyring, %{uri: uri}} = Event.parse(json) 109 + assert uri == "at://did:plc:owner123/app.opake.keyring/3def" 110 + end 111 + 112 + test "ignores identity events" do 113 + json = Jason.encode!(%{"kind" => "identity", "did" => "did:plc:test"}) 114 + assert :ignore = Event.parse(json) 115 + end 116 + 117 + test "ignores unknown collections" do 118 + json = 119 + Jason.encode!(%{ 120 + "did" => "did:plc:test", 121 + "time_us" => 1_000_000, 122 + "kind" => "commit", 123 + "commit" => %{ 124 + "rev" => "abc", 125 + "operation" => "create", 126 + "collection" => "app.bsky.feed.post", 127 + "rkey" => "123", 128 + "record" => %{} 129 + } 130 + }) 131 + 132 + assert :ignore = Event.parse(json) 133 + end 134 + 135 + test "ignores malformed json" do 136 + assert :ignore = Event.parse("not json at all") 137 + end 138 + 139 + test "ignores grant with invalid record" do 140 + json = 141 + Jason.encode!(%{ 142 + "did" => "did:plc:owner123", 143 + "time_us" => 1_000_000, 144 + "kind" => "commit", 145 + "commit" => %{ 146 + "rev" => "abc", 147 + "operation" => "create", 148 + "collection" => "app.opake.grant", 149 + "rkey" => "3abc", 150 + "record" => %{"garbage" => true} 151 + } 152 + }) 153 + 154 + assert :ignore = Event.parse(json) 155 + end 156 + end
+149
appview/test/opake_appview/queries_test.exs
··· 1 + defmodule OpakeAppview.QueriesTest do 2 + use OpakeAppview.DataCase, async: true 3 + 4 + alias OpakeAppview.Queries.{CursorQueries, GrantQueries, KeyringQueries} 5 + 6 + defp make_grant(uri, opts \\ []) do 7 + %{ 8 + uri: uri, 9 + owner_did: Keyword.get(opts, :owner_did, "did:plc:owner"), 10 + recipient_did: Keyword.get(opts, :recipient_did, "did:plc:me"), 11 + document_uri: Keyword.get(opts, :document_uri, "at://did:plc:owner/app.opake.document/3xyz"), 12 + created_at: Keyword.get(opts, :created_at, "2026-03-01T12:00:00Z"), 13 + indexed_at: Keyword.get(opts, :indexed_at, DateTime.utc_now()) 14 + } 15 + end 16 + 17 + # Cursor tests 18 + 19 + test "cursor roundtrip" do 20 + assert CursorQueries.load_cursor() == nil 21 + 22 + {:ok, _} = CursorQueries.save_cursor(1_709_330_400_000_000) 23 + cursor = CursorQueries.load_cursor() 24 + assert cursor.time_us == 1_709_330_400_000_000 25 + 26 + {:ok, _} = CursorQueries.save_cursor(1_709_330_500_000_000) 27 + cursor = CursorQueries.load_cursor() 28 + assert cursor.time_us == 1_709_330_500_000_000 29 + end 30 + 31 + # Grant tests 32 + 33 + test "grant upsert and query" do 34 + grant = make_grant("at://did:plc:owner/app.opake.grant/3abc") 35 + {:ok, _} = GrantQueries.upsert_grant(grant) 36 + 37 + {grants, _cursor} = GrantQueries.list_inbox("did:plc:me") 38 + assert length(grants) == 1 39 + assert hd(grants).uri == "at://did:plc:owner/app.opake.grant/3abc" 40 + assert hd(grants).document_uri == "at://did:plc:owner/app.opake.document/3xyz" 41 + end 42 + 43 + test "grant upsert overwrites" do 44 + grant = make_grant("at://did:plc:owner/app.opake.grant/3abc") 45 + {:ok, _} = GrantQueries.upsert_grant(grant) 46 + 47 + updated = %{grant | document_uri: "at://did:plc:owner/app.opake.document/new"} 48 + {:ok, _} = GrantQueries.upsert_grant(updated) 49 + 50 + {grants, _cursor} = GrantQueries.list_inbox("did:plc:me") 51 + assert length(grants) == 1 52 + assert hd(grants).document_uri == "at://did:plc:owner/app.opake.document/new" 53 + end 54 + 55 + test "grant delete" do 56 + grant = make_grant("at://did:plc:owner/app.opake.grant/3abc") 57 + {:ok, _} = GrantQueries.upsert_grant(grant) 58 + 59 + GrantQueries.delete_grant("at://did:plc:owner/app.opake.grant/3abc") 60 + 61 + {grants, _cursor} = GrantQueries.list_inbox("did:plc:me") 62 + assert grants == [] 63 + end 64 + 65 + test "grant pagination" do 66 + for i <- 0..4 do 67 + indexed_at = 68 + DateTime.new!(~D[2026-03-01], Time.new!(12, 0, i)) 69 + |> DateTime.from_naive!("Etc/UTC") 70 + 71 + grant = 72 + make_grant( 73 + "at://did:plc:owner/app.opake.grant/#{i}", 74 + indexed_at: indexed_at 75 + ) 76 + 77 + {:ok, _} = GrantQueries.upsert_grant(grant) 78 + end 79 + 80 + {page1, cursor1} = GrantQueries.list_inbox("did:plc:me", limit: 2) 81 + assert length(page1) == 2 82 + assert cursor1 != nil 83 + 84 + # Newest first 85 + assert DateTime.compare(hd(page1).indexed_at, List.last(page1).indexed_at) == :gt 86 + 87 + {page2, _cursor2} = GrantQueries.list_inbox("did:plc:me", limit: 2, cursor: cursor1) 88 + assert length(page2) == 2 89 + 90 + # Page 2 items are older than page 1's last item 91 + assert DateTime.compare(hd(page2).indexed_at, List.last(page1).indexed_at) == :lt 92 + end 93 + 94 + # Keyring tests 95 + 96 + test "keyring upsert and query" do 97 + {:ok, _} = 98 + KeyringQueries.upsert_keyring( 99 + "at://did:plc:owner/app.opake.keyring/3def", 100 + "did:plc:owner", 101 + ["did:plc:alice", "did:plc:bob"] 102 + ) 103 + 104 + {alice_keyrings, _} = KeyringQueries.list_keyrings_for_member("did:plc:alice") 105 + assert length(alice_keyrings) == 1 106 + 107 + {bob_keyrings, _} = KeyringQueries.list_keyrings_for_member("did:plc:bob") 108 + assert length(bob_keyrings) == 1 109 + 110 + {charlie_keyrings, _} = KeyringQueries.list_keyrings_for_member("did:plc:charlie") 111 + assert charlie_keyrings == [] 112 + end 113 + 114 + test "keyring update replaces members" do 115 + {:ok, _} = 116 + KeyringQueries.upsert_keyring( 117 + "at://did:plc:owner/app.opake.keyring/3def", 118 + "did:plc:owner", 119 + ["did:plc:alice", "did:plc:bob"] 120 + ) 121 + 122 + {:ok, _} = 123 + KeyringQueries.upsert_keyring( 124 + "at://did:plc:owner/app.opake.keyring/3def", 125 + "did:plc:owner", 126 + ["did:plc:alice", "did:plc:charlie"] 127 + ) 128 + 129 + {bob_keyrings, _} = KeyringQueries.list_keyrings_for_member("did:plc:bob") 130 + assert bob_keyrings == [] 131 + 132 + {charlie_keyrings, _} = KeyringQueries.list_keyrings_for_member("did:plc:charlie") 133 + assert length(charlie_keyrings) == 1 134 + end 135 + 136 + test "keyring delete" do 137 + {:ok, _} = 138 + KeyringQueries.upsert_keyring( 139 + "at://did:plc:owner/app.opake.keyring/3def", 140 + "did:plc:owner", 141 + ["did:plc:alice"] 142 + ) 143 + 144 + KeyringQueries.delete_keyring("at://did:plc:owner/app.opake.keyring/3def") 145 + 146 + {alice_keyrings, _} = KeyringQueries.list_keyrings_for_member("did:plc:alice") 147 + assert alice_keyrings == [] 148 + end 149 + end
+32
appview/test/opake_appview_web/controllers/health_controller_test.exs
··· 1 + defmodule OpakeAppviewWeb.HealthControllerTest do 2 + use OpakeAppviewWeb.ConnCase, async: true 3 + 4 + alias OpakeAppview.Queries.GrantQueries 5 + 6 + test "returns health status", %{conn: conn} do 7 + conn = get(conn, "/api/health") 8 + 9 + response = json_response(conn, 200) 10 + assert response["indexerConnected"] == false 11 + assert response["cursorTime"] == nil 12 + assert response["cursorAgeSecs"] == nil 13 + end 14 + 15 + test "health omits counts", %{conn: conn} do 16 + {:ok, _} = 17 + GrantQueries.upsert_grant(%{ 18 + uri: "at://did:plc:owner/app.opake.grant/3abc", 19 + owner_did: "did:plc:owner", 20 + recipient_did: "did:plc:me", 21 + document_uri: "at://did:plc:owner/app.opake.document/3xyz", 22 + created_at: "2026-03-01T12:00:00Z", 23 + indexed_at: DateTime.utc_now() 24 + }) 25 + 26 + conn = get(conn, "/api/health") 27 + 28 + response = json_response(conn, 200) 29 + refute Map.has_key?(response, "grantCount") 30 + refute Map.has_key?(response, "keyringCount") 31 + end 32 + end
+80
appview/test/opake_appview_web/controllers/inbox_controller_test.exs
··· 1 + defmodule OpakeAppviewWeb.InboxControllerTest do 2 + use OpakeAppviewWeb.ConnCase, async: false 3 + import Mox 4 + 5 + alias OpakeAppview.Queries.GrantQueries 6 + 7 + setup :set_mox_global 8 + setup :verify_on_exit! 9 + 10 + setup do 11 + :ets.delete_all_objects(:key_cache) 12 + :ok 13 + end 14 + 15 + test "requires did parameter", %{conn: conn} do 16 + did = "did:plc:test" 17 + conn = conn |> authed_conn(did, "/api/inbox") |> get("/api/inbox") 18 + 19 + response = json_response(conn, 400) 20 + assert response["error"] =~ "did" 21 + end 22 + 23 + test "returns empty for unknown did", %{conn: conn} do 24 + did = "did:plc:nobody" 25 + conn = conn |> authed_conn(did, "/api/inbox") |> get("/api/inbox?did=#{did}") 26 + 27 + response = json_response(conn, 200) 28 + assert response["grants"] == [] 29 + refute Map.has_key?(response, "cursor") 30 + end 31 + 32 + test "returns grants for recipient", %{conn: conn} do 33 + did = "did:plc:me" 34 + 35 + {:ok, _} = 36 + GrantQueries.upsert_grant(%{ 37 + uri: "at://did:plc:owner/app.opake.grant/3abc", 38 + owner_did: "did:plc:owner", 39 + recipient_did: did, 40 + document_uri: "at://did:plc:owner/app.opake.document/3xyz", 41 + created_at: "2026-03-01T12:00:00Z", 42 + indexed_at: DateTime.utc_now() 43 + }) 44 + 45 + conn = conn |> authed_conn(did, "/api/inbox") |> get("/api/inbox?did=#{did}") 46 + 47 + response = json_response(conn, 200) 48 + assert length(response["grants"]) == 1 49 + 50 + grant = hd(response["grants"]) 51 + assert grant["ownerDid"] == "did:plc:owner" 52 + assert grant["documentUri"] == "at://did:plc:owner/app.opake.document/3xyz" 53 + end 54 + 55 + test "pagination", %{conn: conn} do 56 + did = "did:plc:me" 57 + 58 + for i <- 0..4 do 59 + indexed_at = 60 + DateTime.new!(~D[2026-03-01], Time.new!(12, 0, i)) 61 + |> DateTime.from_naive!("Etc/UTC") 62 + 63 + {:ok, _} = 64 + GrantQueries.upsert_grant(%{ 65 + uri: "at://did:plc:owner/app.opake.grant/#{i}", 66 + owner_did: "did:plc:owner", 67 + recipient_did: did, 68 + document_uri: "at://did:plc:owner/app.opake.document/#{i}", 69 + created_at: "2026-03-01T12:00:00Z", 70 + indexed_at: indexed_at 71 + }) 72 + end 73 + 74 + conn = conn |> authed_conn(did, "/api/inbox") |> get("/api/inbox?did=#{did}&limit=3") 75 + 76 + response = json_response(conn, 200) 77 + assert length(response["grants"]) == 3 78 + assert is_binary(response["cursor"]) 79 + end 80 + end
+41
appview/test/opake_appview_web/controllers/keyrings_controller_test.exs
··· 1 + defmodule OpakeAppviewWeb.KeyringsControllerTest do 2 + use OpakeAppviewWeb.ConnCase, async: false 3 + import Mox 4 + 5 + alias OpakeAppview.Queries.KeyringQueries 6 + 7 + setup :set_mox_global 8 + setup :verify_on_exit! 9 + 10 + setup do 11 + :ets.delete_all_objects(:key_cache) 12 + :ok 13 + end 14 + 15 + test "requires did parameter", %{conn: conn} do 16 + did = "did:plc:test" 17 + conn = conn |> authed_conn(did, "/api/keyrings") |> get("/api/keyrings") 18 + 19 + response = json_response(conn, 400) 20 + assert response["error"] =~ "did" 21 + end 22 + 23 + test "returns memberships", %{conn: conn} do 24 + did = "did:plc:me" 25 + 26 + {:ok, _} = 27 + KeyringQueries.upsert_keyring( 28 + "at://did:plc:owner/app.opake.keyring/3def", 29 + "did:plc:owner", 30 + [did, "did:plc:other"] 31 + ) 32 + 33 + conn = conn |> authed_conn(did, "/api/keyrings") |> get("/api/keyrings?did=#{did}") 34 + 35 + response = json_response(conn, 200) 36 + assert length(response["keyrings"]) == 1 37 + 38 + keyring = hd(response["keyrings"]) 39 + assert keyring["ownerDid"] == "did:plc:owner" 40 + end 41 + end
+35
appview/test/support/conn_case.ex
··· 1 + defmodule OpakeAppviewWeb.ConnCase do 2 + use ExUnit.CaseTemplate 3 + 4 + using do 5 + quote do 6 + @endpoint OpakeAppviewWeb.Endpoint 7 + 8 + import Plug.Conn 9 + import Phoenix.ConnTest 10 + import OpakeAppviewWeb.ConnCase 11 + end 12 + end 13 + 14 + setup tags do 15 + OpakeAppview.DataCase.setup_sandbox(tags) 16 + {:ok, conn: Phoenix.ConnTest.build_conn()} 17 + end 18 + 19 + @doc """ 20 + Creates an authenticated conn with a fresh Ed25519 keypair and Mox expectation. 21 + Sets the `Opake-Ed25519` header for the given DID and path. 22 + """ 23 + def authed_conn(conn, did, path) do 24 + {pubkey, privkey} = :crypto.generate_key(:eddsa, :ed25519) 25 + 26 + Mox.expect(OpakeAppview.Auth.KeyFetcherMock, :fetch_signing_key, fn ^did -> {:ok, pubkey} end) 27 + 28 + timestamp = System.system_time(:second) 29 + message = "GET:#{path}:#{timestamp}:#{did}" 30 + signature = :crypto.sign(:eddsa, :none, message, [privkey, :ed25519]) 31 + sig_b64 = Base.encode64(signature) 32 + 33 + Plug.Conn.put_req_header(conn, "authorization", "Opake-Ed25519 #{did}:#{timestamp}:#{sig_b64}") 34 + end 35 + end
+58
appview/test/support/data_case.ex
··· 1 + defmodule OpakeAppview.DataCase do 2 + @moduledoc """ 3 + This module defines the setup for tests requiring 4 + access to the application's data layer. 5 + 6 + You may define functions here to be used as helpers in 7 + your tests. 8 + 9 + Finally, if the test case interacts with the database, 10 + we enable the SQL sandbox, so changes done to the database 11 + are reverted at the end of every test. If you are using 12 + PostgreSQL, you can even run database tests asynchronously 13 + by setting `use OpakeAppview.DataCase, async: true`, although 14 + this option is not recommended for other databases. 15 + """ 16 + 17 + use ExUnit.CaseTemplate 18 + 19 + using do 20 + quote do 21 + alias OpakeAppview.Repo 22 + 23 + import Ecto 24 + import Ecto.Changeset 25 + import Ecto.Query 26 + import OpakeAppview.DataCase 27 + end 28 + end 29 + 30 + setup tags do 31 + OpakeAppview.DataCase.setup_sandbox(tags) 32 + :ok 33 + end 34 + 35 + @doc """ 36 + Sets up the sandbox based on the test tags. 37 + """ 38 + def setup_sandbox(tags) do 39 + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(OpakeAppview.Repo, shared: not tags[:async]) 40 + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 + end 42 + 43 + @doc """ 44 + A helper that transforms changeset errors into a map of messages. 45 + 46 + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 47 + assert "password is too short" in errors_on(changeset).password 48 + assert %{password: ["password is too short"]} = errors_on(changeset) 49 + 50 + """ 51 + def errors_on(changeset) do 52 + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 + Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 + end) 56 + end) 57 + end 58 + end
+5
appview/test/test_helper.exs
··· 1 + Mox.defmock(OpakeAppview.Auth.KeyFetcherMock, for: OpakeAppview.Auth.KeyFetcherBehaviour) 2 + Application.put_env(:opake_appview, :key_fetcher, OpakeAppview.Auth.KeyFetcherMock) 3 + 4 + ExUnit.start() 5 + Ecto.Adapters.SQL.Sandbox.mode(OpakeAppview.Repo, :manual)
+1
web/.env.development
··· 1 + VITE_APPVIEW_URL=http://localhost:6100