this repo has no description
1
fork

Configure Feed

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

Wire web inbox to appview /api/inbox with Opake-Ed25519 auth [CL-150]

Full authenticated pipeline: WASM signing export → crypto worker →
authenticatedAppview() → listIncomingGrants() → shared.tsx.

- Add signAppviewRequest WASM export wrapping opake-core Ed25519 signing
- Replace unauthenticated appview() with authenticatedAppview() in api.ts
- listIncomingGrants now takes signingKey, signs requests
- Remove hard-coded test grant from shared.tsx, wire real inbox data
- Graceful fallback when signing key absent or appview unreachable
- Add CORS plug to appview (configurable origin, OPTIONS preflight)
- Add CORS_ORIGIN env var support in runtime.exs
- Fix key_fetcher: handle plc.directory's application/did+ld+json
- Add indexer event logging for grant/keyring upsert and delete
- Switch dev jetstream to Frankfurt firehose.stream (European relay)
- Handle Tranquil PDS returning 400 *NotFound instead of 404

+156 -30
+2 -1
appview/config/dev.exs
··· 18 18 watchers: [] 19 19 20 20 config :opake_appview, 21 - jetstream_url: "wss://jetstream2.us-east.bsky.network/subscribe" 21 + jetstream_url: "wss://frankfurt.firehose.stream/tap", 22 + cors_origin: "*" 22 23 23 24 config :opake_appview, dev_routes: true 24 25
+4
appview/config/runtime.exs
··· 12 12 config :opake_appview, :jetstream_url, jetstream_url 13 13 end 14 14 15 + if cors_origin = System.get_env("CORS_ORIGIN") do 16 + config :opake_appview, :cors_origin, cors_origin 17 + end 18 + 15 19 if config_env() == :prod do 16 20 database_url = 17 21 System.get_env("DATABASE_URL") ||
+7
appview/lib/opake_appview/auth/key_fetcher.ex
··· 87 87 {:ok, %Req.Response{status: 200, body: body}} when is_map(body) -> 88 88 {:ok, body} 89 89 90 + # PLC directory returns application/did+ld+json which Req doesn't auto-decode 91 + {:ok, %Req.Response{status: 200, body: body}} when is_binary(body) -> 92 + case Jason.decode(body) do 93 + {:ok, decoded} when is_map(decoded) -> {:ok, decoded} 94 + _ -> {:error, "failed to decode JSON from #{url}"} 95 + end 96 + 90 97 {:ok, %Req.Response{status: status}} -> 91 98 {:error, "HTTP #{status} from #{url}"} 92 99
+4
appview/lib/opake_appview/indexer.ex
··· 32 32 def process_message(json, event_count) do 33 33 case Event.parse(json) do 34 34 {:upsert_grant, attrs} -> 35 + Logger.info("Indexing grant upsert: #{attrs.uri} (owner=#{attrs.owner_did}, recipient=#{attrs.recipient_did})") 35 36 handle_upsert_grant(attrs) 36 37 maybe_save_cursor(attrs.time_us, event_count + 1) 37 38 38 39 {:delete_grant, %{uri: uri, time_us: time_us}} -> 40 + Logger.info("Indexing grant delete: #{uri}") 39 41 GrantQueries.delete_grant(uri) 40 42 maybe_save_cursor(time_us, event_count + 1) 41 43 42 44 {:upsert_keyring, attrs} -> 45 + Logger.info("Indexing keyring upsert: #{attrs.uri} (owner=#{attrs.owner_did}, members=#{length(attrs.member_dids)})") 43 46 handle_upsert_keyring(attrs) 44 47 maybe_save_cursor(attrs.time_us, event_count + 1) 45 48 46 49 {:delete_keyring, %{uri: uri, time_us: time_us}} -> 50 + Logger.info("Indexing keyring delete: #{uri}") 47 51 KeyringQueries.delete_keyring(uri) 48 52 maybe_save_cursor(time_us, event_count + 1) 49 53
+2
appview/lib/opake_appview_web/endpoint.ex
··· 9 9 plug Plug.RequestId 10 10 plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 11 11 12 + plug OpakeAppviewWeb.Plugs.CORS 13 + 12 14 plug Plug.Parsers, 13 15 parsers: [:urlencoded, :json], 14 16 pass: ["*/*"],
+35
appview/lib/opake_appview_web/plugs/cors.ex
··· 1 + defmodule OpakeAppviewWeb.Plugs.CORS do 2 + @moduledoc """ 3 + Minimal CORS plug. Allowed origin is configured per-environment: 4 + 5 + config :opake_appview, :cors_origin, "*" # dev 6 + config :opake_appview, :cors_origin, "https://opake.app" # prod 7 + 8 + Handles OPTIONS preflight and sets headers on all responses. 9 + """ 10 + 11 + import Plug.Conn 12 + 13 + @behaviour Plug 14 + 15 + @impl true 16 + def init(opts), do: opts 17 + 18 + @impl true 19 + def call(conn, _opts) do 20 + origin = Application.get_env(:opake_appview, :cors_origin, "*") 21 + 22 + conn = 23 + conn 24 + |> put_resp_header("access-control-allow-origin", origin) 25 + |> put_resp_header("access-control-allow-methods", "GET, OPTIONS") 26 + |> put_resp_header("access-control-allow-headers", "authorization, content-type") 27 + |> put_resp_header("access-control-max-age", "86400") 28 + 29 + if conn.method == "OPTIONS" do 30 + conn |> send_resp(204, "") |> halt() 31 + else 32 + conn 33 + end 34 + end 35 + end
+13 -3
crates/opake-core/src/client/xrpc/mod.rs
··· 150 150 message: Option<String>, 151 151 } 152 152 153 - let message = serde_json::from_slice::<XrpcError>(&response.body) 154 - .ok() 153 + let parsed = serde_json::from_slice::<XrpcError>(&response.body).ok(); 154 + 155 + let error_code = parsed.as_ref().and_then(|e| e.error.clone()); 156 + 157 + let message = parsed 155 158 .and_then(|e| match (e.error, e.message) { 156 159 (Some(code), Some(msg)) => Some(format!("{code}: {msg}")), 157 160 (Some(code), None) => Some(code), ··· 160 163 }) 161 164 .unwrap_or_else(|| format!("HTTP {}", response.status)); 162 165 163 - if response.status == 404 { 166 + // 404 is the standard "not found" status. Some PDS implementations 167 + // (e.g. Tranquil) return 400 with a *NotFound error code instead. 168 + let is_not_found = response.status == 404 169 + || error_code 170 + .as_deref() 171 + .is_some_and(|c| c.ends_with("NotFound")); 172 + 173 + if is_not_found { 164 174 Err(Error::NotFound(message)) 165 175 } else { 166 176 Err(Error::Xrpc {
+27
crates/opake-wasm/src/lib.rs
··· 404 404 } 405 405 406 406 // --------------------------------------------------------------------------- 407 + // AppView auth signing 408 + // --------------------------------------------------------------------------- 409 + 410 + /// Sign an appview request and return the full Authorization header value. 411 + /// 412 + /// Returns: `Opake-Ed25519 <did>:<timestamp>:<base64(signature)>` 413 + #[wasm_bindgen(js_name = signAppviewRequest)] 414 + pub fn sign_appview_request_js( 415 + method: &str, 416 + path: &str, 417 + did: &str, 418 + signing_key: &[u8], 419 + timestamp: f64, 420 + ) -> Result<String, JsError> { 421 + let key: [u8; 32] = signing_key 422 + .try_into() 423 + .map_err(|_| JsError::new("signing key must be exactly 32 bytes"))?; 424 + Ok(opake_core::client::sign_appview_request( 425 + method, 426 + path, 427 + did, 428 + &key, 429 + timestamp as u64, 430 + )) 431 + } 432 + 433 + // --------------------------------------------------------------------------- 407 434 // DID document utilities 408 435 // --------------------------------------------------------------------------- 409 436
+22 -4
web/src/lib/api.ts
··· 427 427 } 428 428 429 429 // --------------------------------------------------------------------------- 430 - // AppView (unauthenticated) 430 + // AppView (authenticated with Opake-Ed25519) 431 431 // --------------------------------------------------------------------------- 432 432 433 - export async function appview(path: string, config: ApiConfig = defaultConfig): Promise<unknown> { 434 - const response = await fetch(`${config.appviewUrl}${path}`); 433 + interface AuthenticatedAppviewParams { 434 + readonly appviewUrl: string; 435 + readonly path: string; 436 + readonly did: string; 437 + readonly signingKey: Uint8Array; 438 + } 439 + 440 + export async function authenticatedAppview(params: AuthenticatedAppviewParams): Promise<unknown> { 441 + const { appviewUrl, path, did, signingKey } = params; 442 + const worker = getCryptoWorker(); 443 + const timestamp = Math.floor(Date.now() / 1000); 444 + 445 + // Signature covers only the path (no query string), matching appview's conn.request_path 446 + const pathOnly = path.split("?")[0]; 447 + const authHeader = await worker.signAppviewRequest("GET", pathOnly, did, signingKey, timestamp); 448 + 449 + const response = await fetch(`${appviewUrl}${path}`, { 450 + headers: { Authorization: authHeader }, 451 + }); 435 452 436 453 if (!response.ok) { 437 - throw new Error(`AppView ${path}: ${response.status}`); 454 + const detail = await response.text().catch(() => ""); 455 + throw new Error(`AppView ${path}: ${response.status} ${detail}`.trim()); 438 456 } 439 457 440 458 return response.json();
+12 -7
web/src/lib/sharing.ts
··· 4 4 import type { EncryptedMetadataEnvelope, DocumentRecord, DocumentMetadata } from "@/lib/pdsTypes"; 5 5 import type { DecryptedBlob } from "@/lib/preview"; 6 6 import type { Session } from "@/lib/storageTypes"; 7 - import { authenticatedXrpc, authenticatedDeleteRecord, appview } from "@/lib/api"; 7 + import { authenticatedXrpc, authenticatedDeleteRecord, authenticatedAppview } from "@/lib/api"; 8 8 import { resolveHandleToPds } from "@/lib/oauth"; 9 9 import { pdsUrlFromDid } from "@/lib/did"; 10 10 import { getCryptoWorker } from "@/lib/worker"; ··· 191 191 readonly cursor?: string; 192 192 } 193 193 194 - /** Fetch incoming grants from the AppView inbox. */ 195 - export async function listIncomingGrants(did: string): Promise<InboxGrantItem[]> { 194 + /** Fetch incoming grants from the AppView inbox (authenticated). */ 195 + export async function listIncomingGrants( 196 + did: string, 197 + signingKey: Uint8Array, 198 + ): Promise<InboxGrantItem[]> { 196 199 const config = await storage.loadConfig().catch(() => null); 197 200 const appviewUrl = config?.appviewUrl; 198 201 if (!appviewUrl) return []; ··· 202 205 let cursor: string | undefined; 203 206 204 207 do { 205 - const params = new URLSearchParams({ did, limit: "100" }); 206 - if (cursor) params.set("cursor", cursor); 208 + const query = new URLSearchParams({ did, limit: "100" }); 209 + if (cursor) query.set("cursor", cursor); 207 210 208 - const response = (await appview(`/api/inbox?${params}`, { 209 - pdsUrl: "", 211 + const response = (await authenticatedAppview({ 210 212 appviewUrl, 213 + path: `/api/inbox?${query}`, 214 + did, 215 + signingKey, 211 216 })) as InboxResponse; 212 217 213 218 items.push(...response.grants);
+13 -15
web/src/routes/cabinet/shared.tsx
··· 203 203 try { 204 204 const oauthSession = (await storage.loadSession(session.did)) as OAuthSession; 205 205 206 + const identity = await storage.loadIdentity(session.did); 207 + const privateKey = base64ToUint8Array(identity.private_key); 208 + const signingKey = identity.signing_key ? base64ToUint8Array(identity.signing_key) : null; 209 + 206 210 const [out, inc] = await Promise.all([ 207 211 listOutgoingGrants(session.pdsUrl, session.did, oauthSession), 208 - listIncomingGrants(session.did), 212 + signingKey 213 + ? listIncomingGrants(session.did, signingKey).catch((err: unknown) => { 214 + console.warn("[shared] inbox fetch failed, showing outgoing only:", err); 215 + return [] as InboxGrantItem[]; 216 + }) 217 + : Promise.resolve([]), 209 218 ]); 210 219 211 - // REMOVE — hard-coded test grant 212 - const testIncoming: InboxGrantItem = { 213 - uri: "at://did:plc:jgevbp3tq46mkjcavmwfitgb/app.opake.grant/3mgnw5blxtn22", 214 - ownerDid: "did:plc:jgevbp3tq46mkjcavmwfitgb", 215 - documentUri: "", 216 - createdAt: new Date().toISOString(), 217 - }; 218 - const allIncoming = [testIncoming, ...inc]; 219 - 220 220 setOutgoing(out); 221 - setIncoming(allIncoming); 221 + setIncoming(inc); 222 222 223 223 // Pre-resolve unique owner PDS URLs, then resolve each grant 224 - const identity = await storage.loadIdentity(session.did); 225 - const privateKey = base64ToUint8Array(identity.private_key); 226 - const uniqueOwnerDids = [...new Set(allIncoming.map((g) => g.ownerDid))]; 224 + const uniqueOwnerDids = [...new Set(inc.map((g) => g.ownerDid))]; 227 225 const pdsResults = await Promise.all( 228 226 uniqueOwnerDids.map((did) => 229 227 pdsUrlFromDid(did) ··· 233 231 ); 234 232 const pdsUrlCache = new Map(pdsResults.filter((r): r is NonNullable<typeof r> => r !== null)); 235 233 236 - allIncoming.forEach((grant) => { 234 + inc.forEach((grant) => { 237 235 const ownerPds = pdsUrlCache.get(grant.ownerDid); 238 236 if (!ownerPds) return; 239 237 void resolveIncomingGrant(grant, privateKey, ownerPds)
+15
web/src/workers/crypto.worker.ts
··· 18 18 generatePkce as wasmGeneratePkce, 19 19 generateIdentity as wasmGenerateIdentity, 20 20 generateEphemeralKeypair as wasmGenerateEphemeralKeypair, 21 + signAppviewRequest as wasmSignAppviewRequest, 21 22 didDocumentUrl as wasmDidDocumentUrl, 22 23 handleFromDidDocument as wasmHandleFromDidDocument, 23 24 pdsFromDidDocument as wasmPdsFromDidDocument, ··· 147 148 148 149 generateEphemeralKeypair(): EphemeralKeypair { 149 150 return wasmGenerateEphemeralKeypair() as EphemeralKeypair; 151 + }, 152 + 153 + // --------------------------------------------------------------------------- 154 + // AppView auth signing 155 + // --------------------------------------------------------------------------- 156 + 157 + signAppviewRequest( 158 + method: string, 159 + path: string, 160 + did: string, 161 + signingKey: Uint8Array, 162 + timestamp: number, 163 + ): string { 164 + return wasmSignAppviewRequest(method, path, did, signingKey, timestamp); 150 165 }, 151 166 152 167 // ---------------------------------------------------------------------------