A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

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

feat(sdk): allow client to configure clientId

Trezy e76acbea deb4aeef

+118 -46
+1
packages/docs/docs/sdk/lex-agent.md
··· 21 21 22 22 const client = new HappyViewBrowserClient({ 23 23 instanceUrl: "https://happyview.example.com", 24 + clientId: "https://example.com/oauth-client-metadata.json", 24 25 clientKey: "hvc_your_client_key", 25 26 }); 26 27
+44 -2
packages/docs/docs/sdk/oauth-client-browser.md
··· 17 17 18 18 const client = new HappyViewBrowserClient({ 19 19 instanceUrl: "https://happyview.example.com", 20 + clientId: "https://example.com/oauth-client-metadata.json", 20 21 clientKey: "hvc_your_client_key", 21 22 }); 22 23 ``` 23 24 24 - The client uses Web Crypto and localStorage by default. You can override either: 25 + | Option | Required | Description | 26 + | ------------- | -------- | ----------------------------------------------------------------------------- | 27 + | `instanceUrl` | Yes | The HappyView instance URL | 28 + | `clientId` | Yes | URL where your app serves its [OAuth client metadata](#oauth-client-metadata) | 29 + | `clientKey` | Yes | API client key from the HappyView admin dashboard | 30 + | `redirectUri` | No | OAuth callback URL. Defaults to `${window.location.origin}/oauth/callback` | 31 + | `scopes` | No | OAuth scopes to request. Defaults to `"atproto"` | 32 + | `storage` | No | Custom storage adapter. Defaults to localStorage | 33 + | `fetch` | No | Custom fetch implementation | 34 + 35 + The client uses localStorage by default. You can override it: 25 36 26 37 ```typescript 27 38 const client = new HappyViewBrowserClient({ 28 39 instanceUrl: "https://happyview.example.com", 40 + clientId: "https://example.com/oauth-client-metadata.json", 29 41 clientKey: "hvc_your_client_key", 30 - crypto: myCustomCryptoAdapter, 31 42 storage: myCustomStorageAdapter, 32 43 }); 33 44 ``` ··· 128 139 const pdsUrl = resolvePdsUrl(doc); 129 140 const authMeta = await resolveAuthServerMetadata(pdsUrl); 130 141 ``` 142 + 143 + ## OAuth client metadata 144 + 145 + Your app must serve an OAuth client metadata JSON document at the URL you pass as `clientId`. The PDS fetches this during authorization to validate the redirect URI and display your app's information. 146 + 147 + Example for a Next.js app: 148 + 149 + ```typescript 150 + // src/app/oauth-client-metadata.json/route.ts 151 + import { type NextRequest } from "next/server"; 152 + 153 + export function GET(request: NextRequest) { 154 + const origin = request.nextUrl.origin; 155 + 156 + return Response.json({ 157 + client_id: `${origin}/oauth-client-metadata.json`, 158 + client_name: "My App", 159 + client_uri: origin, 160 + redirect_uris: [`${origin}/oauth/callback`], 161 + token_endpoint_auth_method: "none", 162 + grant_types: ["authorization_code", "refresh_token"], 163 + scope: "atproto", 164 + application_type: "web", 165 + dpop_bound_access_tokens: true, 166 + }); 167 + } 168 + ``` 169 + 170 + For a static site, serve a plain JSON file at `/oauth-client-metadata.json`. 171 + 172 + The `redirect_uris` array must include the `redirectUri` your client is configured with (defaults to `${origin}/oauth/callback`). 131 173 132 174 ## Re-exports 133 175
+5 -4
packages/docs/docs/sdk/overview.md
··· 2 2 3 3 HappyView provides JavaScript packages for building third-party apps that authenticate with a HappyView instance and make XRPC requests on behalf of users. 4 4 5 - | Package | Purpose | 6 - | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | 5 + | Package | Purpose | 6 + | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | 7 7 | [`@happyview/lex-agent`](https://npmx.dev/package/@happyview/lex-agent) | Recommended — type-safe XRPC via [`@atproto/lex`](https://npmx.dev/package/@atproto/lex) `Client` with HappyView DPoP auth | 8 - | [`@happyview/oauth-client`](https://npmx.dev/package/@happyview/oauth-client) | Platform-agnostic core — DPoP key provisioning, session management, authenticated fetch | 9 - | [`@happyview/oauth-client-browser`](https://npmx.dev/package/@happyview/oauth-client-browser) | Browser OAuth wrapper for apps already using `@atproto/oauth-client-browser` | 8 + | [`@happyview/oauth-client`](https://npmx.dev/package/@happyview/oauth-client) | Platform-agnostic core — DPoP key provisioning, session management, authenticated fetch | 9 + | [`@happyview/oauth-client-browser`](https://npmx.dev/package/@happyview/oauth-client-browser) | Browser OAuth wrapper for apps already using `@atproto/oauth-client-browser` | 10 10 11 11 ## Which package do I need? 12 12 ··· 39 39 // Set up the OAuth client 40 40 const oauthClient = new HappyViewBrowserClient({ 41 41 instanceUrl: "https://happyview.example.com", 42 + clientId: "https://example.com/oauth-client-metadata.json", 42 43 clientKey: "hvc_your_client_key", 43 44 }); 44 45
+60 -25
packages/oauth-client-browser/src/__tests__/browser-client.test.ts
··· 26 26 function createClient(fetchFn?: typeof globalThis.fetch) { 27 27 return new HappyViewBrowserClient({ 28 28 instanceUrl: "https://happyview.example.com", 29 + clientId: "https://example.com/oauth-client-metadata.json", 29 30 clientKey: "hvc_test", 30 31 storage: new LocalStorageAdapter(), 31 32 fetch: fetchFn, ··· 40 41 return new Response( 41 42 JSON.stringify({ 42 43 Status: 0, 43 - Answer: [{ name: "_atproto.user.bsky.social.", type: 16, TTL: 300, data: '"did=did:plc:abcdefghijklmnopqrstuvwx"' }], 44 + Answer: [ 45 + { 46 + name: "_atproto.user.bsky.social.", 47 + type: 16, 48 + TTL: 300, 49 + data: '"did=did:plc:abcdefghijklmnopqrstuvwx"', 50 + }, 51 + ], 44 52 }), 45 53 { status: 200, headers: { "content-type": "application/dns-json" } }, 46 54 ); ··· 50 58 return new Response( 51 59 JSON.stringify({ 52 60 id: "did:plc:abcdefghijklmnopqrstuvwx", 53 - service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }], 61 + service: [ 62 + { 63 + id: "#atproto_pds", 64 + type: "AtprotoPersonalDataServer", 65 + serviceEndpoint: "https://pds.example.com", 66 + }, 67 + ], 54 68 }), 55 69 { status: 200, headers: { "content-type": "application/json" } }, 56 70 ); ··· 71 85 issuer: "https://pds.example.com", 72 86 authorization_endpoint: "https://pds.example.com/oauth/authorize", 73 87 token_endpoint: "https://pds.example.com/oauth/token", 74 - pushed_authorization_request_endpoint: "https://pds.example.com/oauth/par", 88 + pushed_authorization_request_endpoint: 89 + "https://pds.example.com/oauth/par", 75 90 }), 76 91 { status: 200 }, 77 92 ); ··· 89 104 90 105 if (url.includes("/oauth/par")) { 91 106 return new Response( 92 - JSON.stringify({ request_uri: "urn:ietf:params:oauth:request_uri:test", expires_in: 60 }), 107 + JSON.stringify({ 108 + request_uri: "urn:ietf:params:oauth:request_uri:test", 109 + expires_in: 60, 110 + }), 93 111 { status: 201 }, 94 112 ); 95 113 } 96 114 97 115 if (url.includes("/oauth/sessions") && init?.method === "POST") { 98 116 return new Response( 99 - JSON.stringify({ session_id: "sess_test", did: "did:plc:abcdefghijklmnopqrstuvwx" }), 117 + JSON.stringify({ 118 + session_id: "sess_test", 119 + did: "did:plc:abcdefghijklmnopqrstuvwx", 120 + }), 100 121 { status: 201 }, 101 122 ); 102 123 } ··· 123 144 test("constructor sets up LocalStorageAdapter by default", () => { 124 145 const client = new HappyViewBrowserClient({ 125 146 instanceUrl: "https://happyview.example.com", 147 + clientId: "https://example.com/oauth-client-metadata.json", 126 148 clientKey: "hvc_test", 127 149 }); 128 150 expect(client).toBeDefined(); ··· 136 158 }; 137 159 const client = new HappyViewBrowserClient({ 138 160 instanceUrl: "https://happyview.example.com", 161 + clientId: "https://example.com/oauth-client-metadata.json", 139 162 clientKey: "hvc_test", 140 163 storage: customStorage, 141 164 }); ··· 181 204 expect(session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); 182 205 183 206 // Verify token exchange included DPoP proof header 184 - const tokenCall = fetchFn.mock.calls.find( 185 - (call: any[]) => String(call[0]).includes("/oauth/token"), 207 + const tokenCall = fetchFn.mock.calls.find((call: any[]) => 208 + String(call[0]).includes("/oauth/token"), 186 209 ); 187 210 expect(tokenCall).toBeDefined(); 188 211 const tokenInit = tokenCall![1] as RequestInit; ··· 246 269 247 270 await client.callback("?code=auth-code&state=state789"); 248 271 249 - const tokenCall = fetchFn.mock.calls.find( 250 - (call: any[]) => String(call[0]).includes("/oauth/token"), 272 + const tokenCall = fetchFn.mock.calls.find((call: any[]) => 273 + String(call[0]).includes("/oauth/token"), 251 274 ); 252 275 expect(tokenCall).toBeDefined(); 253 - const body = new URLSearchParams((tokenCall![1] as RequestInit).body as string); 276 + const body = new URLSearchParams( 277 + (tokenCall![1] as RequestInit).body as string, 278 + ); 254 279 expect(body.get("code_verifier")).toBe("auth-verifier"); 255 280 }); 256 281 ··· 308 333 309 334 await client.callback("?code=auth-code&state=stateathtest"); 310 335 311 - const tokenCall = fetchFn.mock.calls.find( 312 - (call: any[]) => String(call[0]).includes("/oauth/token"), 336 + const tokenCall = fetchFn.mock.calls.find((call: any[]) => 337 + String(call[0]).includes("/oauth/token"), 313 338 ); 314 - const dpopJwt = new Headers((tokenCall![1] as RequestInit).headers).get("dpop")!; 339 + const dpopJwt = new Headers((tokenCall![1] as RequestInit).headers).get( 340 + "dpop", 341 + )!; 315 342 const payloadB64 = dpopJwt.split(".")[1]; 316 343 const padded = payloadB64 + "=".repeat((4 - (payloadB64.length % 4)) % 4); 317 - const payload = JSON.parse(atob(padded.replace(/-/g, "+").replace(/_/g, "/"))); 344 + const payload = JSON.parse( 345 + atob(padded.replace(/-/g, "+").replace(/_/g, "/")), 346 + ); 318 347 expect(payload.ath).toBeUndefined(); 319 348 expect(payload.htm).toBe("POST"); 320 349 expect(payload.htu).toBe("https://pds.example.com/oauth/token"); ··· 341 370 }); 342 371 343 372 test("callback throws TokenExchangeError on token endpoint failure", async () => { 344 - const fetchFn = mock(async (input: RequestInfo | URL, init?: RequestInit) => { 345 - const url = String(input); 346 - if (url.includes("/oauth/token")) { 347 - return new Response("invalid_grant", { status: 400 }); 348 - } 349 - return new Response("not found", { status: 404 }); 350 - }); 373 + const fetchFn = mock( 374 + async (input: RequestInfo | URL, init?: RequestInit) => { 375 + const url = String(input); 376 + if (url.includes("/oauth/token")) { 377 + return new Response("invalid_grant", { status: 400 }); 378 + } 379 + return new Response("not found", { status: 404 }); 380 + }, 381 + ); 351 382 352 383 const client = createClient(fetchFn); 353 384 ··· 428 459 ); 429 460 430 461 // Mock the DELETE response 431 - const deleteFn = mock(async (input: RequestInfo | URL, init?: RequestInit) => { 432 - return new Response(null, { status: 204 }); 433 - }); 462 + const deleteFn = mock( 463 + async (input: RequestInfo | URL, init?: RequestInit) => { 464 + return new Response(null, { status: 204 }); 465 + }, 466 + ); 434 467 const logoutClient = createClient(deleteFn); 435 468 436 469 await logoutClient.logout("did:plc:abcdefghijklmnopqrstuvwx"); 437 470 438 471 expect( 439 - localStorage.getItem("@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)"), 472 + localStorage.getItem( 473 + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", 474 + ), 440 475 ).toBeNull(); 441 476 expect( 442 477 localStorage.getItem("@happyview/oauth(happyview:last-active-did)"),
+8 -15
packages/oauth-client-browser/src/browser-client.ts
··· 14 14 15 15 export interface HappyViewBrowserClientOptions { 16 16 instanceUrl: string; 17 + clientId: string; 17 18 clientKey: string; 19 + redirectUri?: string; 18 20 scopes?: string; 19 21 storage?: StorageAdapter; 20 22 fetch?: typeof globalThis.fetch; ··· 49 51 export class HappyViewBrowserClient extends HappyViewOAuthClient { 50 52 private readonly handleResolver: AtprotoDohHandleResolver; 51 53 private readonly didResolver: DidResolverCommon; 54 + private readonly clientId: string; 55 + private readonly redirectUri: string | undefined; 52 56 private readonly scopes: string; 53 57 constructor(options: HappyViewBrowserClientOptions) { 54 58 const fetchFn = options.fetch ?? (((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch); ··· 60 64 fetch: fetchFn, 61 65 }); 62 66 67 + this.clientId = options.clientId; 68 + this.redirectUri = options.redirectUri; 63 69 this.scopes = options.scopes ?? "atproto"; 64 70 this.handleResolver = new AtprotoDohHandleResolver({ 65 71 dohEndpoint: "https://dns.google/resolve", ··· 289 295 } 290 296 291 297 private resolveOAuthEndpoints(): { clientId: string; redirectUri: string } { 292 - const isLoopback = 293 - window.location.hostname === "127.0.0.1" || 294 - window.location.hostname === "[::1]" || 295 - window.location.hostname === "localhost"; 296 - 297 - if (isLoopback) { 298 - const params = new URLSearchParams({ scope: this.scopes }); 299 - return { 300 - clientId: `http://localhost?${params}`, 301 - redirectUri: `http://127.0.0.1:${window.location.port}/`, 302 - }; 303 - } 304 - 305 298 return { 306 - clientId: `${this.instanceUrl}/oauth-client-metadata.json`, 307 - redirectUri: `${window.location.origin}/oauth/callback`, 299 + clientId: this.clientId, 300 + redirectUri: this.redirectUri ?? `${window.location.origin}/oauth/callback`, 308 301 }; 309 302 } 310 303