diff --git a/packages/docs/docs/getting-started/authentication.md b/packages/docs/docs/getting-started/authentication.md index 058fa8c..91bbd7c 100644 --- a/packages/docs/docs/getting-started/authentication.md +++ b/packages/docs/docs/getting-started/authentication.md @@ -9,9 +9,9 @@ HappyView has two distinct authentication surfaces: | Endpoint type | Client identification | User authentication | | ----------------------------------- | ------------------------ | ------------------------------------------------------------------------------------ | -| Queries (`GET /xrpc/{method}`) | `X-Client-Key` required | Optional — provide a session if the query needs to know who the user is | -| Procedures (`POST /xrpc/{method}`) | `X-Client-Key` required | Required — a live OAuth session so HappyView can proxy writes to the user's PDS | -| Admin API (`/admin/*`) | — | Required — must be a HappyView user with the right [permissions](../guides/permissions.md) | +| Queries (`GET /xrpc/{method}`) | `X-Client-Key` required | Optional — DPoP auth if the query needs to know who the user is | +| Procedures (`POST /xrpc/{method}`) | `X-Client-Key` required | Required — DPoP auth so HappyView can proxy writes to the user's PDS | +| Admin API (`/admin/*`) | — | Required — session cookie, admin API key, or service auth JWT with the right [permissions](../guides/permissions.md) | | Health check (`GET /health`) | — | — | ## XRPC: API client identification @@ -48,17 +48,48 @@ curl 'https://happyview.example.com/xrpc/com.example.feed.getHot' \ -H 'X-Client-Secret: hvs_d4e5f6...' ``` -### Logging a user in so you can call procedures +### Authenticating users for procedures -Queries that don't care who is calling need nothing more than the client key. Procedures — and queries whose Lua scripts read the caller's DID — need a real AT Protocol OAuth session. The shape of the flow: +Queries that don't care who is calling need nothing more than the client key. Procedures — and queries whose Lua scripts read the caller's DID — need a real AT Protocol OAuth session. -1. Publish a client metadata document at your API client's `client_id_url`. -2. Redirect the user to HappyView's OAuth authorize endpoint with your `hvc_…` key as `client_id`. -3. Exchange the authorization code at the token endpoint using your client key + `hvs_…` secret. -4. HappyView sets a signed session cookie containing the user's DID and your client key. Subsequent XRPC requests made with that cookie are automatically attributed to your client — you don't need to also send `X-Client-Key`. +XRPC routes only accept **DPoP auth** (`Authorization: DPoP ` + `DPoP` proof header + `X-Client-Key`). Bearer tokens, service auth JWTs, and session cookies are not accepted on XRPC endpoints. + +Third-party apps authenticate users through the [DPoP key provisioning](#dpop-key-provisioning-for-third-party-apps) flow: your app gets a DPoP keypair from HappyView, runs a standard OAuth flow with the user's PDS using that keypair, then registers the resulting tokens back with HappyView. + +The [JavaScript SDK](../sdk/overview.md) handles this entire flow for you: + +```typescript +import { Client } from "@atproto/lex"; +import { HappyViewBrowserClient } from "@happyview/oauth-client-browser"; +import { createAgent } from "@happyview/lex-agent"; + +const oauthClient = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientKey: "hvc_your_client_key", +}); + +// Login — redirects to the user's PDS for authorization +await oauthClient.login("alice.bsky.social"); + +// On /oauth/callback — complete the token exchange +const session = await oauthClient.callback(); + +// Create a type-safe Lex client +const agent = createAgent(session); +const lex = new Client(agent); + +// Make authenticated XRPC calls +await lex.xrpc(myLexicons.com.example.createPost, { + input: { text: "Hello from HappyView!" }, +}); +``` For procedures, HappyView proxies the write to the user's PDS using the stored OAuth session (see [Proxying procedures](#proxying-procedures-to-the-users-pds) below). +:::note +The HappyView dashboard uses a separate cookie-based OAuth flow where HappyView itself acts as the OAuth server. This is only for the dashboard — third-party apps always use DPoP key provisioning. +::: + ## Admin API: user authentication Admin endpoints don't use API clients. They require a real HappyView user, identified by one of three methods: @@ -113,6 +144,10 @@ Third-party apps that want HappyView to make PDS writes on behalf of their users The idea: the app gets a DPoP keypair from HappyView, uses that keypair during its own OAuth flow with the user's PDS, then registers the resulting tokens back with HappyView. From that point on, XRPC requests authenticated with `Authorization: DPoP ` plus a `DPoP` proof header and `X-Client-Key` will have HappyView proxy writes using the stored session. +:::tip +The [JavaScript SDK](../sdk/overview.md) handles this entire flow for you. The raw HTTP flow below is useful for understanding the protocol or building a non-JavaScript client. +::: + ### API clients: confidential vs public API clients have a `client_type` field — either `confidential` (default) or `public`. @@ -245,6 +280,7 @@ This deletes the stored session and the associated DPoP key. ## Next steps +- [JavaScript SDK](../sdk/overview.md) — authenticate and make XRPC calls from JavaScript - [Permissions](../guides/permissions.md) — full list of permissions and what each one grants - [API Keys](../guides/api-keys.md) — create scoped admin API keys for automation - [Admin API — API Clients](../reference/admin-api.md#api-clients) — register API clients and configure rate limits diff --git a/packages/docs/docs/sdk/lex-agent.md b/packages/docs/docs/sdk/lex-agent.md new file mode 100644 index 0000000..4bf7244 --- /dev/null +++ b/packages/docs/docs/sdk/lex-agent.md @@ -0,0 +1,60 @@ +# Lex Agent + +The Lex agent adapter is the recommended way to interact with HappyView from JavaScript. It creates an [`@atproto/lex`](https://www.npmjs.com/package/@atproto/lex) `Agent` from a `HappyViewSession`, so you can use `@atproto/lex`'s type-safe `Client` to make XRPC calls with HappyView's DPoP authentication. All requests are routed to your HappyView instance, which handles its own lexicons locally and proxies standard AT Protocol methods (e.g., `com.atproto.repo.createRecord`) to the user's PDS. + +The adapter gives you lexicon-level type checking on parameters, input bodies, and responses, and works with any library or tool that accepts an `@atproto/lex` `Agent`. + +## Installation + +```bash +npm install @happyview/lex-agent @atproto/lex +``` + +`@atproto/lex` is a peer dependency (`>=0.0.20`). + +## Usage + +```typescript +import { Client } from "@atproto/lex"; +import { HappyViewBrowserClient } from "@happyview/oauth-client-browser"; +import { createAgent } from "@happyview/lex-agent"; + +const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientKey: "hvc_your_client_key", +}); + +// Authenticate (or restore a session) +const session = await client.restore(); + +// Create a Lex agent from the session +const agent = createAgent(session); +const lex = new Client(agent); +``` + +## Type-safe XRPC calls + +With a `Client` instance, you can make type-safe XRPC calls using lexicon definitions: + +```typescript +// Query +const result = await lex.xrpc(myLexicons.com.example.getGame, { + params: { slug: "celeste" }, +}); + +// Procedure +await lex.xrpc(myLexicons.com.example.createPost, { + input: { text: "Hello from HappyView!" }, +}); +``` + +The `Client` validates parameters and return types against the lexicon schema at the type level, so your IDE catches mismatches before runtime. + +## API + +### `createAgent(session: HappyViewSession): Agent` + +Creates an `@atproto/lex` `Agent` from a `HappyViewSession`. + +- `agent.did` — the session user's DID +- `agent.fetchHandler(path, init)` — delegates to `session.fetchHandler`, which attaches DPoP authentication headers and prepends the HappyView instance URL to relative paths diff --git a/packages/docs/docs/sdk/oauth-client-browser.md b/packages/docs/docs/sdk/oauth-client-browser.md new file mode 100644 index 0000000..a6a9dd8 --- /dev/null +++ b/packages/docs/docs/sdk/oauth-client-browser.md @@ -0,0 +1,144 @@ +# Browser Client + +The browser client handles the full OAuth redirect flow for browser apps authenticating with a HappyView instance. It wraps the [OAuth Client](./oauth-client.md) with Web Crypto, localStorage, and AT Protocol handle/DID resolution. + +If you're starting a new app, consider using [`@happyview/lex-agent`](./lex-agent.md) with `@atproto/lex` instead — it provides type-safe XRPC calls and is the recommended way to interact with HappyView. This package is primarily useful if your app already uses `@atproto/oauth-client-browser` and you want to add HappyView authentication alongside it. + +## Installation + +```bash +npm install @happyview/oauth-client-browser +``` + +## Setup + +```typescript +import { HappyViewBrowserClient } from "@happyview/oauth-client-browser"; + +const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientKey: "hvc_your_client_key", +}); +``` + +The client uses Web Crypto and localStorage by default. You can override either: + +```typescript +const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientKey: "hvc_your_client_key", + crypto: myCustomCryptoAdapter, + storage: myCustomStorageAdapter, +}); +``` + +:::note +The API client must be registered as a **public** client (no secret) with your app's origin in `allowed_origins`. See [Authentication — API clients](../getting-started/authentication.md#api-clients-confidential-vs-public). +::: + +## Login + +`login()` resolves the user's handle, discovers their PDS, provisions a DPoP key, and redirects the browser to the PDS authorization server: + +```typescript +await client.login("alice.bsky.social"); +// Browser redirects — code stops here +``` + +If you need the authorization URL without redirecting (e.g., for a popup or custom UI), use `prepareLogin()`: + +```typescript +const { authorizationUrl, did, state } = + await client.prepareLogin("alice.bsky.social"); + +// Open in a popup, new tab, etc. +window.open(authorizationUrl); +``` + +### What happens during login + +1. The handle is resolved to a DID via `resolveHandleToDid`. +2. The DID document is fetched to find the PDS URL. +3. The PDS's OAuth authorization server metadata is fetched. +4. A DPoP key is provisioned from HappyView. +5. PKCE challenge/verifier pairs are generated (one for HappyView's DPoP provisioning, one for the PDS authorization server). +6. The pending auth state is stored in localStorage. +7. The browser is redirected to the PDS authorization endpoint. + +## OAuth callback + +Your app needs an `/oauth/callback` route. On that page, call `callback()` to complete the token exchange: + +```typescript +// On /oauth/callback +const session = await client.callback(); +// Session is now stored in localStorage and ready to use +``` + +`callback()` reads the `code` and `state` from the URL query string, exchanges the code for tokens at the PDS token endpoint, and registers the session with HappyView. The pending auth state is cleaned up automatically. + +## Restore session + +On subsequent page loads, restore the session from localStorage instead of re-authenticating: + +```typescript +const session = await client.restore(); +if (session) { + // User is still logged in +} +``` + +Returns `null` if no stored session is found. + +## Authenticated requests + +The session's `fetchHandler` attaches DPoP proof headers automatically: + +```typescript +const response = await session.fetchHandler( + "/xrpc/com.example.getStuff?limit=10", + { method: "GET" }, +); + +const data = await response.json(); +``` + +Pass a relative path (prepends the HappyView instance URL) or a full URL (used as-is). + +## Logout + +```typescript +await client.logout(session.did); +``` + +## Resolution utilities + +The browser client exports the resolution functions it uses internally. These are useful if you need to resolve handles or discover PDS URLs outside of the login flow: + +```typescript +import { + resolveHandleToDid, + resolveDidDocument, + resolvePdsUrl, + resolveAuthServerMetadata, +} from "@happyview/oauth-client-browser"; + +const did = await resolveHandleToDid("alice.bsky.social"); +const doc = await resolveDidDocument(did); +const pdsUrl = resolvePdsUrl(doc); +const authMeta = await resolveAuthServerMetadata(pdsUrl); +``` + +## Re-exports + +This package re-exports everything from `@happyview/oauth-client`, so you don't need to install the core package separately. All types, error classes, and utilities are available: + +```typescript +import { + HappyViewBrowserClient, + HappyViewSession, + ApiError, + type CryptoAdapter, + type StorageAdapter, +} from "@happyview/oauth-client-browser"; +``` diff --git a/packages/docs/docs/sdk/oauth-client.md b/packages/docs/docs/sdk/oauth-client.md new file mode 100644 index 0000000..5f65a62 --- /dev/null +++ b/packages/docs/docs/sdk/oauth-client.md @@ -0,0 +1,158 @@ +# OAuth Client + +The core OAuth client handles DPoP key provisioning, session registration, and session restoration against a HappyView instance. It's platform-agnostic — you provide a `CryptoAdapter` and optional `StorageAdapter` for your environment. + +If you're building a browser app, use the [Browser Client](./oauth-client-browser.md) instead. It wraps this package with Web Crypto, localStorage, and a complete OAuth redirect flow. + +## Installation + +```bash +npm install @happyview/oauth-client +``` + +## Setup + +```typescript +import { HappyViewOAuthClient } from "@happyview/oauth-client"; + +const client = new HappyViewOAuthClient({ + instanceUrl: "https://happyview.example.com", + clientKey: "hvc_your_client_key", + clientSecret: "hvs_your_secret", // optional, for confidential clients + crypto: myCryptoAdapter, + storage: myStorageAdapter, // optional, defaults to in-memory +}); +``` + +The `clientSecret` parameter makes this a **confidential client**. Omit it for public clients (browser apps), which use PKCE instead. See [Authentication — API clients](../getting-started/authentication.md#api-clients-confidential-vs-public) for details. + +## DPoP key provisioning + +Request a DPoP keypair from the HappyView instance. This is the first step of the [DPoP key provisioning flow](../getting-started/authentication.md#dpop-key-provisioning-for-third-party-apps). + +```typescript +const { provisionId, dpopKey, pkceVerifier } = + await client.provisionDpopKey(); +``` + +For public clients, `pkceVerifier` is included and must be passed back when registering the session. For confidential clients it will be `undefined`. + +Use the returned `dpopKey` (a private JWK) as your DPoP keypair during your AT Protocol OAuth flow with the user's PDS. + +## Session registration + +After completing OAuth authorization with the user's PDS, register the session with HappyView: + +```typescript +const session = await client.registerSession({ + provisionId, + pkceVerifier, // required for public clients + did: "did:plc:abc123", + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + scopes: "atproto", + pdsUrl: "https://bsky.social", + issuer: tokens.iss, + dpopKey, +}); +``` + +The returned `HappyViewSession` is ready to make authenticated requests. The session data is also persisted to the `StorageAdapter` for later restoration. + +## Making authenticated requests + +`HappyViewSession.fetchHandler` works like `fetch` but automatically attaches DPoP proof, authorization, and client key headers: + +```typescript +// Relative path — prepends the HappyView instance URL +const response = await session.fetchHandler( + "/xrpc/com.example.getStuff?limit=10", + { method: "GET" }, +); + +// Absolute URL — used as-is +const response = await session.fetchHandler( + "https://other-service.example.com/xrpc/test.method", + { method: "GET" }, +); +``` + +## Session restoration + +Restore a previously stored session without re-authenticating: + +```typescript +// Restore the last active session +const session = await client.restore(); + +// Restore a specific user's session +const session = await client.restoreSession("did:plc:abc123"); +``` + +Returns `null` if no stored session is found. + +## Logout + +```typescript +await client.deleteSession("did:plc:abc123"); +``` + +This deletes the session from both HappyView and local storage. + +## Adapters + +### CryptoAdapter + +Implement this interface for your platform's cryptographic primitives: + +```typescript +interface CryptoAdapter { + generatePkceVerifier(): Promise; + computePkceChallenge(verifier: string): Promise; + signEs256(privateKey: JsonWebKey, payload: Uint8Array): Promise; + sha256(data: Uint8Array): Promise; + getRandomValues(length: number): Uint8Array; +} +``` + +### StorageAdapter + +Implement this interface to persist sessions: + +```typescript +interface StorageAdapter { + get(key: string): Promise; + set(key: string, value: string): Promise; + delete(key: string): Promise; +} +``` + +If no `StorageAdapter` is provided, sessions are stored in memory and won't survive page reloads or process restarts. + +:::note +The built-in `MemoryStorage` is exported for testing. In production, always provide a persistent storage adapter. +::: + +## Error handling + +All errors extend `HappyViewError`: + +| Error | When | +| --- | --- | +| `ApiError` | HappyView API returned a non-OK response (has `status` and `body`) | +| `AuthenticationError` | Authentication failed (default status 401) | +| `InvalidStateError` | Missing or invalid OAuth state | +| `TokenExchangeError` | Token exchange with the PDS failed (has `status` and `body`) | +| `ResolutionError` | Handle or DID resolution failed | + +```typescript +import { ApiError } from "@happyview/oauth-client"; + +try { + await client.registerSession(params); +} catch (err) { + if (err instanceof ApiError) { + console.error(`API error ${err.status}:`, err.body); + } +} +``` diff --git a/packages/docs/docs/sdk/overview.md b/packages/docs/docs/sdk/overview.md new file mode 100644 index 0000000..4df6f95 --- /dev/null +++ b/packages/docs/docs/sdk/overview.md @@ -0,0 +1,66 @@ +# JavaScript SDK + +HappyView provides JavaScript packages for building third-party apps that authenticate with a HappyView instance and make XRPC requests on behalf of users. + +| Package | Purpose | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| [`@happyview/lex-agent`](https://www.npmjs.com/package/@happyview/lex-agent) | Recommended — type-safe XRPC via [`@atproto/lex`](https://www.npmjs.com/package/@atproto/lex) `Client` with HappyView DPoP auth | +| [`@happyview/oauth-client`](https://www.npmjs.com/package/@happyview/oauth-client) | Platform-agnostic core — DPoP key provisioning, session management, authenticated fetch | +| [`@happyview/oauth-client-browser`](https://www.npmjs.com/package/@happyview/oauth-client-browser) | Browser OAuth wrapper for apps already using `@atproto/oauth-client-browser` | + +## Which package do I need? + +**Starting a new app?** Use `@happyview/lex-agent` with `@atproto/lex`. It gives you type-safe XRPC calls through a `Client` that routes requests to your HappyView instance with DPoP authentication. This is the recommended way to interact with HappyView from JavaScript. + +**Already using `@atproto/oauth-client-browser`?** Add `@happyview/oauth-client-browser` to get a `HappyViewBrowserClient` that handles the HappyView-specific DPoP key provisioning and session registration on top of the standard AT Protocol OAuth flow. + +**Building a server-side app or something more custom?** Use `@happyview/oauth-client` directly and provide your own `CryptoAdapter` and `StorageAdapter`. + +## How it works + +Third-party apps authenticate using HappyView's [DPoP key provisioning](../getting-started/authentication.md#dpop-key-provisioning-for-third-party-apps) flow: + +1. The SDK requests a DPoP keypair from the HappyView instance. +2. Your app runs a standard AT Protocol OAuth flow with the user's PDS using that keypair. +3. The SDK registers the resulting tokens with HappyView. +4. All subsequent XRPC requests are authenticated with DPoP proofs — HappyView handles its own lexicons locally and proxies standard AT Protocol writes to the user's PDS. + +## Quick start + +```bash +npm install @happyview/lex-agent @happyview/oauth-client-browser @atproto/lex +``` + +```typescript +import { Client } from "@atproto/lex"; +import { HappyViewBrowserClient } from "@happyview/oauth-client-browser"; +import { createAgent } from "@happyview/lex-agent"; + +// Set up the OAuth client +const oauthClient = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientKey: "hvc_your_client_key", +}); + +// Login — redirects to the user's PDS +await oauthClient.login("alice.bsky.social"); + +// On /oauth/callback — complete the flow +const session = await oauthClient.callback(); + +// Create a type-safe Lex client +const agent = createAgent(session); +const lex = new Client(agent); + +// Make type-safe XRPC calls +const result = await lex.xrpc(myLexicons.com.example.getGame, { + params: { slug: "celeste" }, +}); +``` + +## Next steps + +- [Lex Agent](./lex-agent.md): type-safe XRPC with `@atproto/lex` +- [OAuth Client](./oauth-client.md): platform-agnostic core client +- [Browser Client](./oauth-client-browser.md): browser OAuth redirect flow +- [Authentication](../getting-started/authentication.md): full details on DPoP key provisioning and API client types diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index 02ee80d..cf59a17 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -232,6 +232,32 @@ const sidebars: SidebarsConfig = { }, ], }, + { + type: "category", + label: "JavaScript SDK", + items: [ + { + type: "doc", + id: "sdk/overview", + label: "Overview", + }, + { + type: "doc", + id: "sdk/lex-agent", + label: "Lex Agent", + }, + { + type: "doc", + id: "sdk/oauth-client", + label: "OAuth Client", + }, + { + type: "doc", + id: "sdk/oauth-client-browser", + label: "Browser Client", + }, + ], + }, { type: "category", label: "Reference",