diff --git a/packages/docs/docs/sdk/oauth-client-node.md b/packages/docs/docs/sdk/oauth-client-node.md index 4c6c84d..25d9100 100644 --- a/packages/docs/docs/sdk/oauth-client-node.md +++ b/packages/docs/docs/sdk/oauth-client-node.md @@ -1,6 +1,6 @@ # Node Client -The Node client handles OAuth authorization and callback flows for server-side Node.js apps authenticating with a HappyView instance. It wraps the [OAuth Client](./oauth-client.md) with handle/DID resolution and a server-friendly API that returns authorization URLs instead of redirecting the browser. +Server-side OAuth client for authenticating with a HappyView instance using AT Protocol. Built on top of [`@happyview/oauth-client`](./oauth-client.md), matching the API surface of [`@atproto/oauth-client-node`](https://www.npmjs.com/package/@atproto/oauth-client-node). ## Installation @@ -17,107 +17,133 @@ const client = new HappyViewNodeClient({ instanceUrl: "https://happyview.example.com", clientId: "https://example.com/oauth-client-metadata.json", clientKey: "hvc_your_client_key", - clientSecret: "hvs_your_secret", // optional, for confidential clients redirectUri: "https://example.com/oauth/callback", storage: myStorageAdapter, }); ``` -| Option | Required | Description | -| ------------- | -------- | ---------------------------------------------------------------------------- | -| `instanceUrl` | Yes | The HappyView instance URL | -| `clientId` | Yes | URL where your app serves its [OAuth client metadata](#oauth-client-metadata) | -| `clientKey` | Yes | API client key from the HappyView admin dashboard | -| `clientSecret`| No | API client secret — makes this a confidential client | -| `redirectUri` | Yes | OAuth callback URL for your server | -| `scopes` | No | OAuth scopes to request. Defaults to `"atproto"` | -| `storage` | Yes | Storage adapter for persisting sessions and pending auth state | -| `sessionHooks`| No | Hooks called on session lifecycle events | -| `fetch` | No | Custom fetch implementation | +| Option | Required | Description | +| -------------- | -------- | ----------------------------------------------------------------------------- | +| `instanceUrl` | Yes | The HappyView instance URL | +| `clientId` | Yes | URL where your app serves its [OAuth client metadata](#oauth-client-metadata) | +| `clientKey` | Yes | API client key from the HappyView admin dashboard | +| `redirectUri` | Yes | OAuth callback URL | +| `storage` | Yes | Storage adapter for persisting sessions and auth state | +| `clientSecret` | No | Secret for confidential clients | +| `scopes` | No | OAuth scopes to request. Defaults to `"atproto"` | +| `sessionHooks` | No | Event hooks for session lifecycle events | +| `fetch` | No | Custom fetch implementation | -:::note -Unlike the browser client, `storage` is required — there is no default. Use any adapter that implements `StorageAdapter` (e.g., backed by Redis, a database, or the filesystem). -::: +### Storage -## Authorization +You must provide a `StorageAdapter`. The built-in `MemoryStorage` works for development but won't survive restarts: -`authorize()` resolves the user's handle, discovers their PDS, provisions a DPoP key, and returns an authorization URL. Your server should redirect the user to this URL: +```typescript +import { MemoryStorage } from "@happyview/oauth-client-node"; + +const client = new HappyViewNodeClient({ + // ... + storage: new MemoryStorage(), +}); +``` + +For production, implement the `StorageAdapter` interface backed by your database or cache: ```typescript -// In your login route handler -const url = await client.authorize("alice.bsky.social"); -res.redirect(url.toString()); +interface StorageAdapter { + get(key: string): Promise; + set(key: string, value: string): Promise; + delete(key: string): Promise; +} ``` -### Authorization options +## Authorize + +Generate an authorization URL and redirect the user: + +```typescript +const url = await client.authorize("alice.bsky.social"); +res.redirect(url.href); +``` -Pass options to customize the authorization request: +Options: ```typescript const url = await client.authorize("alice.bsky.social", { scope: "atproto transition:generic", - state: myCustomState, redirect_uri: "https://example.com/alt-callback", - display: "popup", - prompt: "consent", - ui_locales: "en", + state: "my-custom-state", + prompt: "login", + display: "page", }); ``` -| Option | Description | -| ------------- | ------------------------------------------------------------ | -| `scope` | Override the default scopes for this request | -| `state` | Custom state value (defaults to a random hex string) | -| `redirect_uri`| Override the default redirect URI for this request | -| `display` | `"page"`, `"popup"`, `"touch"`, or `"wap"` | -| `prompt` | Prompt behavior (e.g., `"consent"`, `"login"`) | -| `nonce` | Nonce for ID token validation | -| `max_age` | Maximum authentication age in seconds | -| `ui_locales` | Preferred UI languages | -| `signal` | AbortSignal to cancel the request | +| Option | Description | +| ------------------------ | ---------------------------------------------------------------------------- | +| `scope` | OAuth scopes for this request (overrides constructor default) | +| `scopes` | Deprecated alias for `scope`. `scope` takes priority if both passed. | +| `state` | Custom state value. Defaults to a random hex string. | +| `redirect_uri` | Override the redirect URI for this request | +| `signal` | `AbortSignal` for cancellation | +| `display` | Display hint: `"page"`, `"popup"`, `"touch"`, or `"wap"` | +| `prompt` | Prompt mode (e.g. `"login"` to force re-authentication) | +| `nonce` | OIDC nonce value | +| `max_age` | Max elapsed seconds since last active authentication | +| `ui_locales` | Space-separated locale tags (e.g. `"en fr"`) | +| `dpop_jkt` | DPoP JWK thumbprint | +| `claims` | OIDC claims request object | +| `authorization_details` | RFC 9396 authorization details | +| `id_token_hint` | Previous ID token hint | + +### Abort a pending request + +If you need to cancel a pending authorization (e.g., the user navigates away), pass the URL returned from `authorize()`: + +```typescript +const url = await client.authorize("alice.bsky.social"); +// ...later, if the user cancels: +await client.abortRequest(url); +``` + +This cleans up the stored pending auth state. ## Callback -In your OAuth callback route, pass the query parameters to `callback()`: +On your callback route, process the OAuth response: ```typescript -// In your callback route handler -const params = new URLSearchParams(req.url.split("?")[1]); -const { session, state } = await client.callback(params); +app.get("/oauth/callback", async (req, res) => { + const params = new URLSearchParams(req.url.split("?")[1]); + const { session, state } = await client.callback(params); -// session is ready to use -console.log(session.did); -console.log(session.scopes); + // session.did is the authenticated user's DID + // state is the value passed to authorize() (or the auto-generated one) +}); ``` -The returned `HappyViewSession` is persisted to storage and ready for authenticated requests. - -You can override the redirect URI if needed: +You can override the redirect URI for this specific callback: ```typescript const { session } = await client.callback(params, { - redirect_uri: "https://example.com/alt-callback", + redirect_uri: "https://other.example.com/callback", }); ``` -## Checking approved scopes +## Restore session -After authorization or session restoration, you can check which scopes were approved: +Restore a session by DID: ```typescript -console.log(session.scopes); -// ["atproto", "transition:generic"] +const session = await client.restore("did:plc:abc123"); ``` -To fetch the latest scopes from the server: +The `did` parameter is required in the node client (unlike the browser client, there's no "last active" session concept on the server). -```typescript -const info = await client.getSession("did:plc:abc123"); -console.log(info.scopes); -// ["atproto", "transition:generic"] -``` +A second `refresh` parameter is accepted for API compatibility with upstream (`restore(did, refresh?)`). HappyView manages token refresh server-side, so this parameter is accepted but ignored. + +## Session -## Authenticated requests +### Authenticated requests The session's `fetchHandler` attaches DPoP proof headers automatically: @@ -132,43 +158,149 @@ const data = await response.json(); Pass a relative path (prepends the HappyView instance URL) or a full URL (used as-is). -## Session restoration +### Token info + +```typescript +const info = session.getTokenInfo(); +// { sub, scope, iss, aud, expiresAt?, expired? } +``` + +Returns available metadata about the session. `expiresAt` and `expired` are always `undefined` since HappyView manages token lifecycle server-side. + +### Properties -Restore a previously stored session by DID: +| Property | Type | Description | +| -------- | -------- | ---------------------------------------- | +| `did` | `string` | The authenticated user's DID | +| `sub` | `string` | Alias for `did` (matches upstream naming) | + +### Sign out + +Sessions can self-revoke: ```typescript -const session = await client.restore("did:plc:abc123"); +await session.signOut(); +``` + +This is equivalent to calling `client.revoke(session.did)`. + +## Confidential vs public clients + +Clients created with a `clientSecret` are confidential — they can hold secrets safely on the server. Clients without a secret are public. Use `client.isConfidential` to check: + +```typescript +const client = new HappyViewNodeClient({ + // ... + clientSecret: "hvs_your_secret", +}); +client.isConfidential; // true ``` -Unlike the browser client, `restore()` requires a DID — there is no "last active" session concept on the server. +Public clients use PKCE to secure the DPoP key provisioning step. Confidential clients authenticate with their secret instead. -Throws `InvalidStateError` if no session is found for the given DID. +## Session event hooks + +React to session lifecycle events with `sessionHooks`: + +```typescript +const client = new HappyViewNodeClient({ + // ... + sessionHooks: { + onSessionUpdate(did) { + console.log(`Session created/updated for ${did}`); + }, + onSessionDelete(did) { + console.log(`Session deleted for ${did}`); + }, + }, +}); +``` + +- `onSessionUpdate(did)` fires after a session is registered (from `callback()`) or restored. +- `onSessionDelete(did)` fires after a session is revoked (from `revoke()` or `session.signOut()`). + +## Error handling + +Callback errors are always wrapped in `OAuthCallbackError`, which carries the original callback params and state: + +```typescript +import { OAuthCallbackError } from "@happyview/oauth-client-node"; + +try { + const { session } = await client.callback(params); +} catch (err) { + if (err instanceof OAuthCallbackError) { + console.log(err.state); // the state from the callback + console.log(err.params.get("error")); // e.g. "access_denied" + console.log(err.cause); // the underlying error, if any + } +} +``` + +If the authorization server returns an error (e.g., the user denied access), the `params` contain the `error` and `error_description` fields from the server response. If the token exchange fails, the underlying `TokenExchangeError` is available as `err.cause`. + +## Using with @atproto/api + +`HappyViewSession` is directly compatible with `@atproto/api`'s `Agent`: + +```typescript +import { Agent } from "@atproto/api"; + +const session = await client.restore("did:plc:abc123"); +const agent = new Agent(session); + +const profile = await agent.getProfile({ actor: agent.did }); +await agent.like(postUri, postCid); +``` + +This works because `HappyViewSession` implements the `SessionManager` interface that `Agent` expects. ## Revoke session +From the client: + ```typescript await client.revoke("did:plc:abc123"); ``` -## Aborting a pending authorization +Or from the session itself: -If the user abandons the login flow, clean up the pending state: +```typescript +await session.signOut(); +``` + +## Validate client metadata + +Verify that your OAuth client metadata is served correctly: ```typescript -await client.abortRequest(authorizationUrl); +const metadata = await HappyViewNodeClient.fetchMetadata({ + clientId: "https://example.com/oauth-client-metadata.json", +}); +console.log(metadata.client_name); +``` + +## Identity resolution + +The client exposes its handle and DID resolvers for advanced use: + +```typescript +const did = await client.handleResolver.resolve("alice.bsky.social"); +const doc = await client.didResolver.resolve(did); ``` ## OAuth client metadata -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. +Your app must serve an OAuth client metadata JSON document at the URL you pass as `clientId`. The PDS fetches this during authorization. + +For a confidential Node.js server: ```typescript -// Express example app.get("/oauth-client-metadata.json", (req, res) => { const origin = `${req.protocol}://${req.get("host")}`; res.json({ client_id: `${origin}/oauth-client-metadata.json`, - client_name: "My App", + client_name: "My Server App", client_uri: origin, redirect_uris: [`${origin}/oauth/callback`], token_endpoint_auth_method: "none", @@ -180,47 +312,41 @@ app.get("/oauth-client-metadata.json", (req, res) => { }); ``` -The `redirect_uris` array must include the `redirectUri` your client is configured with. - ## Re-exports -This package re-exports everything from `@happyview/oauth-client`, so you don't need to install the core package separately: +This package re-exports everything from `@happyview/oauth-client`, `@atproto-labs/handle-resolver`, and `@atproto-labs/did-resolver`. You don't need to install these packages separately: ```typescript import { + // From @happyview/oauth-client HappyViewNodeClient, HappyViewSession, + MemoryStorage, ApiError, + OAuthCallbackError, + Key, + type SessionEventHooks, type StorageAdapter, -} from "@happyview/oauth-client-node"; -``` + type TokenInfo, + type Jwk, -It also re-exports handle and DID resolution utilities from `@atproto-labs/handle-resolver` and `@atproto-labs/did-resolver`. + // From @atproto-labs/handle-resolver + AtprotoDohHandleResolver, -## Error handling - -All errors extend `HappyViewError`. The Node client additionally uses `OAuthCallbackError` for callback failures: + // From @atproto-labs/did-resolver + DidResolverCommon, + type DidDocument, +} from "@happyview/oauth-client-node"; +``` -| Error | When | -| --- | --- | -| `ApiError` | HappyView API returned a non-OK response (has `status` and `body`) | -| `OAuthCallbackError` | OAuth callback failed — wraps the callback params and underlying error | -| `InvalidStateError` | Missing or invalid OAuth/session state | -| `TokenExchangeError` | Token exchange with the PDS failed (has `status` and `body`) | -| `ResolutionError` | Handle or DID resolution failed | +## Differences from upstream -```typescript -import { - OAuthCallbackError, - InvalidStateError, -} from "@happyview/oauth-client-node"; +The HappyView SDK matches the upstream `@atproto/oauth-client-node` public API but differs architecturally: -try { - const { session } = await client.callback(params); -} catch (err) { - if (err instanceof OAuthCallbackError) { - console.error("OAuth callback failed:", err.message); - console.error("State:", err.state); - } -} -``` +| Area | Upstream | HappyView | +|------|----------|-----------| +| DPoP keys | Generated client-side | Provisioned from HappyView instance | +| Token refresh | Client-side with `refresh` param | Server-side (HappyView manages lifecycle) | +| `restore(did, refresh?)` | `refresh` controls token refresh behavior | `refresh` accepted but ignored | +| `session.getTokenInfo()` | Includes `expiresAt`/`expired` | These fields are `undefined` | +| `jwks` | Returns client's public keyset | Not applicable (no client keypairs) | diff --git a/packages/oauth-client-node/README.md b/packages/oauth-client-node/README.md new file mode 100644 index 0000000..10d026d --- /dev/null +++ b/packages/oauth-client-node/README.md @@ -0,0 +1,152 @@ +# @happyview/oauth-client-node + +Node.js OAuth client for authenticating with a [HappyView](https://github.com/gamesgamesgamesgamesgames/happyview) instance using AT Protocol. + +Built on top of [`@happyview/oauth-client`](https://www.npmjs.com/package/@happyview/oauth-client). Matches the API surface of [`@atproto/oauth-client-node`](https://www.npmjs.com/package/@atproto/oauth-client-node). + +## Installation + +```bash +npm install @happyview/oauth-client-node +``` + +## Usage + +### Setup + +```typescript +import { HappyViewNodeClient } from "@happyview/oauth-client-node"; + +const client = new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_your_client_key", + clientSecret: "hvs_your_secret", // optional, for confidential clients + redirectUri: "https://example.com/oauth/callback", + storage: myStorageAdapter, +}); +``` + +### Authorize + +Generate an authorization URL and redirect the user: + +```typescript +const url = await client.authorize("alice.bsky.social"); +// Redirect the user to url.href +``` + +With options: + +```typescript +const url = await client.authorize("alice.bsky.social", { + scope: "atproto transition:generic", + redirect_uri: "https://example.com/alt-callback", + prompt: "login", + display: "page", +}); +``` + +### Callback + +On your callback route, process the OAuth response: + +```typescript +const params = new URLSearchParams(req.url.split("?")[1]); +const { session, state } = await client.callback(params); +``` + +### Restore Session + +Restore a session by DID: + +```typescript +const session = await client.restore("did:plc:abc123"); +``` + +### Session + +```typescript +// Authenticated requests +const response = await session.fetchHandler( + "/xrpc/com.example.getStuff?limit=10", + { method: "GET" }, +); + +// Token metadata +const info = session.getTokenInfo(); +// { sub, scope, iss, aud } + +// Self-revoke +await session.signOut(); +``` + +### Using with @atproto/api + +```typescript +import { Agent } from "@atproto/api"; + +const agent = new Agent(session); +const profile = await agent.getProfile({ actor: agent.did }); +``` + +### Revoke Session + +```typescript +await client.revoke("did:plc:abc123"); +// or +await session.signOut(); +``` + +### Abort Request + +```typescript +const url = await client.authorize("alice.bsky.social"); +// ...later: +await client.abortRequest(url); +``` + +### Validate Client Metadata + +```typescript +const metadata = await HappyViewNodeClient.fetchMetadata({ + clientId: "https://example.com/oauth-client-metadata.json", +}); +``` + +### Identity Resolution + +```typescript +const did = await client.handleResolver.resolve("alice.bsky.social"); +const doc = await client.didResolver.resolve(did); +``` + +## Storage + +You must provide a `StorageAdapter`. The built-in `MemoryStorage` works for testing but won't survive restarts: + +```typescript +import { MemoryStorage } from "@happyview/oauth-client-node"; + +const client = new HappyViewNodeClient({ + // ... + storage: new MemoryStorage(), +}); +``` + +For production, implement the `StorageAdapter` interface backed by your database or cache: + +```typescript +interface StorageAdapter { + get(key: string): Promise; + set(key: string, value: string): Promise; + delete(key: string): Promise; +} +``` + +## Exports + +This package re-exports everything from `@happyview/oauth-client`, plus: + +- `HappyViewNodeClient` -- the main Node.js client +- `AuthorizeOptions`, `CallbackOptions`, `HappyViewNodeClientOptions` types diff --git a/packages/oauth-client-node/package.json b/packages/oauth-client-node/package.json new file mode 100644 index 0000000..a0e4e6d --- /dev/null +++ b/packages/oauth-client-node/package.json @@ -0,0 +1,52 @@ +{ + "name": "@happyview/oauth-client-node", + "version": "0.0.0-development", + "description": "HappyView OAuth client for Node.js ATProto DPoP authentication", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/gamesgamesgamesgamesgames/happyview.git", + "directory": "packages/oauth-client-node" + }, + "bugs": "https://github.com/gamesgamesgamesgamesgames/happyview/issues", + "homepage": "https://happyview.dev", + "publishConfig": { + "access": "public" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@happyview/oauth-client": "workspace:*", + "@atproto-labs/handle-resolver": "^0.3.6", + "@atproto-labs/did-resolver": "^0.2.6", + "@atproto/did": "^0.3.0" + }, + "devDependencies": { + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/github": "^12.0.6", + "@semantic-release/exec": "^7.0.0", + "@semantic-release/release-notes-generator": "^14.1.0", + "@types/bun": "^1.3.12", + "semantic-release": "^25.0.3", + "semantic-release-monorepo": "^8.0.2", + "tsup": "^8.0.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/oauth-client-node/src/__tests__/node-client.test.ts b/packages/oauth-client-node/src/__tests__/node-client.test.ts new file mode 100644 index 0000000..4d9fda0 --- /dev/null +++ b/packages/oauth-client-node/src/__tests__/node-client.test.ts @@ -0,0 +1,1227 @@ +import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"; +import { + HappyViewOAuthClient, + HappyViewError, + InvalidStateError, + MemoryStorage, + OAuthCallbackError, + TokenExchangeError, +} from "@happyview/oauth-client"; +import { HappyViewNodeClient } from "../node-client"; + +let testJwk: JsonWebKey; +beforeAll(async () => { + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + testJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); + delete testJwk.key_ops; +}); + +let storage: MemoryStorage; + +afterEach(() => { + storage = new MemoryStorage(); +}); + +function createClient(fetchFn?: typeof globalThis.fetch) { + storage = new MemoryStorage(); + return new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + redirectUri: "https://example.com/oauth/callback", + storage, + fetch: fetchFn, + }); +} + +function mockFetchForFullFlow() { + return mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + + if (url.includes("dns.google")) { + return new Response( + JSON.stringify({ + Status: 0, + Answer: [ + { + name: "_atproto.user.bsky.social.", + type: 16, + TTL: 300, + data: '"did=did:plc:abcdefghijklmnopqrstuvwx"', + }, + ], + }), + { status: 200, headers: { "content-type": "application/dns-json" } }, + ); + } + + if (url.includes("plc.directory")) { + return new Response( + JSON.stringify({ + id: "did:plc:abcdefghijklmnopqrstuvwx", + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: "https://pds.example.com", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + if (url.includes(".well-known/oauth-protected-resource")) { + return new Response( + JSON.stringify({ + authorization_servers: ["https://pds.example.com"], + }), + { status: 200 }, + ); + } + + if (url.includes(".well-known/oauth-authorization-server")) { + return new Response( + JSON.stringify({ + issuer: "https://pds.example.com", + authorization_endpoint: "https://pds.example.com/oauth/authorize", + token_endpoint: "https://pds.example.com/oauth/token", + pushed_authorization_request_endpoint: + "https://pds.example.com/oauth/par", + }), + { status: 200 }, + ); + } + + if (url.includes("/oauth/dpop-keys")) { + return new Response( + JSON.stringify({ + provision_id: "hvp_test123", + dpop_key: testJwk, + }), + { status: 201 }, + ); + } + + if (url.includes("/oauth/par")) { + return new Response( + JSON.stringify({ + request_uri: "urn:ietf:params:oauth:request_uri:test", + expires_in: 60, + }), + { status: 201 }, + ); + } + + if (url.includes("/oauth/sessions") && init?.method === "POST") { + return new Response( + JSON.stringify({ + session_id: "sess_test", + did: "did:plc:abcdefghijklmnopqrstuvwx", + }), + { status: 201 }, + ); + } + + if (url.includes("/oauth/token")) { + return new Response( + JSON.stringify({ + access_token: "at_test_token", + refresh_token: "rt_test_token", + token_type: "DPoP", + scope: "atproto", + sub: "did:plc:abcdefghijklmnopqrstuvwx", + iss: "https://pds.example.com", + }), + { status: 200 }, + ); + } + + return new Response("not found", { status: 404 }); + }); +} + +describe("HappyViewNodeClient", () => { + test("constructor requires storage", () => { + const client = new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + redirectUri: "https://example.com/oauth/callback", + storage: new MemoryStorage(), + }); + expect(client).toBeDefined(); + }); + + test("authorize returns a URL object", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + const url = await client.authorize("user.bsky.social"); + + expect(url).toBeInstanceOf(URL); + expect(url.hostname).toBe("pds.example.com"); + }); + + test("authorize stores pending auth state", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + const url = await client.authorize("user.bsky.social"); + + const params = new URLSearchParams(url.search); + const requestUri = params.get("request_uri"); + expect(requestUri).toBe("urn:ietf:params:oauth:request_uri:test"); + }); + + test("authorize uses custom scopes", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + scopes: "atproto transition:generic", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + expect(parCall).toBeDefined(); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("scope")).toBe("atproto transition:generic"); + }); + + test("authorize uses custom state", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + state: "my-custom-state", + }); + + const stored = await storage.get("pending-auth:my-custom-state"); + expect(stored).not.toBeNull(); + }); + + test("callback exchanges code for tokens and returns session with state", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "pending-auth:state123", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "state123", + issuer: "https://pds.example.com", + }), + ); + + const params = new URLSearchParams({ + code: "auth-code-123", + state: "state123", + }); + + const result = await client.callback(params); + expect(result.session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(result.state).toBe("state123"); + }); + + test("callback includes DPoP proof", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "pending-auth:statedpop", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "statedpop", + issuer: "https://pds.example.com", + }), + ); + + await client.callback( + new URLSearchParams({ code: "auth-code", state: "statedpop" }), + ); + + const tokenCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/token"), + ); + expect(tokenCall).toBeDefined(); + const tokenHeaders = new Headers((tokenCall![1] as RequestInit).headers); + expect(tokenHeaders.get("dpop")).not.toBeNull(); + expect(tokenHeaders.get("dpop")!.split(".")).toHaveLength(3); + }); + + test("callback accepts redirect_uri override", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "pending-auth:stateuri", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "stateuri", + issuer: "https://pds.example.com", + }), + ); + + await client.callback( + new URLSearchParams({ code: "auth-code", state: "stateuri" }), + { redirect_uri: "https://other.example.com/callback" }, + ); + + const tokenCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/token"), + ); + const body = new URLSearchParams( + (tokenCall![1] as RequestInit).body as string, + ); + expect(body.get("redirect_uri")).toBe( + "https://other.example.com/callback", + ); + }); + + test("callback throws OAuthCallbackError when state is missing", async () => { + const client = createClient(); + try { + await client.callback(new URLSearchParams({ code: "auth-code" })); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(OAuthCallbackError); + expect((err as OAuthCallbackError).state).toBeUndefined(); + } + }); + + test("callback throws OAuthCallbackError when no pending state found", async () => { + const client = createClient(); + try { + await client.callback( + new URLSearchParams({ code: "auth-code", state: "nonexistent" }), + ); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(OAuthCallbackError); + expect((err as OAuthCallbackError).state).toBe("nonexistent"); + } + }); + + test("callback throws OAuthCallbackError wrapping TokenExchangeError on token failure", async () => { + const fetchFn = mock( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/oauth/token")) { + return new Response("invalid_grant", { status: 400 }); + } + return new Response("not found", { status: 404 }); + }, + ); + + const client = createClient(fetchFn); + + await storage.set( + "pending-auth:statefail", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "statefail", + issuer: "https://pds.example.com", + }), + ); + + try { + await client.callback( + new URLSearchParams({ code: "auth-code", state: "statefail" }), + ); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(OAuthCallbackError); + expect((err as OAuthCallbackError).state).toBe("statefail"); + expect((err as OAuthCallbackError).cause).toBeInstanceOf(TokenExchangeError); + expect(((err as OAuthCallbackError).cause as TokenExchangeError).status).toBe(400); + } + }); + + test("restore returns session for existing DID", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore( + "did:plc:abcdefghijklmnopqrstuvwx", + ); + expect(session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + }); + + test("restore throws when session does not exist", async () => { + const client = createClient(); + try { + await client.restore("did:plc:nonexistent"); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(InvalidStateError); + } + }); + + test("revoke deletes session", async () => { + const deleteFn = mock( + async (input: RequestInfo | URL, init?: RequestInit) => { + return new Response(null, { status: 204 }); + }, + ); + const client = createClient(deleteFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + await storage.set( + "happyview:last-active-did", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + + await client.revoke("did:plc:abcdefghijklmnopqrstuvwx"); + + const session = await storage.get( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + ); + expect(session).toBeNull(); + }); + + test("authorize uses scope (singular) option", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + scope: "atproto transition:generic", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + expect(parCall).toBeDefined(); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("scope")).toBe("atproto transition:generic"); + }); + + test("authorize prefers scope over scopes when both provided", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + scope: "atproto transition:generic", + scopes: "atproto", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("scope")).toBe("atproto transition:generic"); + }); + + test("restore throws when called with no DID", async () => { + const client = createClient(); + try { + await client.restore(); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(InvalidStateError); + } + }); + + test("restore accepts and ignores refresh parameter", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore( + "did:plc:abcdefghijklmnopqrstuvwx", + true, + ); + expect(session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + }); + + test("authorize without PAR falls back to direct URL", async () => { + const fetchFn = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + + if (url.includes("dns.google")) { + return new Response( + JSON.stringify({ + Status: 0, + Answer: [ + { + name: "_atproto.user.bsky.social.", + type: 16, + TTL: 300, + data: '"did=did:plc:abcdefghijklmnopqrstuvwx"', + }, + ], + }), + { status: 200, headers: { "content-type": "application/dns-json" } }, + ); + } + + if (url.includes("plc.directory")) { + return new Response( + JSON.stringify({ + id: "did:plc:abcdefghijklmnopqrstuvwx", + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: "https://pds.example.com", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + if (url.includes(".well-known/oauth-protected-resource")) { + return new Response( + JSON.stringify({ + authorization_servers: ["https://pds.example.com"], + }), + { status: 200 }, + ); + } + + if (url.includes(".well-known/oauth-authorization-server")) { + return new Response( + JSON.stringify({ + issuer: "https://pds.example.com", + authorization_endpoint: "https://pds.example.com/oauth/authorize", + token_endpoint: "https://pds.example.com/oauth/token", + }), + { status: 200 }, + ); + } + + if (url.includes("/oauth/dpop-keys")) { + return new Response( + JSON.stringify({ + provision_id: "hvp_test123", + dpop_key: testJwk, + }), + { status: 201 }, + ); + } + + return new Response("not found", { status: 404 }); + }); + + const client = createClient(fetchFn); + const url = await client.authorize("user.bsky.social"); + + expect(url).toBeInstanceOf(URL); + expect(url.hostname).toBe("pds.example.com"); + expect(url.pathname).toBe("/oauth/authorize"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("client_id")).toBe( + "https://example.com/oauth-client-metadata.json", + ); + expect(url.searchParams.get("scope")).toBe("atproto"); + }); + + test("callback retries with DPoP nonce on use_dpop_nonce error", async () => { + let tokenAttempt = 0; + const fetchFn = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + + if (url.includes("/oauth/token")) { + tokenAttempt++; + if (tokenAttempt === 1) { + return new Response( + JSON.stringify({ error: "use_dpop_nonce" }), + { + status: 400, + headers: { "dpop-nonce": "server-nonce-123" }, + }, + ); + } + return new Response( + JSON.stringify({ + access_token: "at_test_token", + refresh_token: "rt_test_token", + scope: "atproto", + sub: "did:plc:abcdefghijklmnopqrstuvwx", + iss: "https://pds.example.com", + }), + { status: 200 }, + ); + } + + if (url.includes("/oauth/sessions") && init?.method === "POST") { + return new Response( + JSON.stringify({ + session_id: "sess_test", + did: "did:plc:abcdefghijklmnopqrstuvwx", + }), + { status: 201 }, + ); + } + + return new Response("not found", { status: 404 }); + }); + + const client = createClient(fetchFn); + + await storage.set( + "pending-auth:statenonce", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "statenonce", + issuer: "https://pds.example.com", + }), + ); + + const result = await client.callback( + new URLSearchParams({ code: "auth-code", state: "statenonce" }), + ); + + expect(result.session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(tokenAttempt).toBe(2); + + const secondTokenCall = fetchFn.mock.calls.filter((call: any[]) => + String(call[0]).includes("/oauth/token"), + )[1]; + const dpopJwt = new Headers( + (secondTokenCall![1] as RequestInit).headers, + ).get("dpop")!; + const payloadB64 = dpopJwt.split(".")[1]; + const padded = + payloadB64 + "=".repeat((4 - (payloadB64.length % 4)) % 4); + const payload = JSON.parse( + atob(padded.replace(/-/g, "+").replace(/_/g, "/")), + ); + expect(payload.nonce).toBe("server-nonce-123"); + }); + + test("abortRequest cleans up pending auth state", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + const url = await client.authorize("user.bsky.social", { + state: "abort-test", + }); + + const pendingBefore = await storage.get("pending-auth:abort-test"); + expect(pendingBefore).not.toBeNull(); + + await client.abortRequest(url); + + const pendingAfter = await storage.get("pending-auth:abort-test"); + expect(pendingAfter).toBeNull(); + }); + + test("abortRequest is a no-op for unknown URLs", async () => { + const client = createClient(); + await client.abortRequest(new URL("https://unknown.example.com/auth")); + }); + + test("confidential client passes clientSecret to base class", () => { + storage = new MemoryStorage(); + const client = new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + clientSecret: "hvs_test_secret", + redirectUri: "https://example.com/oauth/callback", + storage, + }); + expect(client.isConfidential).toBe(true); + }); + + test("public client is not confidential", () => { + const client = createClient(); + expect(client.isConfidential).toBe(false); + }); + + test("full authorize → callback flow", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + const url = await client.authorize("user.bsky.social"); + expect(url).toBeInstanceOf(URL); + + // Simulate the PDS redirecting back with code and state + // Extract the state from stored pending auth + const keys: string[] = []; + // MemoryStorage doesn't expose keys, so find it via the authorize call + // The state is random, but we can find it by checking the PAR body + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const parBody = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + const state = parBody.get("state")!; + + const result = await client.callback( + new URLSearchParams({ code: "auth-code", state }), + ); + + expect(result.session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(result.state).toBe(state); + }); + + test("session.sub is an alias for session.did", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore("did:plc:abcdefghijklmnopqrstuvwx"); + expect(session.sub).toBe(session.did); + expect(session.sub).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + }); + + test("session.getTokenInfo returns available metadata", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + scopes: "atproto", + pdsUrl: "https://pds.example.com", + issuer: "https://pds.example.com", + }), + ); + + const session = await client.restore("did:plc:abcdefghijklmnopqrstuvwx"); + const info = session.getTokenInfo(); + expect(info.sub).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(info.scope).toBe("atproto"); + expect(info.aud).toBe("https://pds.example.com"); + expect(info.iss).toBe("https://pds.example.com"); + }); + + test("session.getTokenInfo works with legacy stored sessions (no extra fields)", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore("did:plc:abcdefghijklmnopqrstuvwx"); + const info = session.getTokenInfo(); + expect(info.sub).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(info.scope).toBeUndefined(); + expect(info.aud).toBeUndefined(); + expect(info.iss).toBeUndefined(); + }); + + test("session.signOut deletes the session", async () => { + const deleteFn = mock( + async (input: RequestInfo | URL, init?: RequestInit) => { + return new Response(null, { status: 204 }); + }, + ); + const client = createClient(deleteFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + await storage.set( + "happyview:last-active-did", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + + const session = await client.restore("did:plc:abcdefghijklmnopqrstuvwx"); + await session.signOut(); + + const stored = await storage.get( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + ); + expect(stored).toBeNull(); + }); + + test("callback session includes token metadata", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "pending-auth:statemeta", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "statemeta", + issuer: "https://pds.example.com", + }), + ); + + const result = await client.callback( + new URLSearchParams({ code: "auth-code", state: "statemeta" }), + ); + + const info = result.session.getTokenInfo(); + expect(info.scope).toBe("atproto"); + expect(info.iss).toBe("https://pds.example.com"); + expect(info.aud).toBe("https://pds.example.com"); + }); + + test("authorize passes prompt option to PAR", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + prompt: "login", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("prompt")).toBe("login"); + }); + + test("authorize passes redirect_uri option", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + redirect_uri: "https://other.example.com/cb", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("redirect_uri")).toBe("https://other.example.com/cb"); + }); + + test("authorize passes display and ui_locales options", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + display: "popup", + ui_locales: "en fr", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("display")).toBe("popup"); + expect(body.get("ui_locales")).toBe("en fr"); + }); + + test("handleResolver and didResolver are publicly accessible", () => { + const client = createClient(); + expect(client.handleResolver).toBeDefined(); + expect(client.didResolver).toBeDefined(); + }); + + test("fetchMetadata fetches and returns client metadata JSON", async () => { + const metadata = { + client_id: "https://example.com/metadata.json", + client_name: "Test", + }; + const fetchFn = mock(async () => { + return new Response(JSON.stringify(metadata), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + const result = await HappyViewOAuthClient.fetchMetadata({ + clientId: "https://example.com/metadata.json", + fetch: fetchFn as typeof globalThis.fetch, + }); + + expect(result.client_id).toBe("https://example.com/metadata.json"); + expect(result.client_name).toBe("Test"); + }); + + test("fetchMetadata throws on non-200 response", async () => { + const fetchFn = mock(async () => { + return new Response("not found", { status: 404 }); + }); + + try { + await HappyViewOAuthClient.fetchMetadata({ + clientId: "https://example.com/metadata.json", + fetch: fetchFn as typeof globalThis.fetch, + }); + expect(true).toBe(false); + } catch (err) { + expect((err as Error).message).toContain("404"); + } + }); + + test("fetchMetadata throws on non-JSON content type", async () => { + const fetchFn = mock(async () => { + return new Response("hi", { + status: 200, + headers: { "content-type": "text/html" }, + }); + }); + + try { + await HappyViewOAuthClient.fetchMetadata({ + clientId: "https://example.com/metadata.json", + fetch: fetchFn as typeof globalThis.fetch, + }); + expect(true).toBe(false); + } catch (err) { + expect((err as Error).message).toContain("content type"); + } + }); + + test("sessionHooks.onSessionUpdate fires after callback", async () => { + const onSessionUpdate = mock((did: string) => {}); + const fetchFn = mockFetchForFullFlow(); + storage = new MemoryStorage(); + const client = new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + redirectUri: "https://example.com/oauth/callback", + storage, + sessionHooks: { onSessionUpdate }, + fetch: fetchFn, + }); + + await storage.set( + "pending-auth:statehook", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "statehook", + issuer: "https://pds.example.com", + }), + ); + + await client.callback( + new URLSearchParams({ code: "auth-code", state: "statehook" }), + ); + + expect(onSessionUpdate).toHaveBeenCalledTimes(1); + expect(onSessionUpdate.mock.calls[0][0]).toBe( + "did:plc:abcdefghijklmnopqrstuvwx", + ); + }); + + test("sessionHooks.onSessionDelete fires after revoke", async () => { + const onSessionDelete = mock((did: string) => {}); + const deleteFn = mock(async () => new Response(null, { status: 204 })); + storage = new MemoryStorage(); + const client = new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + redirectUri: "https://example.com/oauth/callback", + storage, + sessionHooks: { onSessionDelete }, + fetch: deleteFn, + }); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + await client.revoke("did:plc:abcdefghijklmnopqrstuvwx"); + + expect(onSessionDelete).toHaveBeenCalledTimes(1); + expect(onSessionDelete.mock.calls[0][0]).toBe( + "did:plc:abcdefghijklmnopqrstuvwx", + ); + }); + + test("callback throws OAuthCallbackError when params contain error", async () => { + const client = createClient(); + + await storage.set( + "pending-auth:stateerr", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "pv", + authPkceVerifier: "av", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "stateerr", + issuer: "https://pds.example.com", + }), + ); + + try { + await client.callback( + new URLSearchParams({ + error: "access_denied", + error_description: "User denied access", + state: "stateerr", + }), + ); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(OAuthCallbackError); + const oauthErr = err as OAuthCallbackError; + expect(oauthErr.state).toBe("stateerr"); + expect(oauthErr.params.get("error")).toBe("access_denied"); + expect(oauthErr.message).toBe("User denied access"); + } + }); + + test("callback cleans up pending state on error param", async () => { + const client = createClient(); + + await storage.set( + "pending-auth:stateclean", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "pv", + authPkceVerifier: "av", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "stateclean", + issuer: "https://pds.example.com", + }), + ); + + try { + await client.callback( + new URLSearchParams({ + error: "access_denied", + state: "stateclean", + }), + ); + } catch { + // expected + } + + const pending = await storage.get("pending-auth:stateclean"); + expect(pending).toBeNull(); + }); +}); + +describe("OAuthCallbackError", () => { + test("extends HappyViewError", () => { + const err = new OAuthCallbackError(new URLSearchParams(), "test"); + expect(err).toBeInstanceOf(HappyViewError); + expect(err).toBeInstanceOf(Error); + }); + + test("uses error_description from params when no message given", () => { + const params = new URLSearchParams({ + error: "access_denied", + error_description: "User denied", + }); + const err = new OAuthCallbackError(params); + expect(err.message).toBe("User denied"); + }); + + test("falls back to default message when no description or message", () => { + const err = new OAuthCallbackError(new URLSearchParams()); + expect(err.message).toBe("OAuth callback error"); + }); + + test("explicit message overrides error_description", () => { + const params = new URLSearchParams({ + error_description: "from params", + }); + const err = new OAuthCallbackError(params, "explicit message"); + expect(err.message).toBe("explicit message"); + }); + + test("preserves params and state", () => { + const params = new URLSearchParams({ code: "abc", state: "xyz" }); + const err = new OAuthCallbackError(params, "msg", "xyz"); + expect(err.params).toBe(params); + expect(err.state).toBe("xyz"); + }); + + test("preserves cause when provided", () => { + const cause = new Error("original"); + const err = new OAuthCallbackError( + new URLSearchParams(), + "wrapped", + "s1", + cause, + ); + expect(err.cause).toBe(cause); + }); + + test("from() returns same instance for OAuthCallbackError input", () => { + const original = new OAuthCallbackError( + new URLSearchParams(), + "original", + "s1", + ); + const result = OAuthCallbackError.from( + original, + new URLSearchParams(), + "s2", + ); + expect(result).toBe(original); + }); + + test("from() wraps Error with message and cause", () => { + const cause = new TokenExchangeError("exchange failed", 400, "body"); + const params = new URLSearchParams({ state: "s1" }); + const result = OAuthCallbackError.from(cause, params, "s1"); + expect(result).toBeInstanceOf(OAuthCallbackError); + expect(result.message).toBe("exchange failed"); + expect(result.state).toBe("s1"); + expect(result.cause).toBe(cause); + }); + + test("from() wraps non-Error with undefined message", () => { + const params = new URLSearchParams({ + error_description: "desc from params", + }); + const result = OAuthCallbackError.from("string error", params, "s1"); + expect(result).toBeInstanceOf(OAuthCallbackError); + expect(result.message).toBe("desc from params"); + expect(result.cause).toBe("string error"); + }); +}); + +describe("re-exports from sub-packages", () => { + test("re-exports Key from @atproto/jwk via index", async () => { + const mod = await import("../index"); + expect(mod.Key).toBeDefined(); + }); + + test("re-exports AtprotoDohHandleResolver from @atproto-labs/handle-resolver via index", async () => { + const mod = await import("../index"); + expect(mod.AtprotoDohHandleResolver).toBeDefined(); + }); + + test("re-exports DidResolverCommon from @atproto-labs/did-resolver via index", async () => { + const mod = await import("../index"); + expect(mod.DidResolverCommon).toBeDefined(); + }); + + test("re-exports HappyViewSession from @happyview/oauth-client via index", async () => { + const mod = await import("../index"); + expect(mod.HappyViewSession).toBeDefined(); + }); + + test("re-exports OAuthCallbackError from @happyview/oauth-client via index", async () => { + const mod = await import("../index"); + expect(mod.OAuthCallbackError).toBeDefined(); + }); +}); diff --git a/packages/oauth-client-node/src/index.ts b/packages/oauth-client-node/src/index.ts new file mode 100644 index 0000000..16aca9b --- /dev/null +++ b/packages/oauth-client-node/src/index.ts @@ -0,0 +1,10 @@ +export * from "@happyview/oauth-client"; +export * from "@atproto-labs/handle-resolver"; +export * from "@atproto-labs/did-resolver"; + +export { HappyViewNodeClient } from "./node-client"; +export type { + AuthorizeOptions, + CallbackOptions, + HappyViewNodeClientOptions, +} from "./node-client"; diff --git a/packages/oauth-client-node/src/node-client.ts b/packages/oauth-client-node/src/node-client.ts new file mode 100644 index 0000000..2ca23f9 --- /dev/null +++ b/packages/oauth-client-node/src/node-client.ts @@ -0,0 +1,445 @@ +import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; +import { DidResolverCommon } from "@atproto-labs/did-resolver"; +import type { DidDocument } from "@atproto/did"; +import { + HappyViewOAuthClient, + HappyViewSession, + importJwk, + InvalidStateError, + OAuthCallbackError, + ResolutionError, + TokenExchangeError, + type SessionEventHooks, + type StorageAdapter, +} from "@happyview/oauth-client"; + +export interface HappyViewNodeClientOptions { + instanceUrl: string; + clientId: string; + clientKey: string; + clientSecret?: string; + redirectUri: string; + scopes?: string; + storage: StorageAdapter; + sessionHooks?: SessionEventHooks; + fetch?: typeof globalThis.fetch; +} + +export interface AuthorizeOptions { + scope?: string; + /** @deprecated Use `scope` instead. */ + scopes?: string; + state?: string; + redirect_uri?: string; + signal?: AbortSignal; + display?: "page" | "popup" | "touch" | "wap"; + prompt?: string; + nonce?: string; + max_age?: number; + ui_locales?: string; + dpop_jkt?: string; + claims?: Record>>; + authorization_details?: unknown[]; + id_token_hint?: string; +} + +export interface CallbackOptions { + redirect_uri?: string; +} + +interface PendingAuthState { + did: string; + provisionId: string; + rawJwk: JsonWebKey; + provisionPkceVerifier: string; + authPkceVerifier: string; + pdsUrl: string; + tokenEndpoint: string; + state: string; + issuer: string; +} + +interface AuthServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + pushed_authorization_request_endpoint?: string; + dpop_signing_alg_values_supported?: string[]; +} + +export class HappyViewNodeClient extends HappyViewOAuthClient { + readonly handleResolver: AtprotoDohHandleResolver; + readonly didResolver: DidResolverCommon; + private readonly clientId: string; + private readonly redirectUri: string; + private readonly scopes: string; + + constructor(options: HappyViewNodeClientOptions) { + const fetchFn = + options.fetch ?? + (((input: RequestInfo | URL, init?: RequestInit) => + fetch(input, init)) as typeof globalThis.fetch); + + super({ + instanceUrl: options.instanceUrl, + clientKey: options.clientKey, + clientSecret: options.clientSecret, + storage: options.storage, + sessionHooks: options.sessionHooks, + fetch: fetchFn, + }); + + this.clientId = options.clientId; + this.redirectUri = options.redirectUri; + this.scopes = options.scopes ?? "atproto"; + this.handleResolver = new AtprotoDohHandleResolver({ + dohEndpoint: "https://dns.google/resolve", + fetch: fetchFn, + }); + this.didResolver = new DidResolverCommon({ fetch: fetchFn }); + } + + async authorize( + handle: string, + options?: AuthorizeOptions, + ): Promise { + const resolvedDid = await this.handleResolver.resolve(handle); + if (!resolvedDid) { + throw new ResolutionError(`Failed to resolve handle: ${handle}`); + } + const did = resolvedDid as string; + + const didDoc = await this.didResolver.resolve(resolvedDid); + const pdsUrl = extractPdsUrl(didDoc); + const authMeta = await this.fetchAuthServerMetadata(pdsUrl); + + const scopes = options?.scope ?? options?.scopes ?? this.scopes; + + const { provisionId, rawJwk, pkceVerifier: provisionPkceVerifier } = + await this.provisionDpopKey(); + + const authPkceVerifier = generatePkceVerifier(); + const authPkceChallenge = await computePkceChallenge(authPkceVerifier); + + const state = options?.state ?? randomHex(16); + + const pendingState: PendingAuthState = { + did, + provisionId, + rawJwk, + provisionPkceVerifier: provisionPkceVerifier!, + authPkceVerifier, + pdsUrl, + tokenEndpoint: authMeta.token_endpoint, + state, + issuer: authMeta.issuer, + }; + await this.storage.set( + `pending-auth:${state}`, + JSON.stringify(pendingState), + ); + + const redirectUri = options?.redirect_uri ?? this.redirectUri; + + const authParams = new URLSearchParams({ + response_type: "code", + client_id: this.clientId, + redirect_uri: redirectUri, + state, + scope: scopes, + code_challenge: authPkceChallenge, + code_challenge_method: "S256", + login_hint: handle, + }); + + if (options?.display) authParams.set("display", options.display); + if (options?.prompt) authParams.set("prompt", options.prompt); + if (options?.nonce) authParams.set("nonce", options.nonce); + if (options?.max_age != null) authParams.set("max_age", String(options.max_age)); + if (options?.ui_locales) authParams.set("ui_locales", options.ui_locales); + if (options?.dpop_jkt) authParams.set("dpop_jkt", options.dpop_jkt); + if (options?.id_token_hint) authParams.set("id_token_hint", options.id_token_hint); + if (options?.claims) authParams.set("claims", JSON.stringify(options.claims)); + if (options?.authorization_details) authParams.set("authorization_details", JSON.stringify(options.authorization_details)); + + const parEndpoint = authMeta.pushed_authorization_request_endpoint; + if (parEndpoint) { + const parResp = await this._fetch(parEndpoint, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: authParams, + }); + + if (!parResp.ok) { + const err = await parResp.text(); + throw new ResolutionError( + `PAR request failed: ${parResp.status} ${err}`, + ); + } + + const parData = (await parResp.json()) as { request_uri: string }; + const url = new URL( + `${authMeta.authorization_endpoint}?` + + new URLSearchParams({ + client_id: this.clientId, + request_uri: parData.request_uri, + }), + ); + await this.storage.set(`pending-auth-url:${url.href}`, state); + return url; + } + + const url = new URL( + `${authMeta.authorization_endpoint}?${authParams}`, + ); + await this.storage.set(`pending-auth-url:${url.href}`, state); + return url; + } + + async callback( + params: URLSearchParams, + options?: CallbackOptions, + ): Promise<{ session: HappyViewSession; state: string | null }> { + const code = params.get("code"); + const state = params.get("state"); + + if (!state) { + throw new OAuthCallbackError(params, 'Missing "state" parameter'); + } + + const pendingJson = await this.storage.get(`pending-auth:${state}`); + if (!pendingJson) { + throw new OAuthCallbackError( + params, + `Unknown authorization session "${state}"`, + state, + ); + } + + if (params.has("error")) { + await this.storage.delete(`pending-auth:${state}`); + throw new OAuthCallbackError(params, undefined, state); + } + + if (!code) { + throw new OAuthCallbackError( + params, + 'Missing "code" parameter', + state, + ); + } + const pending: PendingAuthState = JSON.parse(pendingJson); + + try { + const dpopKey = await importJwk(pending.rawJwk); + const { d: _, ...publicJwk } = pending.rawJwk; + const redirectUri = options?.redirect_uri ?? this.redirectUri; + + let dpopNonce: string | undefined; + let tokenResp!: Response; + + for (let attempt = 0; attempt < 2; attempt++) { + const proof = await dpopKey.createJwt( + { + alg: "ES256", + typ: "dpop+jwt", + jwk: publicJwk as any, + }, + { + htm: "POST", + htu: pending.tokenEndpoint, + iat: Math.floor(Date.now() / 1000), + jti: randomHex(16), + ...(dpopNonce ? { nonce: dpopNonce } : {}), + }, + ); + + tokenResp = await this._fetch(pending.tokenEndpoint, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + dpop: proof, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: this.clientId, + code_verifier: pending.authPkceVerifier, + }), + }); + + if (!tokenResp.ok && attempt === 0) { + const nonceHeader = tokenResp.headers.get("dpop-nonce"); + if (nonceHeader) { + const errorBody = await tokenResp.text(); + if (errorBody.includes("use_dpop_nonce")) { + dpopNonce = nonceHeader; + continue; + } + throw new TokenExchangeError( + `Token exchange failed: ${tokenResp.status} ${errorBody}`, + tokenResp.status, + errorBody, + ); + } + } + + break; + } + + if (!tokenResp!.ok) { + const err = await tokenResp!.text(); + throw new TokenExchangeError( + `Token exchange failed: ${tokenResp!.status} ${err}`, + tokenResp!.status, + err, + ); + } + + const tokens = (await tokenResp.json()) as { + access_token: string; + refresh_token?: string; + scope?: string; + sub?: string; + iss?: string; + }; + + const session = await this.registerSession({ + provisionId: pending.provisionId, + pkceVerifier: pending.provisionPkceVerifier, + did: pending.did, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + scopes: tokens.scope ?? this.scopes, + pdsUrl: pending.pdsUrl, + issuer: tokens.iss ?? pending.issuer, + dpopKey: pending.rawJwk, + }); + + await this.storage.delete(`pending-auth:${state}`); + + return { session, state }; + } catch (err) { + throw OAuthCallbackError.from(err, params, state); + } + } + + override async restore(did?: string, _refresh?: boolean | "auto"): Promise { + if (!did) { + throw new InvalidStateError( + "DID is required for restore() in the Node client", + ); + } + const session = await this.restoreSession(did); + if (!session) { + throw new InvalidStateError(`No session found for ${did}`); + } + return session; + } + + async revoke(did: string): Promise { + await this.deleteSession(did); + } + + async abortRequest(url: URL): Promise { + const urlKey = `pending-auth-url:${url.href}`; + const state = await this.storage.get(urlKey); + if (state) { + await this.storage.delete(`pending-auth:${state}`); + await this.storage.delete(urlKey); + } + } + + private async fetchAuthServerMetadata( + pdsUrl: string, + ): Promise { + const base = pdsUrl.replace(/\/+$/, ""); + + const resourceResp = await this._fetch( + `${base}/.well-known/oauth-protected-resource`, + ); + if (!resourceResp.ok) { + throw new ResolutionError( + `Failed to fetch protected resource metadata from ${pdsUrl}: ${resourceResp.status}`, + ); + } + const resource = (await resourceResp.json()) as { + authorization_servers?: string[]; + }; + const authServer = resource.authorization_servers?.[0]; + if (!authServer) { + throw new ResolutionError( + `No authorization server found in protected resource metadata from ${pdsUrl}`, + ); + } + + const metaResp = await this._fetch( + `${authServer.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`, + ); + if (!metaResp.ok) { + throw new ResolutionError( + `Failed to fetch auth server metadata from ${authServer}: ${metaResp.status}`, + ); + } + return metaResp.json() as Promise; + } +} + +function extractPdsUrl(doc: DidDocument): string { + const services = doc.service ?? []; + for (const service of services) { + if ( + service.id === "#atproto_pds" || + (typeof service.id === "string" && + service.id.endsWith("#atproto_pds")) + ) { + if (typeof service.serviceEndpoint === "string") { + return service.serviceEndpoint; + } + throw new ResolutionError( + `#atproto_pds service endpoint is not a string URL in DID document for ${doc.id}`, + ); + } + } + throw new ResolutionError( + `No #atproto_pds service found in DID document for ${doc.id}`, + ); +} + +function randomHex(byteLength: number): string { + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join( + "", + ); +} + +function generatePkceVerifier(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function computePkceChallenge(verifier: string): Promise { + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(verifier), + ); + const bytes = new Uint8Array(hash); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} diff --git a/packages/oauth-client-node/tsconfig.json b/packages/oauth-client-node/tsconfig.json new file mode 100644 index 0000000..30b8260 --- /dev/null +++ b/packages/oauth-client-node/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/__tests__"] +} diff --git a/packages/oauth-client-node/tsup.config.ts b/packages/oauth-client-node/tsup.config.ts new file mode 100644 index 0000000..ff1a153 --- /dev/null +++ b/packages/oauth-client-node/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + clean: true, +}); -- 2.51.2 From 538d03380e1940789e67ce4c13e4f808680581f4 Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 7 May 2026 08:55:06 -0500 Subject: [PATCH 02/56] feat: add support for session hooks Signed-off-by: Trezy --- bun.lock | 27 +- .../docs/docs/sdk/oauth-client-browser.md | 169 +++++++- packages/docs/docs/sdk/overview.md | 10 +- .../src/__tests__/browser-client.test.ts | 381 +++++++++++++++++- .../src/__tests__/util.test.ts | 89 ++++ .../src/browser-client.ts | 239 ++++++----- packages/oauth-client-browser/src/index.ts | 20 +- packages/oauth-client-browser/src/util.ts | 19 + packages/oauth-client-browser/tsconfig.json | 2 +- packages/oauth-client/src/client.ts | 48 ++- packages/oauth-client/src/errors.ts | 34 ++ packages/oauth-client/src/index.ts | 7 + packages/oauth-client/src/session.ts | 33 +- packages/oauth-client/src/types.ts | 19 +- 14 files changed, 943 insertions(+), 154 deletions(-) create mode 100644 packages/oauth-client-browser/src/__tests__/util.test.ts create mode 100644 packages/oauth-client-browser/src/util.ts diff --git a/bun.lock b/bun.lock index cb20426..0ff328b 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,7 @@ "name": "@happyview/lex-agent", "version": "0.0.0-development", "dependencies": { - "@happyview/oauth-client": "^1.0.0-dev.2", + "@happyview/oauth-client": "workspace:*", }, "devDependencies": { "@atproto/lex": "^0.0.25", @@ -87,6 +87,27 @@ "typescript": "^5.7.0", }, }, + "packages/oauth-client-node": { + "name": "@happyview/oauth-client-node", + "version": "0.0.0-development", + "dependencies": { + "@atproto-labs/did-resolver": "^0.2.6", + "@atproto-labs/handle-resolver": "^0.3.6", + "@atproto/did": "^0.3.0", + "@happyview/oauth-client": "workspace:*", + }, + "devDependencies": { + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/exec": "^7.0.0", + "@semantic-release/github": "^12.0.6", + "@semantic-release/release-notes-generator": "^14.1.0", + "@types/bun": "^1.3.12", + "semantic-release": "^25.0.3", + "semantic-release-monorepo": "^8.0.2", + "tsup": "^8.0.0", + "typescript": "^5.7.0", + }, + }, }, "overrides": { "webpack": "5.97.1", @@ -636,6 +657,8 @@ "@happyview/oauth-client-browser": ["@happyview/oauth-client-browser@workspace:packages/oauth-client-browser"], + "@happyview/oauth-client-node": ["@happyview/oauth-client-node@workspace:packages/oauth-client-node"], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], @@ -3070,8 +3093,6 @@ "@docusaurus/types/webpack-merge": ["webpack-merge@5.10.0", "", { "dependencies": { "clone-deep": "^4.0.1", "flat": "^5.0.2", "wildcard": "^2.0.0" } }, "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA=="], - "@happyview/lex-agent/@happyview/oauth-client": ["@happyview/oauth-client@1.0.0", "", {}, "sha512-hvVeyVHJStZ/nrqolcWfy39AJuaws0axKx7b3UvXTSi2TmnTeCJOnelnHstt0L9S6P6b7xxP8ujzvcWZsOd0Ig=="], - "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@17.67.0", "", { "dependencies": { "@jsonjoy.com/base64": "17.67.0", "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0", "@jsonjoy.com/json-pointer": "17.67.0", "@jsonjoy.com/util": "17.67.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w=="], "@jsonjoy.com/fs-snapshot/@jsonjoy.com/util": ["@jsonjoy.com/util@17.67.0", "", { "dependencies": { "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew=="], diff --git a/packages/docs/docs/sdk/oauth-client-browser.md b/packages/docs/docs/sdk/oauth-client-browser.md index f684e83..9ee8c8f 100644 --- a/packages/docs/docs/sdk/oauth-client-browser.md +++ b/packages/docs/docs/sdk/oauth-client-browser.md @@ -30,6 +30,7 @@ const client = new HappyViewBrowserClient({ | `redirectUri` | No | OAuth callback URL. Defaults to `${window.location.origin}/oauth/callback` | | `scopes` | No | OAuth scopes to request. Defaults to `"atproto"` | | `storage` | No | Custom storage adapter. Defaults to localStorage | +| `sessionHooks`| No | Event hooks for session lifecycle events | | `fetch` | No | Custom fetch implementation | The client uses localStorage by default. You can override it: @@ -165,7 +166,9 @@ console.log(info.scopes); // ["atproto", "transition:generic"] ``` -## Authenticated requests +## Session + +### Authenticated requests The session's `fetchHandler` attaches DPoP proof headers automatically: @@ -180,32 +183,128 @@ const data = await response.json(); Pass a relative path (prepends the HappyView instance URL) or a full URL (used as-is). +### Token info + +```typescript +const info = session.getTokenInfo(); +// { sub, scope, iss, aud } +``` + +### Properties + +| Property | Type | Description | +| -------- | -------- | ---------------------------------------- | +| `did` | `string` | The authenticated user's DID | +| `sub` | `string` | Alias for `did` (matches upstream naming) | + +### Sign out + +Sessions can self-revoke: + +```typescript +await session.signOut(); +``` + +This is equivalent to calling `client.revoke(session.did)`. + +## Session event hooks + +React to session lifecycle events with `sessionHooks`: + +```typescript +const client = new HappyViewBrowserClient({ + // ... + sessionHooks: { + onSessionUpdate(did) { + console.log(`Session created/updated for ${did}`); + }, + onSessionDelete(did) { + console.log(`Session deleted for ${did}`); + }, + }, +}); +``` + +- `onSessionUpdate(did)` fires after a session is registered (from `callback()`) or restored. +- `onSessionDelete(did)` fires after a session is revoked (from `revoke()`, `logout()`, or `session.signOut()`). + +## Error handling + +Callback errors are always wrapped in `OAuthCallbackError`, which carries the original callback params and state: + +```typescript +import { OAuthCallbackError } from "@happyview/oauth-client-browser"; + +try { + const session = await client.callback(); +} catch (err) { + if (err instanceof OAuthCallbackError) { + console.log(err.state); // the state from the callback + console.log(err.params.get("error")); // e.g. "access_denied" + console.log(err.cause); // the underlying error, if any + } +} +``` + +If the authorization server returns an error (e.g., the user denied access), the `params` contain the `error` and `error_description` fields from the server response. If the token exchange fails, the underlying `TokenExchangeError` is available as `err.cause`. + +## Using with @atproto/api + +`HappyViewSession` is directly compatible with `@atproto/api`'s `Agent`. Pass it as the session manager: + +```typescript +import { Agent } from "@atproto/api"; + +const result = await client.init(); +if (result) { + const agent = new Agent(result.session); + + // Use the full @atproto/api surface + const profile = await agent.getProfile({ actor: agent.did }); + await agent.like(postUri, postCid); +} +``` + +This works because `HappyViewSession` implements the `SessionManager` interface that `Agent` expects — it has `did` and a `fetchHandler` that attaches DPoP authentication headers and prepends the HappyView instance URL. + ## Revoke session +From the client: + ```typescript await client.revoke(session.did); ``` +Or from the session itself: + +```typescript +await session.signOut(); +``` + :::note `logout()` still works as an alias for `revoke()`. ::: -## Resolution utilities +## Identity resolution -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: +The client exposes its handle and DID resolvers for advanced use: ```typescript -import { - resolveHandleToDid, - resolveDidDocument, - resolvePdsUrl, - resolveAuthServerMetadata, -} from "@happyview/oauth-client-browser"; +const did = await client.handleResolver.resolve("alice.bsky.social"); +const doc = await client.didResolver.resolve(did); +``` + +## Validate client metadata -const did = await resolveHandleToDid("alice.bsky.social"); -const doc = await resolveDidDocument(did); -const pdsUrl = resolvePdsUrl(doc); -const authMeta = await resolveAuthServerMetadata(pdsUrl); +Verify that your OAuth client metadata is served correctly: + +```typescript +import { HappyViewBrowserClient } from "@happyview/oauth-client-browser"; + +const metadata = await HappyViewBrowserClient.fetchMetadata({ + clientId: "https://example.com/oauth-client-metadata.json", +}); +console.log(metadata.client_name); ``` ## OAuth client metadata @@ -239,16 +338,56 @@ For a static site, serve a plain JSON file at `/oauth-client-metadata.json`. The `redirect_uris` array must include the `redirectUri` your client is configured with (defaults to `${origin}/oauth/callback`). +## Local development + +For local development with ATProto's loopback client ID convention, use `buildLoopbackClientId`: + +```typescript +import { buildLoopbackClientId } from "@happyview/oauth-client-browser"; + +const clientId = buildLoopbackClientId(window.location); +// → "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2F" +``` + +This builds a client ID that authorization servers recognize as a local development app. The `redirect_uri` is encoded in the client ID URL query string. + +## Cleanup + +The browser client implements `AsyncDisposable` for use with `await using`: + +```typescript +await using client = new HappyViewBrowserClient({ ... }); +// client.dispose() called automatically when scope exits +``` + +Or call `dispose()` manually: + +```typescript +client.dispose(); +``` + ## 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: +This package re-exports everything from `@happyview/oauth-client`, `@atproto-labs/handle-resolver`, and `@atproto-labs/did-resolver`. You don't need to install these packages separately: ```typescript import { + // From @happyview/oauth-client HappyViewBrowserClient, HappyViewSession, ApiError, - type CryptoAdapter, + OAuthCallbackError, + Key, + type SessionEventHooks, type StorageAdapter, + type TokenInfo, + type Jwk, + + // From @atproto-labs/handle-resolver + AtprotoDohHandleResolver, + + // From @atproto-labs/did-resolver + DidResolverCommon, + type DidDocument, } from "@happyview/oauth-client-browser"; ``` diff --git a/packages/docs/docs/sdk/overview.md b/packages/docs/docs/sdk/overview.md index 9229674..e072183 100644 --- a/packages/docs/docs/sdk/overview.md +++ b/packages/docs/docs/sdk/overview.md @@ -6,8 +6,8 @@ HappyView provides JavaScript packages for building third-party apps that authen | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | [`@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 | | [`@happyview/oauth-client`](https://npmx.dev/package/@happyview/oauth-client) | Platform-agnostic core — DPoP key provisioning, session management, authenticated fetch | -| [`@happyview/oauth-client-browser`](https://npmx.dev/package/@happyview/oauth-client-browser) | Browser OAuth wrapper with Web Crypto, localStorage, and redirect/popup flows | -| [`@happyview/oauth-client-node`](https://npmx.dev/package/@happyview/oauth-client-node) | Node.js server wrapper with handle/DID resolution and server-friendly authorize/callback API | +| [`@happyview/oauth-client-browser`](https://npmx.dev/package/@happyview/oauth-client-browser) | Browser OAuth wrapper for apps already using `@atproto/oauth-client-browser` | +| [`@happyview/oauth-client-node`](https://npmx.dev/package/@happyview/oauth-client-node) | Node.js OAuth client for server-side apps, matching `@atproto/oauth-client-node` | ## Which package do I need? @@ -15,9 +15,9 @@ HappyView provides JavaScript packages for building third-party apps that authen **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 atproto OAuth flow. -**Building a server-side Node.js app?** Use `@happyview/oauth-client-node`. It provides `authorize()` and `callback()` methods designed for server request handlers, plus built-in handle and DID resolution. +**Building a server-side (Node.js) app?** Use `@happyview/oauth-client-node` — it handles handle resolution, DID resolution, PDS discovery, and the full OAuth flow server-side. Matches the API surface of `@atproto/oauth-client-node`. -**Need something more custom?** Use `@happyview/oauth-client` directly and provide your own `StorageAdapter`. +**Building something more custom?** Use `@happyview/oauth-client` directly and provide your own `CryptoAdapter` and `StorageAdapter`. ## How it works @@ -68,5 +68,5 @@ const result = await lex.xrpc(myLexicons.com.example.getGame, { - [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 -- [Node Client](./oauth-client-node.md): server-side authorize/callback flow +- [Node Client](./oauth-client-node.md): server-side OAuth flow - [Authentication](../getting-started/authentication.md): full details on DPoP key provisioning and API client types diff --git a/packages/oauth-client-browser/src/__tests__/browser-client.test.ts b/packages/oauth-client-browser/src/__tests__/browser-client.test.ts index d6e26c1..41a4954 100644 --- a/packages/oauth-client-browser/src/__tests__/browser-client.test.ts +++ b/packages/oauth-client-browser/src/__tests__/browser-client.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"; import { - InvalidStateError, + HappyViewOAuthClient, + OAuthCallbackError, TokenExchangeError, type StorageAdapter, } from "@happyview/oauth-client"; @@ -402,27 +403,29 @@ describe("HappyViewBrowserClient", () => { ); }); - test("callback throws InvalidStateError when code or state is missing", async () => { + test("callback throws OAuthCallbackError when state is missing", async () => { const client = createClient(); try { await client.callback("?code=auth-code"); expect(true).toBe(false); } catch (err) { - expect(err).toBeInstanceOf(InvalidStateError); + expect(err).toBeInstanceOf(OAuthCallbackError); + expect((err as OAuthCallbackError).state).toBeUndefined(); } }); - test("callback throws InvalidStateError when no pending state found", async () => { + test("callback throws OAuthCallbackError when no pending state found", async () => { const client = createClient(); try { await client.callback("?code=auth-code&state=nonexistent"); expect(true).toBe(false); } catch (err) { - expect(err).toBeInstanceOf(InvalidStateError); + expect(err).toBeInstanceOf(OAuthCallbackError); + expect((err as OAuthCallbackError).state).toBe("nonexistent"); } }); - test("callback throws TokenExchangeError on token endpoint failure", async () => { + test("callback throws OAuthCallbackError wrapping TokenExchangeError on token failure", async () => { const fetchFn = mock( async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -455,9 +458,10 @@ describe("HappyViewBrowserClient", () => { await client.callback("?code=auth-code&state=statefail"); expect(true).toBe(false); } catch (err) { - expect(err).toBeInstanceOf(TokenExchangeError); - expect((err as TokenExchangeError).status).toBe(400); - expect((err as TokenExchangeError).body).toBe("invalid_grant"); + expect(err).toBeInstanceOf(OAuthCallbackError); + expect((err as OAuthCallbackError).state).toBe("statefail"); + expect((err as OAuthCallbackError).cause).toBeInstanceOf(TokenExchangeError); + expect(((err as OAuthCallbackError).cause as TokenExchangeError).status).toBe(400); } }); @@ -775,10 +779,369 @@ describe("HappyViewBrowserClient", () => { expect(() => client.dispose()).not.toThrow(); }); + test("prepareLogin accepts scope (singular) option", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.prepareLogin("user.bsky.social", { + scope: "atproto transition:generic", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + expect(parCall).toBeDefined(); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("scope")).toBe("atproto transition:generic"); + }); + + test("prepareLogin prefers scope over scopes when both provided", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.prepareLogin("user.bsky.social", { + scope: "atproto transition:generic", + scopes: "atproto", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("scope")).toBe("atproto transition:generic"); + }); + + test("restore accepts and ignores refresh parameter", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore( + "did:plc:abcdefghijklmnopqrstuvwx", + true, + ); + expect(session).not.toBeNull(); + expect(session!.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + }); + + test("callback retries with DPoP nonce on use_dpop_nonce error", async () => { + let tokenAttempt = 0; + const fetchFn = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + + if (url.includes("/oauth/token")) { + tokenAttempt++; + if (tokenAttempt === 1) { + return new Response( + JSON.stringify({ error: "use_dpop_nonce" }), + { + status: 400, + headers: { "dpop-nonce": "server-nonce-123" }, + }, + ); + } + return new Response( + JSON.stringify({ + access_token: "at_test_token", + refresh_token: "rt_test_token", + scope: "atproto", + sub: "did:plc:abcdefghijklmnopqrstuvwx", + iss: "https://pds.example.com", + }), + { status: 200 }, + ); + } + + if (url.includes("/oauth/sessions") && init?.method === "POST") { + return new Response( + JSON.stringify({ + session_id: "sess_test", + did: "did:plc:abcdefghijklmnopqrstuvwx", + }), + { status: 201 }, + ); + } + + return new Response("not found", { status: 404 }); + }); + + const client = createClient(fetchFn); + + localStorage.setItem( + "@happyview/oauth(pending-auth:statenonce)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "statenonce", + issuer: "https://pds.example.com", + }), + ); + + const session = await client.callback("?code=auth-code&state=statenonce"); + expect(session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(tokenAttempt).toBe(2); + + const secondTokenCall = fetchFn.mock.calls.filter((call: any[]) => + String(call[0]).includes("/oauth/token"), + )[1]; + const dpopJwt = new Headers( + (secondTokenCall![1] as RequestInit).headers, + ).get("dpop")!; + const payloadB64 = dpopJwt.split(".")[1]; + const padded = + payloadB64 + "=".repeat((4 - (payloadB64.length % 4)) % 4); + const payload = JSON.parse( + atob(padded.replace(/-/g, "+").replace(/_/g, "/")), + ); + expect(payload.nonce).toBe("server-nonce-123"); + }); + + test("session.sub is an alias for session.did", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore("did:plc:abcdefghijklmnopqrstuvwx"); + expect(session!.sub).toBe(session!.did); + }); + + test("session.getTokenInfo returns metadata from stored session", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + scopes: "atproto transition:generic", + pdsUrl: "https://pds.example.com", + issuer: "https://pds.example.com", + }), + ); + + const session = await client.restore("did:plc:abcdefghijklmnopqrstuvwx"); + const info = session!.getTokenInfo(); + expect(info.sub).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(info.scope).toBe("atproto transition:generic"); + expect(info.aud).toBe("https://pds.example.com"); + expect(info.iss).toBe("https://pds.example.com"); + }); + + test("session.signOut deletes the session", async () => { + const deleteFn = mock( + async (input: RequestInfo | URL, init?: RequestInit) => { + return new Response(null, { status: 204 }); + }, + ); + const client = createClient(deleteFn); + + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + localStorage.setItem( + "@happyview/oauth(happyview:last-active-did)", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + + const session = await client.restore("did:plc:abcdefghijklmnopqrstuvwx"); + await session!.signOut(); + + expect( + localStorage.getItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + ), + ).toBeNull(); + }); + + test("prepareLogin passes prompt option to PAR", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.prepareLogin("user.bsky.social", { + prompt: "login", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("prompt")).toBe("login"); + }); + + test("prepareLogin passes redirect_uri option", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.prepareLogin("user.bsky.social", { + redirect_uri: "https://other.example.com/cb", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("redirect_uri")).toBe("https://other.example.com/cb"); + }); + + test("handleResolver and didResolver are publicly accessible", () => { + const client = createClient(); + expect(client.handleResolver).toBeDefined(); + expect(client.didResolver).toBeDefined(); + }); + test("LoginContinuedInParentWindowError has correct name and message", () => { const err = new LoginContinuedInParentWindowError(); expect(err.name).toBe("LoginContinuedInParentWindowError"); expect(err.message).toBe("Login continued in parent window"); expect(err).toBeInstanceOf(Error); }); + + test("sessionHooks.onSessionUpdate fires after callback", async () => { + const onSessionUpdate = mock((did: string) => {}); + const fetchFn = mockFetchForFullFlow(); + const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + storage: new LocalStorageAdapter(), + sessionHooks: { onSessionUpdate }, + fetch: fetchFn, + }); + + localStorage.setItem( + "@happyview/oauth(pending-auth:statehook)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "statehook", + issuer: "https://pds.example.com", + }), + ); + + await client.callback("?code=auth-code&state=statehook"); + + expect(onSessionUpdate).toHaveBeenCalledTimes(1); + expect(onSessionUpdate.mock.calls[0][0]).toBe( + "did:plc:abcdefghijklmnopqrstuvwx", + ); + }); + + test("sessionHooks.onSessionDelete fires after logout", async () => { + const onSessionDelete = mock((did: string) => {}); + const deleteFn = mock(async () => new Response(null, { status: 204 })); + const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + storage: new LocalStorageAdapter(), + sessionHooks: { onSessionDelete }, + fetch: deleteFn, + }); + + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + localStorage.setItem( + "@happyview/oauth(happyview:last-active-did)", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + + await client.logout("did:plc:abcdefghijklmnopqrstuvwx"); + + expect(onSessionDelete).toHaveBeenCalledTimes(1); + expect(onSessionDelete.mock.calls[0][0]).toBe( + "did:plc:abcdefghijklmnopqrstuvwx", + ); + }); + + test("callback throws OAuthCallbackError when params contain error", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(pending-auth:stateerr)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "pv", + authPkceVerifier: "av", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "stateerr", + issuer: "https://pds.example.com", + }), + ); + + try { + await client.callback( + "?error=access_denied&error_description=User+denied+access&state=stateerr", + ); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(OAuthCallbackError); + const oauthErr = err as OAuthCallbackError; + expect(oauthErr.state).toBe("stateerr"); + expect(oauthErr.params.get("error")).toBe("access_denied"); + expect(oauthErr.message).toBe("User denied access"); + } + }); + + test("[Symbol.asyncDispose] calls dispose", async () => { + const client = createClient(); + await client[Symbol.asyncDispose](); + }); }); diff --git a/packages/oauth-client-browser/src/__tests__/util.test.ts b/packages/oauth-client-browser/src/__tests__/util.test.ts new file mode 100644 index 0000000..3ae7bc2 --- /dev/null +++ b/packages/oauth-client-browser/src/__tests__/util.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { buildLoopbackClientId } from "../util"; + +describe("buildLoopbackClientId", () => { + test("builds client ID from localhost", () => { + const result = buildLoopbackClientId({ + hostname: "localhost", + pathname: "/", + port: "3000", + }); + expect(result).toBe( + "http://localhost?redirect_uri=" + + encodeURIComponent("http://127.0.0.1:3000/"), + ); + }); + + test("builds client ID from 127.0.0.1", () => { + const result = buildLoopbackClientId({ + hostname: "127.0.0.1", + pathname: "/", + port: "8080", + }); + expect(result).toBe( + "http://localhost?redirect_uri=" + + encodeURIComponent("http://127.0.0.1:8080/"), + ); + }); + + test("preserves non-root pathname in client ID", () => { + const result = buildLoopbackClientId({ + hostname: "localhost", + pathname: "/callback", + port: "3000", + }); + expect(result).toBe( + "http://localhost/callback?redirect_uri=" + + encodeURIComponent("http://127.0.0.1:3000/callback"), + ); + }); + + test("omits port from redirect_uri when empty", () => { + const result = buildLoopbackClientId({ + hostname: "localhost", + pathname: "/", + port: "", + }); + expect(result).toBe( + "http://localhost?redirect_uri=" + + encodeURIComponent("http://127.0.0.1/"), + ); + }); + + test("accepts custom localhost override", () => { + const result = buildLoopbackClientId( + { hostname: "localhost", pathname: "/", port: "3000" }, + "::1", + ); + expect(result).toContain("http%3A%2F%2F%3A%3A1%3A3000"); + }); + + test("accepts [::1] hostname", () => { + const result = buildLoopbackClientId({ + hostname: "[::1]", + pathname: "/", + port: "3000", + }); + expect(result).toContain("redirect_uri="); + }); + + test("throws on non-loopback hostname", () => { + expect(() => + buildLoopbackClientId({ + hostname: "example.com", + pathname: "/", + port: "3000", + }), + ).toThrow(TypeError); + }); + + test("throws with descriptive message", () => { + expect(() => + buildLoopbackClientId({ + hostname: "evil.com", + pathname: "/", + port: "80", + }), + ).toThrow('Expected a loopback hostname, got "evil.com"'); + }); +}); diff --git a/packages/oauth-client-browser/src/browser-client.ts b/packages/oauth-client-browser/src/browser-client.ts index 740cbc8..ae0ce3b 100644 --- a/packages/oauth-client-browser/src/browser-client.ts +++ b/packages/oauth-client-browser/src/browser-client.ts @@ -7,8 +7,10 @@ import { LAST_ACTIVE_KEY, importJwk, InvalidStateError, + OAuthCallbackError, ResolutionError, TokenExchangeError, + type SessionEventHooks, type StorageAdapter, } from "@happyview/oauth-client"; import { LocalStorageAdapter } from "./local-storage-adapter"; @@ -31,6 +33,7 @@ export interface HappyViewBrowserClientOptions { redirectUri?: string; scopes?: string; storage?: StorageAdapter; + sessionHooks?: SessionEventHooks; fetch?: typeof globalThis.fetch; } @@ -47,8 +50,21 @@ interface PendingAuthState { } export interface LoginOptions { + scope?: string; + /** @deprecated Use `scope` instead. */ scopes?: string; state?: string; + redirect_uri?: string; + signal?: AbortSignal; + display?: "page" | "popup" | "touch" | "wap"; + prompt?: string; + nonce?: string; + max_age?: number; + ui_locales?: string; + dpop_jkt?: string; + claims?: Record>>; + authorization_details?: unknown[]; + id_token_hint?: string; } export interface PopupLoginOptions extends LoginOptions { @@ -77,8 +93,8 @@ interface AuthServerMetadata { } export class HappyViewBrowserClient extends HappyViewOAuthClient { - private readonly handleResolver: AtprotoDohHandleResolver; - private readonly didResolver: DidResolverCommon; + readonly handleResolver: AtprotoDohHandleResolver; + readonly didResolver: DidResolverCommon; private readonly clientId: string; private readonly redirectUri: string | undefined; private readonly scopes: string; @@ -89,6 +105,7 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { instanceUrl: options.instanceUrl, clientKey: options.clientKey, storage: storageAdapter, + sessionHooks: options.sessionHooks, fetch: fetchFn, }); @@ -114,7 +131,7 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { const pdsUrl = extractPdsUrl(didDoc); const authMeta = await this.fetchAuthServerMetadata(pdsUrl); - const scopes = options?.scopes ?? this.scopes; + const scopes = options?.scope ?? options?.scopes ?? this.scopes; // Provision DPoP key from HappyView const { provisionId, rawJwk, pkceVerifier: provisionPkceVerifier } = @@ -142,7 +159,8 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { JSON.stringify(pendingState), ); - const { clientId, redirectUri } = this.resolveOAuthEndpoints(); + const { clientId, redirectUri: defaultRedirectUri } = this.resolveOAuthEndpoints(); + const redirectUri = options?.redirect_uri ?? defaultRedirectUri; const authParams = new URLSearchParams({ response_type: "code", @@ -155,6 +173,16 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { login_hint: handle, }); + if (options?.display) authParams.set("display", options.display); + if (options?.prompt) authParams.set("prompt", options.prompt); + if (options?.nonce) authParams.set("nonce", options.nonce); + if (options?.max_age != null) authParams.set("max_age", String(options.max_age)); + if (options?.ui_locales) authParams.set("ui_locales", options.ui_locales); + if (options?.dpop_jkt) authParams.set("dpop_jkt", options.dpop_jkt); + if (options?.id_token_hint) authParams.set("id_token_hint", options.id_token_hint); + if (options?.claims) authParams.set("claims", JSON.stringify(options.claims)); + if (options?.authorization_details) authParams.set("authorization_details", JSON.stringify(options.authorization_details)); + // ATProto requires Pushed Authorization Requests (PAR) const parEndpoint = authMeta.pushed_authorization_request_endpoint; if (parEndpoint) { @@ -200,121 +228,128 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { const code = params.get("code"); const state = params.get("state"); - if (!code || !state) { - const error = params.get("error"); - const errorDesc = params.get("error_description"); - const raw = search ?? window.location.search; - throw new InvalidStateError( - `Missing code or state in callback URL. ` + - `error=${error}, error_description=${errorDesc}, ` + - `search=${raw}` - ); + if (!state) { + throw new OAuthCallbackError(params, 'Missing "state" parameter'); } const pendingJson = await this.storage.get(`pending-auth:${state}`); if (!pendingJson) { - throw new InvalidStateError( - "No pending auth state found for this callback", + throw new OAuthCallbackError( + params, + `Unknown authorization session "${state}"`, + state, ); } - const pending: PendingAuthState = JSON.parse(pendingJson); - // Import the stored JWK into a Key for DPoP proof generation - const dpopKey = await importJwk(pending.rawJwk); - // Build a plain public JWK object from the raw key (strip private "d" component) - const { d: _, ...publicJwk } = pending.rawJwk; + if (params.has("error")) { + await this.storage.delete(`pending-auth:${state}`); + throw new OAuthCallbackError(params, undefined, state); + } - const { clientId, redirectUri } = this.resolveOAuthEndpoints(); + if (!code) { + throw new OAuthCallbackError( + params, + 'Missing "code" parameter', + state, + ); + } - // Token exchange with DPoP nonce handling — the PDS may require a nonce - // by responding with 400 + use_dpop_nonce error and a DPoP-Nonce header. - let dpopNonce: string | undefined; - let tokenResp!: Response; + const pending: PendingAuthState = JSON.parse(pendingJson); - for (let attempt = 0; attempt < 2; attempt++) { - const proof = await dpopKey.createJwt( - { - alg: "ES256", - typ: "dpop+jwt", - jwk: publicJwk as any, - }, - { - htm: "POST", - htu: pending.tokenEndpoint, - iat: Math.floor(Date.now() / 1000), - jti: randomHex(16), - ...(dpopNonce ? { nonce: dpopNonce } : {}), - }, - ); + try { + const dpopKey = await importJwk(pending.rawJwk); + const { d: _, ...publicJwk } = pending.rawJwk; + + const { clientId, redirectUri } = this.resolveOAuthEndpoints(); + + let dpopNonce: string | undefined; + let tokenResp!: Response; + + for (let attempt = 0; attempt < 2; attempt++) { + const proof = await dpopKey.createJwt( + { + alg: "ES256", + typ: "dpop+jwt", + jwk: publicJwk as any, + }, + { + htm: "POST", + htu: pending.tokenEndpoint, + iat: Math.floor(Date.now() / 1000), + jti: randomHex(16), + ...(dpopNonce ? { nonce: dpopNonce } : {}), + }, + ); - tokenResp = await this._fetch(pending.tokenEndpoint, { - method: "POST", - headers: { - "content-type": "application/x-www-form-urlencoded", - dpop: proof, - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - client_id: clientId, - code_verifier: pending.authPkceVerifier, - }), - }); + tokenResp = await this._fetch(pending.tokenEndpoint, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + dpop: proof, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: pending.authPkceVerifier, + }), + }); - // If the server requires a DPoP nonce, retry with it - if (!tokenResp.ok && attempt === 0) { - const nonceHeader = tokenResp.headers.get("dpop-nonce"); - if (nonceHeader) { - const errorBody = await tokenResp.text(); - if (errorBody.includes("use_dpop_nonce")) { - dpopNonce = nonceHeader; - continue; + if (!tokenResp.ok && attempt === 0) { + const nonceHeader = tokenResp.headers.get("dpop-nonce"); + if (nonceHeader) { + const errorBody = await tokenResp.text(); + if (errorBody.includes("use_dpop_nonce")) { + dpopNonce = nonceHeader; + continue; + } + throw new TokenExchangeError( + `Token exchange failed: ${tokenResp.status} ${errorBody}`, + tokenResp.status, + errorBody, + ); } - // Not a nonce error — throw - throw new TokenExchangeError( - `Token exchange failed: ${tokenResp.status} ${errorBody}`, - tokenResp.status, - errorBody, - ); } - } - break; - } + break; + } - if (!tokenResp!.ok) { - const err = await tokenResp!.text(); - throw new TokenExchangeError( - `Token exchange failed: ${tokenResp!.status} ${err}`, - tokenResp!.status, - err, - ); - } + if (!tokenResp!.ok) { + const err = await tokenResp!.text(); + throw new TokenExchangeError( + `Token exchange failed: ${tokenResp!.status} ${err}`, + tokenResp!.status, + err, + ); + } - const tokens = (await tokenResp.json()) as { - access_token: string; - refresh_token?: string; - scope?: string; - sub?: string; - iss?: string; - }; + const tokens = (await tokenResp.json()) as { + access_token: string; + refresh_token?: string; + scope?: string; + sub?: string; + iss?: string; + }; - const session = await this.registerSession({ - provisionId: pending.provisionId, - pkceVerifier: pending.provisionPkceVerifier, - did: pending.did, - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - scopes: tokens.scope ?? this.scopes, - pdsUrl: pending.pdsUrl, - issuer: tokens.iss ?? pending.issuer, - dpopKey: pending.rawJwk, - }); + const session = await this.registerSession({ + provisionId: pending.provisionId, + pkceVerifier: pending.provisionPkceVerifier, + did: pending.did, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + scopes: tokens.scope ?? this.scopes, + pdsUrl: pending.pdsUrl, + issuer: tokens.iss ?? pending.issuer, + dpopKey: pending.rawJwk, + }); - await this.storage.delete(`pending-auth:${state}`); + await this.storage.delete(`pending-auth:${state}`); - return session; + return session; + } catch (err) { + throw OAuthCallbackError.from(err, params, state); + } } async logout(did: string): Promise { @@ -325,7 +360,7 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { return this.logout(did); } - override async restore(did?: string): Promise { + override async restore(did?: string, _refresh?: boolean | "auto"): Promise { if (did) { const session = await this.restoreSession(did); if (session) { @@ -497,6 +532,10 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { // No persistent resources to clean up } + async [Symbol.asyncDispose](): Promise { + this.dispose(); + } + private resolveOAuthEndpoints(): { clientId: string; redirectUri: string } { return { clientId: this.clientId, diff --git a/packages/oauth-client-browser/src/index.ts b/packages/oauth-client-browser/src/index.ts index b581a9f..4c20236 100644 --- a/packages/oauth-client-browser/src/index.ts +++ b/packages/oauth-client-browser/src/index.ts @@ -1,19 +1,6 @@ -export { - ApiError, - AuthenticationError, - HappyViewError, - HappyViewSession, - InvalidStateError, - MemoryStorage, - ResolutionError, - TokenExchangeError, - importJwk, - type DpopProvision, - type HappyViewOAuthClientOptions, - type RegisterSessionParams, - type StorageAdapter, - type StoredSession, -} from "@happyview/oauth-client"; +export * from "@happyview/oauth-client"; +export * from "@atproto-labs/handle-resolver"; +export * from "@atproto-labs/did-resolver"; export { HappyViewBrowserClient, @@ -27,3 +14,4 @@ export type { SignInOptions, } from "./browser-client"; export { LocalStorageAdapter } from "./local-storage-adapter"; +export { buildLoopbackClientId } from "./util"; diff --git a/packages/oauth-client-browser/src/util.ts b/packages/oauth-client-browser/src/util.ts new file mode 100644 index 0000000..a98d455 --- /dev/null +++ b/packages/oauth-client-browser/src/util.ts @@ -0,0 +1,19 @@ +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +export function buildLoopbackClientId( + location: { hostname: string; pathname: string; port: string }, + localhost = "127.0.0.1", +): string { + if (!LOOPBACK_HOSTS.has(location.hostname)) { + throw new TypeError( + `Expected a loopback hostname, got "${location.hostname}"`, + ); + } + + const port = location.port ? `:${location.port}` : ""; + const redirectUri = `http://${localhost}${port}${location.pathname}`; + + const pathname = location.pathname === "/" ? "" : location.pathname; + const encodedRedirect = encodeURIComponent(redirectUri); + return `http://localhost${pathname}?redirect_uri=${encodedRedirect}`; +} diff --git a/packages/oauth-client-browser/tsconfig.json b/packages/oauth-client-browser/tsconfig.json index 51fb261..40083db 100644 --- a/packages/oauth-client-browser/tsconfig.json +++ b/packages/oauth-client-browser/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "./dist", "rootDir": "./src", - "lib": ["ES2022", "DOM", "DOM.Iterable"] + "lib": ["ES2022", "ESNext.Disposable", "DOM", "DOM.Iterable"] }, "include": ["src"], "exclude": ["src/__tests__"] diff --git a/packages/oauth-client/src/client.ts b/packages/oauth-client/src/client.ts index 29f98f2..e163783 100644 --- a/packages/oauth-client/src/client.ts +++ b/packages/oauth-client/src/client.ts @@ -9,6 +9,7 @@ import type { ProvisionKeyResponse, RegisterSessionParams, RegisterSessionResponse, + SessionEventHooks, StorageAdapter, StoredSession, } from "./types"; @@ -16,12 +17,38 @@ import type { const STORAGE_PREFIX = "happyview:session:"; export const LAST_ACTIVE_KEY = "happyview:last-active-did"; +export interface FetchMetadataOptions { + clientId: string; + fetch?: typeof globalThis.fetch; + signal?: AbortSignal; +} + export class HappyViewOAuthClient { + static async fetchMetadata({ + clientId, + fetch: fetchFn = globalThis.fetch, + signal, + }: FetchMetadataOptions): Promise> { + signal?.throwIfAborted(); + const resp = await fetchFn(clientId, { redirect: "error", signal }); + if (resp.status !== 200) { + resp.body?.cancel?.(); + throw new Error(`Failed to fetch client metadata: ${resp.status}`); + } + const mime = resp.headers.get("content-type")?.split(";")[0].trim(); + if (mime !== "application/json") { + resp.body?.cancel?.(); + throw new Error(`Invalid client metadata content type: ${mime}`); + } + return resp.json() as Promise>; + } + protected readonly instanceUrl: string; protected readonly clientKey: string; protected readonly storage: StorageAdapter; private readonly clientSecret: string | undefined; protected readonly _fetch: typeof globalThis.fetch; + private readonly sessionHooks: SessionEventHooks; constructor( options: HappyViewOAuthClientOptions & { @@ -33,6 +60,7 @@ export class HappyViewOAuthClient { this.clientSecret = options.clientSecret; this.storage = options.storage ?? new MemoryStorage(); this._fetch = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch; + this.sessionHooks = options.sessionHooks ?? {}; } get isConfidential(): boolean { @@ -136,7 +164,9 @@ export class HappyViewOAuthClient { accessToken: params.accessToken, clientKey: this.clientKey, instanceUrl: this.instanceUrl, - scopes: data.scopes, + scopes: params.scopes, + pdsUrl: params.pdsUrl, + issuer: params.issuer, }; await this.storage.set( `${STORAGE_PREFIX}${data.did}`, @@ -144,15 +174,22 @@ export class HappyViewOAuthClient { ); await this.storage.set(LAST_ACTIVE_KEY, data.did); - return new HappyViewSession({ + const session = new HappyViewSession({ did: data.did, dpopKey, accessToken: params.accessToken, clientKey: this.clientKey, instanceUrl: this.instanceUrl, - scopes: data.scopes, + scopes: params.scopes, + pdsUrl: params.pdsUrl, + issuer: params.issuer, fetch: this._fetch, + onSignOut: () => this.deleteSession(data.did), }); + + this.sessionHooks.onSessionUpdate?.(data.did); + + return session; } async deleteSession(did: string): Promise { @@ -179,6 +216,8 @@ export class HappyViewOAuthClient { if (lastActive === did) { await this.storage.delete(LAST_ACTIVE_KEY); } + + this.sessionHooks.onSessionDelete?.(did); } async restoreSession(did: string): Promise { @@ -194,7 +233,10 @@ export class HappyViewOAuthClient { clientKey: data.clientKey, instanceUrl: data.instanceUrl, scopes: data.scopes ?? [], + pdsUrl: data.pdsUrl, + issuer: data.issuer, fetch: this._fetch, + onSignOut: () => this.deleteSession(data.did), }); } diff --git a/packages/oauth-client/src/errors.ts b/packages/oauth-client/src/errors.ts index e78b83e..2a69e26 100644 --- a/packages/oauth-client/src/errors.ts +++ b/packages/oauth-client/src/errors.ts @@ -52,3 +52,37 @@ export class ResolutionError extends HappyViewError { this.name = "ResolutionError"; } } + +export class OAuthCallbackError extends HappyViewError { + readonly params: URLSearchParams; + readonly state: string | undefined; + + static from( + err: unknown, + params: URLSearchParams, + state?: string, + ): OAuthCallbackError { + if (err instanceof OAuthCallbackError) return err; + const message = err instanceof Error ? err.message : undefined; + return new OAuthCallbackError(params, message, state, err); + } + + constructor( + params: URLSearchParams, + message?: string, + state?: string, + cause?: unknown, + ) { + super( + message ?? + params.get("error_description") ?? + "OAuth callback error", + ); + this.name = "OAuthCallbackError"; + this.params = params; + this.state = state; + if (cause !== undefined) { + this.cause = cause; + } + } +} diff --git a/packages/oauth-client/src/index.ts b/packages/oauth-client/src/index.ts index eaa7592..4263eb1 100644 --- a/packages/oauth-client/src/index.ts +++ b/packages/oauth-client/src/index.ts @@ -1,10 +1,15 @@ +export * from "@atproto/jwk"; +export * from "@atproto/jwk-webcrypto"; + export { HappyViewOAuthClient, LAST_ACTIVE_KEY } from "./client"; +export type { FetchMetadataOptions } from "./client"; export { importJwk } from "./import-jwk"; export { ApiError, AuthenticationError, HappyViewError, InvalidStateError, + OAuthCallbackError, ResolutionError, TokenExchangeError, } from "./errors"; @@ -18,6 +23,8 @@ export type { ProvisionKeyResponse, RegisterSessionParams, RegisterSessionResponse, + SessionEventHooks, StorageAdapter, StoredSession, + TokenInfo, } from "./types"; diff --git a/packages/oauth-client/src/session.ts b/packages/oauth-client/src/session.ts index f7b5033..744d8bb 100644 --- a/packages/oauth-client/src/session.ts +++ b/packages/oauth-client/src/session.ts @@ -1,4 +1,5 @@ import type { Key } from "@atproto/jwk"; +import type { TokenInfo } from "./types"; export interface HappyViewSessionOptions { did: string; @@ -6,8 +7,11 @@ export interface HappyViewSessionOptions { accessToken: string; clientKey: string; instanceUrl: string; - scopes: string[]; + scopes?: string; + pdsUrl?: string; + issuer?: string; fetch?: typeof globalThis.fetch; + onSignOut?: () => Promise; } function randomHex(byteLength: number): string { @@ -35,7 +39,11 @@ export class HappyViewSession { private readonly accessToken: string; private readonly clientKey: string; private readonly instanceUrl: string; + private readonly _scopes: string | undefined; + private readonly _pdsUrl: string | undefined; + private readonly _issuer: string | undefined; private readonly _fetch: typeof globalThis.fetch; + private readonly _onSignOut: (() => Promise) | undefined; private dpopNonce: string | undefined; constructor(options: HappyViewSessionOptions) { @@ -45,7 +53,30 @@ export class HappyViewSession { this.accessToken = options.accessToken; this.clientKey = options.clientKey; this.instanceUrl = options.instanceUrl.replace(/\/+$/, ""); + this._scopes = options.scopes; + this._pdsUrl = options.pdsUrl; + this._issuer = options.issuer; this._fetch = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch; + this._onSignOut = options.onSignOut; + } + + get sub(): string { + return this.did; + } + + getTokenInfo(): TokenInfo { + return { + scope: this._scopes, + iss: this._issuer, + aud: this._pdsUrl, + sub: this.did, + }; + } + + async signOut(): Promise { + if (this._onSignOut) { + await this._onSignOut(); + } } async fetchHandler(url: string, init: RequestInit): Promise { diff --git a/packages/oauth-client/src/types.ts b/packages/oauth-client/src/types.ts index f611f44..c893969 100644 --- a/packages/oauth-client/src/types.ts +++ b/packages/oauth-client/src/types.ts @@ -4,11 +4,17 @@ export interface StorageAdapter { delete(key: string): Promise; } +export interface SessionEventHooks { + onSessionUpdate?: (did: string) => void; + onSessionDelete?: (did: string, cause?: unknown) => void; +} + export interface HappyViewOAuthClientOptions { instanceUrl: string; clientKey: string; clientSecret?: string; storage?: StorageAdapter; + sessionHooks?: SessionEventHooks; } export interface DpopProvision { @@ -40,7 +46,18 @@ export interface StoredSession { accessToken: string; clientKey: string; instanceUrl: string; - scopes: string[]; + scopes?: string; + pdsUrl?: string; + issuer?: string; +} + +export interface TokenInfo { + expiresAt?: Date; + expired?: boolean; + scope?: string; + iss?: string; + aud?: string; + sub: string; } export interface ProvisionKeyResponse { -- 2.51.2 From a23efe6c9432bebef1caf5b0cea2754f9bd4b0c4 Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 7 May 2026 08:56:26 -0500 Subject: [PATCH 03/56] feat: allow the base path to be configured Signed-off-by: Trezy --- .../docs/getting-started/configuration.md | 4 +- .../getting-started/production-deployment.md | 48 ++++ src/auth/routes.rs | 17 +- src/config.rs | 234 ++++++++++++++++++ src/external_auth/routes.rs | 4 +- src/lua/atproto_api.rs | 1 + src/lua/db_api.rs | 1 + src/lua/execute.rs | 1 + src/lua/http_api.rs | 1 + src/lua/xrpc_api.rs | 1 + src/main.rs | 17 +- src/server.rs | 26 +- tests/common/app.rs | 1 + tests/lua_atproto_api.rs | 1 + tests/lua_db_api.rs | 1 + web/next.config.ts | 2 + web/src/app/dashboard/about/page.tsx | 2 +- web/src/lib/api.ts | 8 +- web/src/lib/auth-context.tsx | 6 +- web/src/lib/config-context.tsx | 2 +- 20 files changed, 351 insertions(+), 27 deletions(-) diff --git a/packages/docs/docs/getting-started/configuration.md b/packages/docs/docs/getting-started/configuration.md index 34a809f..0c7f530 100644 --- a/packages/docs/docs/getting-started/configuration.md +++ b/packages/docs/docs/getting-started/configuration.md @@ -8,7 +8,8 @@ HappyView is configured via environment variables. A `.env` file in the project |----------|----------|---------|-------------| | `DATABASE_URL` | yes | --- | Database connection string. SQLite (`sqlite://path/to/db?mode=rwc`) or Postgres (`postgres://user:pass@host/db`) | | `DATABASE_BACKEND` | no | auto-detected | Force `sqlite` or `postgres`. Auto-detected from `DATABASE_URL` scheme if not set | -| `PUBLIC_URL` | yes | --- | Public-facing URL for HappyView (used for OAuth callbacks, e.g. `https://happyview.example.com`). **For local development, use `http://127.0.0.1:3000` — not `localhost`** (see note below) | +| `PUBLIC_URL` | yes | --- | Public-facing URL for HappyView (used for OAuth callbacks, e.g. `https://happyview.example.com`). **For local development, use `http://127.0.0.1:3000` — not `localhost`** (see note below). Do **not** include the base path — see `BASE_PATH` | +| `BASE_PATH` | no | _(none)_ | Subpath prefix for mounting HappyView behind a reverse proxy (e.g. `/hv`). Must start with `/` and have no trailing slash. When set, all routes are served under this prefix and the dashboard is accessible at `https://example.com/hv/`. See [Reverse proxy subpath](production-deployment.md#reverse-proxy-subpath) | | `SESSION_SECRET` | no | dev default | Secret key for signing session cookies (at least 64 characters). **Must be set in production** | | `HOST` | no | `0.0.0.0` | Bind host | | `PORT` | no | `3000` | Bind port | @@ -45,6 +46,7 @@ SESSION_SECRET=change-me-in-production # DATABASE_URL=postgres://happyview:happyview@localhost/happyview # Optional overrides +# BASE_PATH=/hv # HOST=0.0.0.0 # PORT=3000 # JETSTREAM_URL=wss://jetstream1.us-east.bsky.network diff --git a/packages/docs/docs/getting-started/production-deployment.md b/packages/docs/docs/getting-started/production-deployment.md index 6c07f4e..0a7b3c1 100644 --- a/packages/docs/docs/getting-started/production-deployment.md +++ b/packages/docs/docs/getting-started/production-deployment.md @@ -32,6 +32,54 @@ PUBLIC_URL=https://happyview.example.com `PUBLIC_URL` is used to construct OAuth redirect URIs, so it must exactly match the URL users hit — including scheme. A mismatch breaks OAuth login. +## Reverse proxy subpath + +If you need HappyView to share a domain with other services, set `BASE_PATH` to mount it at a subpath. For example, to serve the dashboard at `https://example.com/hv/`: + +```sh +PUBLIC_URL=https://example.com +BASE_PATH=/hv +``` + +`PUBLIC_URL` should **not** include the base path — HappyView appends it automatically when constructing OAuth callbacks and other external URLs. + +The base path is applied at container startup without rebuilding the image, so prebuilt Docker images (including Railway deployments) work with any subpath. + +### XRPC endpoints + +ATProto clients expect XRPC endpoints at `/xrpc/*` on the domain root. When using a base path, configure your reverse proxy to rewrite `/xrpc/*` requests so they reach HappyView under its base path. Here's an example using Caddy: + +``` +example.com { + # Dashboard and API under the base path + handle /hv/* { + reverse_proxy happyview:3000 + } + + # Redirect bare /hv to /hv/ for cleaner URLs + handle /hv { + redir /hv/ permanent + } + + # ATProto XRPC — rewrite to base path before proxying + handle /xrpc/* { + uri prefix /hv + reverse_proxy happyview:3000 + } + + # Everything else goes to another service + handle { + reverse_proxy other-app:3001 + } +} +``` + +The `uri prefix /hv` directive prepends `/hv` to the request path before proxying, so `/xrpc/com.atproto.sync.getRecord` becomes `/hv/xrpc/com.atproto.sync.getRecord` — which matches HappyView's nested routes. + +### Health checks with a base path + +`GET /health` is always served at the domain root, even when `BASE_PATH` is set. This keeps load balancer probes working without routing changes. + ## Database SQLite is fine for small to medium instances and is the default. Switch to Postgres if you need: diff --git a/src/auth/routes.rs b/src/auth/routes.rs index 0d08274..019d773 100644 --- a/src/auth/routes.rs +++ b/src/auth/routes.rs @@ -229,7 +229,13 @@ async fn callback( .map_err(|e| AppError::Internal(format!("user lookup failed: {e}")))?; if user_exists.is_none() { - return Ok((jar, Redirect::to("/login?error=not_authorized"))); + let login_url = state + .config + .base_path + .as_ref() + .map(|bp| format!("{}/login?error=not_authorized", bp)) + .unwrap_or_else(|| "/login?error=not_authorized".into()); + return Ok((jar, Redirect::to(&login_url))); } } @@ -250,8 +256,13 @@ async fn callback( None }; - // Use DB-stored redirect, or default to "/" - let redirect_url = redirect_url.unwrap_or_else(|| "/".into()); + let default_redirect = state + .config + .base_path + .as_ref() + .map(|bp| format!("{}/", bp)) + .unwrap_or_else(|| "/".into()); + let redirect_url = redirect_url.unwrap_or(default_redirect); tracing::debug!(redirect_url = %redirect_url, "redirecting after callback"); // Set the session cookie diff --git a/src/config.rs b/src/config.rs index a1e2f8e..cabd5d2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,6 +15,7 @@ pub struct Config { pub relay_url: String, pub plc_url: String, pub static_dir: String, + pub base_path: Option, pub event_log_retention_days: u32, pub app_name: Option, pub logo_uri: Option, @@ -49,6 +50,16 @@ impl Config { relay_url: env::var("RELAY_URL").unwrap_or_else(|_| "https://bsky.network".into()), plc_url: env::var("PLC_URL").unwrap_or_else(|_| "https://plc.directory".into()), static_dir: env::var("STATIC_DIR").unwrap_or_else(|_| "./web/out".into()), + base_path: env::var("BASE_PATH").ok().and_then(|s| { + let s = s.trim_end_matches('/').to_string(); + if s.is_empty() { + None + } else if !s.starts_with('/') { + panic!("BASE_PATH must start with '/' (got: {s})"); + } else { + Some(s) + } + }), event_log_retention_days: std::env::var("EVENT_LOG_RETENTION_DAYS") .ok() .and_then(|v| v.parse().ok()) @@ -80,6 +91,20 @@ impl Config { .parse() .expect("invalid HOST/PORT") } + + pub fn effective_public_url(&self) -> String { + match &self.base_path { + Some(bp) => format!("{}{}", self.public_url.trim_end_matches('/'), bp), + None => self.public_url.clone(), + } + } + + pub fn url_with_base_path(&self, domain_url: &str) -> String { + match &self.base_path { + Some(bp) => format!("{}{}", domain_url.trim_end_matches('/'), bp), + None => domain_url.to_string(), + } + } } #[cfg(test)] @@ -103,6 +128,7 @@ mod tests { "LOGO_URI", "TOS_URI", "POLICY_URI", + "BASE_PATH", ] { unsafe { env::remove_var(key); @@ -130,6 +156,7 @@ mod tests { relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, @@ -286,4 +313,211 @@ mod tests { let config = Config::from_env(); assert_eq!(config.database_backend, DatabaseBackend::Sqlite); } + + #[test] + #[serial] + fn from_env_base_path_none_by_default() { + unsafe { + clear_env(); + set_required_env(); + } + let config = Config::from_env(); + assert!(config.base_path.is_none()); + } + + #[test] + #[serial] + fn from_env_base_path_read_from_env() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", "/hv"); + } + let config = Config::from_env(); + assert_eq!(config.base_path.as_deref(), Some("/hv")); + } + + #[test] + #[serial] + fn from_env_base_path_strips_trailing_slash() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", "/hv/"); + } + let config = Config::from_env(); + assert_eq!(config.base_path.as_deref(), Some("/hv")); + } + + #[test] + #[serial] + fn from_env_base_path_empty_becomes_none() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", ""); + } + let config = Config::from_env(); + assert!(config.base_path.is_none()); + } + + #[test] + #[serial] + fn from_env_base_path_slash_only_becomes_none() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", "/"); + } + let config = Config::from_env(); + assert!(config.base_path.is_none()); + } + + #[test] + #[serial] + #[should_panic(expected = "BASE_PATH must start with '/'")] + fn from_env_base_path_without_leading_slash_panics() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", "hv"); + } + Config::from_env(); + } + + #[test] + fn effective_public_url_without_base_path() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: "https://example.com".into(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: None, + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!(config.effective_public_url(), "https://example.com"); + } + + #[test] + fn effective_public_url_with_base_path() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: "https://example.com".into(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: Some("/hv".into()), + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!(config.effective_public_url(), "https://example.com/hv"); + } + + #[test] + fn effective_public_url_trims_trailing_slash() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: "https://example.com/".into(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: Some("/hv".into()), + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!(config.effective_public_url(), "https://example.com/hv"); + } + + #[test] + fn url_with_base_path_appends() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: String::new(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: Some("/hv".into()), + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!( + config.url_with_base_path("https://otherdomain.com"), + "https://otherdomain.com/hv" + ); + } + + #[test] + fn url_with_base_path_without_base_path() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: String::new(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: None, + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!( + config.url_with_base_path("https://otherdomain.com"), + "https://otherdomain.com" + ); + } } diff --git a/src/external_auth/routes.rs b/src/external_auth/routes.rs index d5d9fcd..36f406c 100644 --- a/src/external_auth/routes.rs +++ b/src/external_auth/routes.rs @@ -130,8 +130,8 @@ async fn authorize( // Build the backend callback URL for OpenID/OAuth return_to // This ensures the auth provider redirects back to the backend, not the frontend let domain_url = domain - .map(|d| d.0.url.clone()) - .unwrap_or_else(|| app_state.config.public_url.clone()); + .map(|d| app_state.config.url_with_base_path(&d.0.url)) + .unwrap_or_else(|| app_state.config.effective_public_url()); let callback_url = format!( "{}/external-auth/{}/callback", diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs index bc1c614..bde9321 100644 --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -492,6 +492,7 @@ mod tests { relay_url: String::new(), plc_url: plc_url.to_string(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs index 41e68bf..e2fb031 100644 --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -632,6 +632,7 @@ mod tests { relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/lua/execute.rs b/src/lua/execute.rs index c1548ab..c2c0c0c 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -1089,6 +1089,7 @@ mod tests { relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/lua/http_api.rs b/src/lua/http_api.rs index 5e5b5d6..39b7a22 100644 --- a/src/lua/http_api.rs +++ b/src/lua/http_api.rs @@ -98,6 +98,7 @@ mod tests { relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/lua/xrpc_api.rs b/src/lua/xrpc_api.rs index 0b78ae6..308be59 100644 --- a/src/lua/xrpc_api.rs +++ b/src/lua/xrpc_api.rs @@ -202,6 +202,7 @@ mod tests { relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/main.rs b/src/main.rs index 079e61b..8145cf4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -411,7 +411,10 @@ async fn main() { // Build atrium-oauth client let dns = NativeDnsResolver::new(); - let callback_url = format!("{}/auth/callback", config.public_url.trim_end_matches('/')); + let callback_url = format!( + "{}/auth/callback", + config.effective_public_url().trim_end_matches('/') + ); let atrium_http = Arc::new(DefaultHttpClient::default()); let did_resolver = CommonDidResolver::new(CommonDidResolverConfig { @@ -459,9 +462,9 @@ async fn main() { client_metadata: AtprotoClientMetadata { client_id: format!( "{}/oauth-client-metadata.json", - config.public_url.trim_end_matches('/') + config.effective_public_url().trim_end_matches('/') ), - client_uri: Some(config.public_url.clone()), + client_uri: Some(config.effective_public_url()), redirect_uris: vec![callback_url], token_endpoint_auth_method: AuthMethod::None, grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], @@ -526,10 +529,12 @@ async fn main() { continue; // Already registered above } - let domain_callback_url = format!("{}/auth/callback", domain.url.trim_end_matches('/')); + let domain_base_url = config.url_with_base_path(&domain.url); + let domain_callback_url = + format!("{}/auth/callback", domain_base_url.trim_end_matches('/')); let domain_client_id = format!( "{}/oauth-client-metadata.json", - domain.url.trim_end_matches('/') + domain_base_url.trim_end_matches('/') ); let domain_http = Arc::new(DefaultHttpClient::default()); @@ -549,7 +554,7 @@ async fn main() { match atrium_oauth::OAuthClient::new(OAuthClientConfig { client_metadata: AtprotoClientMetadata { client_id: domain_client_id, - client_uri: Some(domain.url.clone()), + client_uri: Some(domain_base_url.clone()), redirect_uris: vec![domain_callback_url], token_endpoint_auth_method: AuthMethod::None, grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], diff --git a/src/server.rs b/src/server.rs index 11e470b..5699604 100644 --- a/src/server.rs +++ b/src/server.rs @@ -132,11 +132,22 @@ pub fn router(state: AppState) -> Router { resolve_domain, )); - Router::new() - .route("/health", get(health)) + let app_routes = Router::new() .nest("/admin", admin::admin_routes(state.clone())) .merge(domain_routes) - .fallback_service(serve_dir) + .fallback_service(serve_dir); + + let outer = if let Some(ref base_path) = state.config.base_path { + Router::new() + .route("/health", get(health)) + .nest(base_path, app_routes) + } else { + Router::new() + .route("/health", get(health)) + .merge(app_routes) + }; + + outer .layer(TraceLayer::new_for_http()) .layer( CorsLayer::new() @@ -166,8 +177,8 @@ async fn config_endpoint( req: axum::extract::Request, ) -> Json { let domain_url = crate::domain_middleware::extract_domain(&req) - .map(|d| d.url.clone()) - .unwrap_or_else(|| state.config.public_url.clone()); + .map(|d| state.config.url_with_base_path(&d.url)) + .unwrap_or_else(|| state.config.effective_public_url()); let pool = &state.db; let backend = state.db_backend; @@ -223,11 +234,12 @@ async fn client_metadata( State(state): State, req: axum::extract::Request, ) -> Json { - let domain_url = crate::domain_middleware::extract_domain(&req) + let raw_domain_url = crate::domain_middleware::extract_domain(&req) .map(|d| d.url.clone()) .unwrap_or_else(|| state.config.public_url.clone()); + let domain_url = state.config.url_with_base_path(&raw_domain_url); - let oauth_client = state.oauth.get_for_domain(&domain_url); + let oauth_client = state.oauth.get_for_domain(&raw_domain_url); let mut metadata = serde_json::to_value(&oauth_client.client_metadata).unwrap_or_default(); // The `client_id` field in the response must exactly match the URL the diff --git a/tests/common/app.rs b/tests/common/app.rs index c749a77..4b82744 100644 --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -54,6 +54,7 @@ impl TestApp { relay_url: mock_url.clone(), plc_url: mock_url.clone(), static_dir: "./web/out".into(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/tests/lua_atproto_api.rs b/tests/lua_atproto_api.rs index 3302244..94a5b64 100644 --- a/tests/lua_atproto_api.rs +++ b/tests/lua_atproto_api.rs @@ -27,6 +27,7 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs index ada3107..0e2ce49 100644 --- a/tests/lua_db_api.rs +++ b/tests/lua_db_api.rs @@ -30,6 +30,7 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/web/next.config.ts b/web/next.config.ts index 93e2742..7e073de 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,11 +1,13 @@ import type { NextConfig } from "next"; const apiBase = process.env.API_URL || "http://localhost:3000"; +const basePath = process.env.NEXT_PUBLIC_BASE_PATH || undefined; const nextConfig: NextConfig = { reactCompiler: true, trailingSlash: true, images: { unoptimized: true }, + basePath, }; if (process.env.NODE_ENV === "production") { diff --git a/web/src/app/dashboard/about/page.tsx b/web/src/app/dashboard/about/page.tsx index daa4728..614253f 100644 --- a/web/src/app/dashboard/about/page.tsx +++ b/web/src/app/dashboard/about/page.tsx @@ -25,7 +25,7 @@ export default function AboutPage() { const [error, setError] = useState(null) useEffect(() => { - fetch("/config", { credentials: "same-origin" }) + fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/config`, { credentials: "same-origin" }) .then((r) => { if (!r.ok) throw new Error("Failed to load config") return r.json() diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a6ce249..f5fab6a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -57,6 +57,8 @@ export type { BulkActionResponse, } from "@/types/dead-letters" +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || "" + export class ApiError extends Error { status: number constructor(status: number, message: string) { @@ -78,7 +80,7 @@ async function apiFetch( headers["Content-Type"] = "application/json" } - const res = await fetch(path, { + const res = await fetch(`${BASE_PATH}${path}`, { ...options, headers: { ...headers, ...options?.headers }, credentials: "same-origin", @@ -239,7 +241,7 @@ export async function xrpcQuery( params?: Record ): Promise { const search = params ? `?${new URLSearchParams(params)}` : "" - const res = await fetch(`/xrpc/${encodeURIComponent(method)}${search}`) + const res = await fetch(`${BASE_PATH}/xrpc/${encodeURIComponent(method)}${search}`) if (!res.ok) { const text = await res.text().catch(() => res.statusText) throw new ApiError(res.status, text) @@ -323,7 +325,7 @@ export function deleteSetting(key: string) { export async function uploadLogo(file: File) { const formData = new FormData() formData.append("file", file) - const res = await fetch("/admin/settings/logo", { + const res = await fetch(`${BASE_PATH}/admin/settings/logo`, { method: "PUT", body: formData, credentials: "same-origin", diff --git a/web/src/lib/auth-context.tsx b/web/src/lib/auth-context.tsx index d8a1379..a817b45 100644 --- a/web/src/lib/auth-context.tsx +++ b/web/src/lib/auth-context.tsx @@ -41,7 +41,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { async function init() { try { // Check if the user has a valid session cookie - const resp = await fetch("/auth/me", { credentials: "same-origin" }) + const resp = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/auth/me`, { credentials: "same-origin" }) if (resp.ok) { const data = await resp.json() if (!cancelled && data.did) { @@ -67,7 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const login = useCallback(async (handle: string) => { setError(null) - const resp = await fetch(`/auth/login?handle=${encodeURIComponent(handle)}`, { + const resp = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/auth/login?handle=${encodeURIComponent(handle)}`, { credentials: "same-origin", }) @@ -83,7 +83,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const logout = useCallback(async () => { try { - await fetch("/auth/logout", { + await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/auth/logout`, { method: "POST", credentials: "same-origin", }) diff --git a/web/src/lib/config-context.tsx b/web/src/lib/config-context.tsx index db6a35b..875627e 100644 --- a/web/src/lib/config-context.tsx +++ b/web/src/lib/config-context.tsx @@ -23,7 +23,7 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { const [error, setError] = useState(null) useEffect(() => { - fetch("/config") + fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/config`) .then((res) => { if (!res.ok) throw new Error(`Config fetch failed: ${res.status}`) return res.json() -- 2.51.2 From 3a57317e62ccf6a18c21f5eea336ea77699c2445 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 09:54:11 -0500 Subject: [PATCH 04/56] feat(xrpc-proxy): add settings to control the XRPC proxy Signed-off-by: Trezy --- web/src/lib/api.ts | 555 ++++++++++++++++++++++----------------------- 1 file changed, 268 insertions(+), 287 deletions(-) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f5fab6a..f05a348 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,15 +1,18 @@ -import type { ApiKeySummary, CreateApiKeyResponse } from "@/types/api-keys" -import type { StatsResponse } from "@/types/stats" -import type { LexiconSummary, LexiconDetail } from "@/types/lexicons" -import type { NetworkLexiconSummary } from "@/types/network-lexicons" -import type { BackfillJob } from "@/types/backfill" -import type { UserSummary } from "@/types/users" -import type { AdminListRecordsResponse } from "@/types/records" -import type { EventsListResponse } from "@/types/events" -import type { ScriptVariableSummary } from "@/types/script-variables" -import type { LabelerSummary } from "@/types/labelers" -import type { ApiClientSummary, CreateApiClientResponse } from "@/types/api-clients" -import type { SettingEntry } from "@/types/settings" +import type { ApiKeySummary, CreateApiKeyResponse } from "@/types/api-keys"; +import type { StatsResponse } from "@/types/stats"; +import type { LexiconSummary, LexiconDetail } from "@/types/lexicons"; +import type { NetworkLexiconSummary } from "@/types/network-lexicons"; +import type { BackfillJob } from "@/types/backfill"; +import type { UserSummary } from "@/types/users"; +import type { AdminListRecordsResponse } from "@/types/records"; +import type { EventsListResponse } from "@/types/events"; +import type { ScriptVariableSummary } from "@/types/script-variables"; +import type { LabelerSummary } from "@/types/labelers"; +import type { + ApiClientSummary, + CreateApiClientResponse, +} from "@/types/api-clients"; +import type { SettingEntry } from "@/types/settings"; import type { ExternalProvider, LinkedAccount, @@ -17,28 +20,31 @@ import type { SyncResponse, UnlinkResponse, ConnectResponse, -} from "@/types/external-accounts" +} from "@/types/external-accounts"; import type { DeadLettersListResponse, DeadLetterDetail, DeadLetterCountResponse, BulkActionResponse, -} from "@/types/dead-letters" - -export type { ApiKeySummary, CreateApiKeyResponse } from "@/types/api-keys" -export type { CollectionStat, StatsResponse } from "@/types/stats" -export type { LexiconSummary, LexiconDetail } from "@/types/lexicons" -export type { NetworkLexiconSummary } from "@/types/network-lexicons" -export type { BackfillJob } from "@/types/backfill" -export type { UserSummary } from "@/types/users" -export type { AdminRecord, AdminListRecordsResponse } from "@/types/records" -export type { EventLogEntry, EventsListResponse } from "@/types/events" -export type { ScriptVariableSummary } from "@/types/script-variables" -export type { LabelerSummary } from "@/types/labelers" -export type { RecordLabel } from "@/types/records" -export type { ApiClientSummary, CreateApiClientResponse } from "@/types/api-clients" -export type { SettingEntry, InstanceSettings } from "@/types/settings" -export { INSTANCE_SETTING_KEYS } from "@/types/settings" +} from "@/types/dead-letters"; + +export type { ApiKeySummary, CreateApiKeyResponse } from "@/types/api-keys"; +export type { CollectionStat, StatsResponse } from "@/types/stats"; +export type { LexiconSummary, LexiconDetail } from "@/types/lexicons"; +export type { NetworkLexiconSummary } from "@/types/network-lexicons"; +export type { BackfillJob } from "@/types/backfill"; +export type { UserSummary } from "@/types/users"; +export type { AdminRecord, AdminListRecordsResponse } from "@/types/records"; +export type { EventLogEntry, EventsListResponse } from "@/types/events"; +export type { ScriptVariableSummary } from "@/types/script-variables"; +export type { LabelerSummary } from "@/types/labelers"; +export type { RecordLabel } from "@/types/records"; +export type { + ApiClientSummary, + CreateApiClientResponse, +} from "@/types/api-clients"; +export type { SettingEntry, InstanceSettings } from "@/types/settings"; +export { INSTANCE_SETTING_KEYS } from "@/types/settings"; export type { ExternalProvider, LinkedAccount, @@ -48,22 +54,22 @@ export type { ConnectResponse, ConfigSchema, ConfigProperty, -} from "@/types/external-accounts" +} from "@/types/external-accounts"; export type { DeadLetterSummary, DeadLetterDetail, DeadLettersListResponse, DeadLetterCountResponse, BulkActionResponse, -} from "@/types/dead-letters" +} from "@/types/dead-letters"; -const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || "" +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || ""; export class ApiError extends Error { - status: number + status: number; constructor(status: number, message: string) { - super(message) - this.status = status + super(message); + this.status = status; } } @@ -71,453 +77,425 @@ async function apiFetch( path: string, options?: RequestInit, ): Promise { - const headers: Record = {} + const headers: Record = {}; if ( options?.method === "POST" || options?.method === "PUT" || options?.method === "PATCH" ) { - headers["Content-Type"] = "application/json" + headers["Content-Type"] = "application/json"; } const res = await fetch(`${BASE_PATH}${path}`, { ...options, headers: { ...headers, ...options?.headers }, credentials: "same-origin", - }) + }); if (!res.ok) { - const text = await res.text().catch(() => res.statusText) - throw new ApiError(res.status, text) + const text = await res.text().catch(() => res.statusText); + throw new ApiError(res.status, text); } - if (res.status === 204) return null as T - const text = await res.text() - if (!text) return null as T - return JSON.parse(text) + if (res.status === 204) return null as T; + const text = await res.text(); + if (!text) return null as T; + return JSON.parse(text); } // Stats export function getStats() { - return apiFetch("/admin/stats") + return apiFetch("/admin/stats"); } // Lexicons export function getLexicons() { - return apiFetch("/admin/lexicons") + return apiFetch("/admin/lexicons"); } export function getLexicon(id: string) { - return apiFetch( - `/admin/lexicons/${encodeURIComponent(id)}`, - ) + return apiFetch(`/admin/lexicons/${encodeURIComponent(id)}`); } -export function uploadLexicon( - body: { - lexicon_json: unknown - backfill?: boolean - target_collection?: string - action?: string - script?: string - index_hook?: string - token_cost?: number | null - } -) { +export function uploadLexicon(body: { + lexicon_json: unknown; + backfill?: boolean; + target_collection?: string; + action?: string; + script?: string; + index_hook?: string; + token_cost?: number | null; +}) { return apiFetch<{ id: string; revision: number }>("/admin/lexicons", { method: "POST", body: JSON.stringify(body), - }) + }); } export function deleteLexicon(id: string) { return apiFetch(`/admin/lexicons/${encodeURIComponent(id)}`, { method: "DELETE", - }) + }); } // Network Lexicons export function getNetworkLexicons() { - return apiFetch("/admin/network-lexicons") + return apiFetch("/admin/network-lexicons"); } -export function addNetworkLexicon( - body: { nsid: string; target_collection?: string } -) { +export function addNetworkLexicon(body: { + nsid: string; + target_collection?: string; +}) { return apiFetch<{ nsid: string; authority_did: string; revision: number }>( "/admin/network-lexicons", - { method: "POST", body: JSON.stringify(body) } - ) + { method: "POST", body: JSON.stringify(body) }, + ); } -export function deleteNetworkLexicon( - nsid: string -) { - return apiFetch( - `/admin/network-lexicons/${encodeURIComponent(nsid)}`, - { method: "DELETE" } - ) +export function deleteNetworkLexicon(nsid: string) { + return apiFetch(`/admin/network-lexicons/${encodeURIComponent(nsid)}`, { + method: "DELETE", + }); } // Backfill export function getBackfillJobs() { - return apiFetch("/admin/backfill/status") + return apiFetch("/admin/backfill/status"); } -export function createBackfillJob( - body: { collection?: string; did?: string } -) { +export function createBackfillJob(body: { collection?: string; did?: string }) { return apiFetch<{ id: string; status: string }>("/admin/backfill", { method: "POST", body: JSON.stringify(body), - }) + }); } // Users export function getUsers() { - return apiFetch("/admin/users") + return apiFetch("/admin/users"); } export function getUser(id: string) { - return apiFetch(`/admin/users/${encodeURIComponent(id)}`) + return apiFetch(`/admin/users/${encodeURIComponent(id)}`); } -export function addUser( - body: { did: string; template?: string; permissions?: string[] } -) { +export function addUser(body: { + did: string; + template?: string; + permissions?: string[]; +}) { return apiFetch<{ id: string; did: string }>("/admin/users", { method: "POST", body: JSON.stringify(body), - }) + }); } export function deleteUser(id: string) { return apiFetch(`/admin/users/${encodeURIComponent(id)}`, { method: "DELETE", - }) + }); } export function updateUserPermissions( id: string, - body: { grant?: string[]; revoke?: string[] } + body: { grant?: string[]; revoke?: string[] }, ) { return apiFetch(`/admin/users/${encodeURIComponent(id)}/permissions`, { method: "PATCH", body: JSON.stringify(body), - }) + }); } -export function transferSuper( - body: { target_user_id: string } -) { +export function transferSuper(body: { target_user_id: string }) { return apiFetch("/admin/users/transfer-super", { method: "POST", body: JSON.stringify(body), - }) + }); } // API Keys export function getApiKeys() { - return apiFetch("/admin/api-keys") + return apiFetch("/admin/api-keys"); } -export function createApiKey( - body: { name: string; permissions: string[] } -) { +export function createApiKey(body: { name: string; permissions: string[] }) { return apiFetch("/admin/api-keys", { method: "POST", body: JSON.stringify(body), - }) + }); } export function revokeApiKey(id: string) { return apiFetch(`/admin/api-keys/${encodeURIComponent(id)}`, { method: "DELETE", - }) + }); } // XRPC (public, no auth needed) export async function xrpcQuery( method: string, - params?: Record + params?: Record, ): Promise { - const search = params ? `?${new URLSearchParams(params)}` : "" - const res = await fetch(`${BASE_PATH}/xrpc/${encodeURIComponent(method)}${search}`) + const search = params ? `?${new URLSearchParams(params)}` : ""; + const res = await fetch( + `${BASE_PATH}/xrpc/${encodeURIComponent(method)}${search}`, + ); if (!res.ok) { - const text = await res.text().catch(() => res.statusText) - throw new ApiError(res.status, text) + const text = await res.text().catch(() => res.statusText); + throw new ApiError(res.status, text); } - return res.json() + return res.json(); } // Admin records browsing export function getAdminRecords( collection: string, limit?: number, - cursor?: string + cursor?: string, ) { - const params = new URLSearchParams({ collection }) - if (limit) params.set("limit", String(limit)) - if (cursor) params.set("cursor", cursor) - return apiFetch( - `/admin/records?${params}`, - ) + const params = new URLSearchParams({ collection }); + if (limit) params.set("limit", String(limit)); + if (cursor) params.set("cursor", cursor); + return apiFetch(`/admin/records?${params}`); } -export function deleteRecord( - uri: string -) { - return apiFetch( - `/admin/records?${new URLSearchParams({ uri })}`, - { method: "DELETE" } - ) +export function deleteRecord(uri: string) { + return apiFetch(`/admin/records?${new URLSearchParams({ uri })}`, { + method: "DELETE", + }); } -export function deleteCollectionRecords( - collection: string, -) { +export function deleteCollectionRecords(collection: string) { return apiFetch<{ deleted: number }>( `/admin/records/collection?${new URLSearchParams({ collection })}`, { method: "DELETE" }, - ) + ); } // Script Variables export function getScriptVariables() { - return apiFetch("/admin/script-variables") + return apiFetch("/admin/script-variables"); } -export function upsertScriptVariable( - body: { key: string; value: string } -) { +export function upsertScriptVariable(body: { key: string; value: string }) { return apiFetch("/admin/script-variables", { method: "POST", body: JSON.stringify(body), - }) + }); } -export function deleteScriptVariable( - key: string -) { - return apiFetch( - `/admin/script-variables/${encodeURIComponent(key)}`, - { method: "DELETE" } - ) +export function deleteScriptVariable(key: string) { + return apiFetch(`/admin/script-variables/${encodeURIComponent(key)}`, { + method: "DELETE", + }); } // Settings export function getSettings() { - return apiFetch("/admin/settings") + return apiFetch("/admin/settings"); } export function upsertSetting(key: string, value: string) { return apiFetch(`/admin/settings/${encodeURIComponent(key)}`, { method: "PUT", body: JSON.stringify({ value }), - }) + }); } export function deleteSetting(key: string) { return apiFetch(`/admin/settings/${encodeURIComponent(key)}`, { method: "DELETE", - }) + }); } export async function uploadLogo(file: File) { - const formData = new FormData() - formData.append("file", file) + const formData = new FormData(); + formData.append("file", file); const res = await fetch(`${BASE_PATH}/admin/settings/logo`, { method: "PUT", body: formData, credentials: "same-origin", - }) + }); if (!res.ok) { - const text = await res.text().catch(() => res.statusText) - throw new ApiError(res.status, text) + const text = await res.text().catch(() => res.statusText); + throw new ApiError(res.status, text); } } export function deleteLogo() { - return apiFetch("/admin/settings/logo", { method: "DELETE" }) + return apiFetch("/admin/settings/logo", { method: "DELETE" }); } // Feature Flags export type FeatureFlag = { - key: string - name: string - description: string - enabled: boolean -} + key: string; + name: string; + description: string; + enabled: boolean; +}; export function getFeatureFlags() { - return apiFetch("/admin/feature-flags") + return apiFetch("/admin/feature-flags"); } export function setFeatureFlag(key: string, enabled: boolean) { if (enabled) { - return upsertSetting(key, "true") + return upsertSetting(key, "true"); } - return deleteSetting(key) + return deleteSetting(key); } // Proxy config export type ProxyConfig = { - mode: "disabled" | "open" | "allowlist" | "blocklist" - nsids: string[] -} + mode: "disabled" | "open" | "allowlist" | "blocklist"; + nsids: string[]; +}; export function getProxyConfig() { - return apiFetch("/admin/settings/xrpc-proxy") + return apiFetch("/admin/settings/xrpc-proxy"); } export function updateProxyConfig(config: ProxyConfig) { return apiFetch("/admin/settings/xrpc-proxy", { method: "PUT", body: JSON.stringify(config), - }) + }); } // Labelers export function getLabelers() { - return apiFetch("/admin/labelers") + return apiFetch("/admin/labelers"); } -export function addLabeler( - body: { did: string } -) { +export function addLabeler(body: { did: string }) { return apiFetch("/admin/labelers", { method: "POST", body: JSON.stringify(body), - }) + }); } -export function updateLabeler( - did: string, - body: { status: string } -) { +export function updateLabeler(did: string, body: { status: string }) { return apiFetch(`/admin/labelers/${encodeURIComponent(did)}`, { method: "PATCH", body: JSON.stringify(body), - }) + }); } -export function deleteLabeler( - did: string -) { +export function deleteLabeler(did: string) { return apiFetch(`/admin/labelers/${encodeURIComponent(did)}`, { method: "DELETE", - }) + }); } // API Clients export function getApiClients() { - return apiFetch("/admin/api-clients") + return apiFetch("/admin/api-clients"); } export function getApiClient(id: string) { - return apiFetch(`/admin/api-clients/${encodeURIComponent(id)}`) -} - -export function createApiClient( - body: { - name: string - client_id_url: string - client_uri: string - redirect_uris: string[] - scopes?: string - client_type?: string - allowed_origins?: string[] - rate_limit_capacity: number | null - rate_limit_refill_rate: number | null - } -) { + return apiFetch( + `/admin/api-clients/${encodeURIComponent(id)}`, + ); +} + +export function createApiClient(body: { + name: string; + client_id_url: string; + client_uri: string; + redirect_uris: string[]; + scopes?: string; + client_type?: string; + allowed_origins?: string[]; + rate_limit_capacity: number | null; + rate_limit_refill_rate: number | null; +}) { return apiFetch("/admin/api-clients", { method: "POST", body: JSON.stringify(body), - }) + }); } export function updateApiClient( id: string, body: { - name?: string - client_uri?: string - redirect_uris?: string[] - scopes?: string - allowed_origins?: string[] | null - rate_limit_capacity?: number | null - rate_limit_refill_rate?: number | null - is_active?: boolean - } + name?: string; + client_uri?: string; + redirect_uris?: string[]; + scopes?: string; + allowed_origins?: string[] | null; + rate_limit_capacity?: number | null; + rate_limit_refill_rate?: number | null; + is_active?: boolean; + }, ) { return apiFetch(`/admin/api-clients/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(body), - }) + }); } export function deleteApiClient(id: string) { return apiFetch(`/admin/api-clients/${encodeURIComponent(id)}`, { method: "DELETE", - }) + }); } // Event Logs -export function getEvents( - params?: { - category?: string - severity?: string - subject?: string - cursor?: string - limit?: number - } -) { - const searchParams = new URLSearchParams() - if (params?.category) searchParams.set("category", params.category) - if (params?.severity) searchParams.set("severity", params.severity) - if (params?.subject) searchParams.set("subject", params.subject) - if (params?.cursor) searchParams.set("cursor", params.cursor) - if (params?.limit) searchParams.set("limit", String(params.limit)) - const qs = searchParams.toString() - return apiFetch( - `/admin/events${qs ? `?${qs}` : ""}`, - ) +export function getEvents(params?: { + category?: string; + severity?: string; + subject?: string; + cursor?: string; + limit?: number; +}) { + const searchParams = new URLSearchParams(); + if (params?.category) searchParams.set("category", params.category); + if (params?.severity) searchParams.set("severity", params.severity); + if (params?.subject) searchParams.set("subject", params.subject); + if (params?.cursor) searchParams.set("cursor", params.cursor); + if (params?.limit) searchParams.set("limit", String(params.limit)); + const qs = searchParams.toString(); + return apiFetch(`/admin/events${qs ? `?${qs}` : ""}`); } // External Accounts export function getExternalProviders() { - return apiFetch("/external-auth/providers") + return apiFetch("/external-auth/providers"); } export function getLinkedAccounts() { - return apiFetch("/external-auth/accounts") + return apiFetch("/external-auth/accounts"); } export function authorizeExternal(pluginId: string, redirectUri: string) { - const params = new URLSearchParams({ redirect_uri: redirectUri }) + const params = new URLSearchParams({ redirect_uri: redirectUri }); return apiFetch( `/external-auth/${encodeURIComponent(pluginId)}/authorize?${params}`, - ) + ); } export function syncExternal(pluginId: string) { return apiFetch( `/external-auth/${encodeURIComponent(pluginId)}/sync`, { method: "POST" }, - ) + ); } export function unlinkExternal(pluginId: string) { return apiFetch( `/external-auth/${encodeURIComponent(pluginId)}/unlink`, { method: "POST" }, - ) + ); } -export function connectWithConfig(pluginId: string, config: Record) { +export function connectWithConfig( + pluginId: string, + config: Record, +) { return apiFetch( `/external-auth/${encodeURIComponent(pluginId)}/connect`, { method: "POST", body: JSON.stringify({ config }) }, - ) + ); } // Plugins @@ -525,30 +503,30 @@ import type { PluginSummary, PluginsListResponse, OfficialPluginsListResponse, -} from "@/types/plugins" +} from "@/types/plugins"; export type { PluginSummary, PluginsListResponse, OfficialPluginSummary, OfficialPluginsListResponse, ReleaseEntry, -} from "@/types/plugins" +} from "@/types/plugins"; export function getPlugins() { - return apiFetch("/admin/plugins") + return apiFetch("/admin/plugins"); } export function addPlugin(body: { url: string; sha256?: string }) { return apiFetch("/admin/plugins", { method: "POST", body: JSON.stringify(body), - }) + }); } export function removePlugin(id: string) { return apiFetch(`/admin/plugins/${encodeURIComponent(id)}`, { method: "DELETE", - }) + }); } export function reloadPlugin(id: string, body?: { url?: string }) { @@ -558,54 +536,57 @@ export function reloadPlugin(id: string, body?: { url?: string }) { method: "POST", body: body ? JSON.stringify(body) : undefined, }, - ) + ); } export function getOfficialPlugins() { - return apiFetch("/admin/plugins/official") + return apiFetch("/admin/plugins/official"); } export function checkPluginUpdate(id: string) { return apiFetch( `/admin/plugins/${encodeURIComponent(id)}/check-update`, { method: "POST" }, - ) + ); } export interface PluginSecretsResponse { - plugin_id: string - secrets: Record + plugin_id: string; + secrets: Record; } export function getPluginSecrets(id: string) { return apiFetch( `/admin/plugins/${encodeURIComponent(id)}/secrets`, - ) + ); } -export function updatePluginSecrets(id: string, secrets: Record) { - return apiFetch( - `/admin/plugins/${encodeURIComponent(id)}/secrets`, - { method: "PUT", body: JSON.stringify({ secrets }) }, - ) +export function updatePluginSecrets( + id: string, + secrets: Record, +) { + return apiFetch(`/admin/plugins/${encodeURIComponent(id)}/secrets`, { + method: "PUT", + body: JSON.stringify({ secrets }), + }); } export interface SecretDefinition { - key: string - name: string - description: string | null + key: string; + name: string; + description: string | null; } export interface PluginPreview { - id: string - name: string - version: string - description: string | null - icon_url: string | null - auth_type: string - required_secrets: SecretDefinition[] - manifest_url: string - wasm_url: string + id: string; + name: string; + version: string; + description: string | null; + icon_url: string | null; + auth_type: string; + required_secrets: SecretDefinition[]; + manifest_url: string; + wasm_url: string; } export function previewPlugin(url: string, signal?: AbortSignal) { @@ -613,7 +594,7 @@ export function previewPlugin(url: string, signal?: AbortSignal) { method: "POST", body: JSON.stringify({ url }), signal, - }) + }); } // --------------------------------------------------------------------------- @@ -621,84 +602,84 @@ export function previewPlugin(url: string, signal?: AbortSignal) { // --------------------------------------------------------------------------- export function getDeadLetters(params?: { - collection?: string - resolved?: string - cursor?: string - limit?: number + collection?: string; + resolved?: string; + cursor?: string; + limit?: number; }) { - const searchParams = new URLSearchParams() - if (params?.collection) searchParams.set("collection", params.collection) - if (params?.resolved) searchParams.set("resolved", params.resolved) - if (params?.cursor) searchParams.set("cursor", params.cursor) - if (params?.limit) searchParams.set("limit", String(params.limit)) - const qs = searchParams.toString() + const searchParams = new URLSearchParams(); + if (params?.collection) searchParams.set("collection", params.collection); + if (params?.resolved) searchParams.set("resolved", params.resolved); + if (params?.cursor) searchParams.set("cursor", params.cursor); + if (params?.limit) searchParams.set("limit", String(params.limit)); + const qs = searchParams.toString(); return apiFetch( `/admin/dead-letters${qs ? `?${qs}` : ""}`, - ) + ); } export function getDeadLetterCount(resolved?: string) { - const searchParams = new URLSearchParams() - if (resolved) searchParams.set("resolved", resolved) - const qs = searchParams.toString() + const searchParams = new URLSearchParams(); + if (resolved) searchParams.set("resolved", resolved); + const qs = searchParams.toString(); return apiFetch( `/admin/dead-letters/count${qs ? `?${qs}` : ""}`, - ) + ); } export function getDeadLetter(id: string) { return apiFetch( `/admin/dead-letters/${encodeURIComponent(id)}`, - ) + ); } export function retryDeadLetter(id: string) { return apiFetch(`/admin/dead-letters/${encodeURIComponent(id)}/retry`, { method: "POST", - }) + }); } export function reindexDeadLetter(id: string) { return apiFetch(`/admin/dead-letters/${encodeURIComponent(id)}/reindex`, { method: "POST", - }) + }); } export function dismissDeadLetter(id: string) { return apiFetch(`/admin/dead-letters/${encodeURIComponent(id)}/dismiss`, { method: "POST", - }) + }); } export function bulkDismissDeadLetters(body: { - ids?: string[] - all?: boolean - collection?: string + ids?: string[]; + all?: boolean; + collection?: string; }) { return apiFetch("/admin/dead-letters/bulk/dismiss", { method: "POST", body: JSON.stringify(body), - }) + }); } export function bulkRetryDeadLetters(body: { - ids?: string[] - all?: boolean - collection?: string + ids?: string[]; + all?: boolean; + collection?: string; }) { return apiFetch("/admin/dead-letters/bulk/retry", { method: "POST", body: JSON.stringify(body), - }) + }); } export function bulkReindexDeadLetters(body: { - ids?: string[] - all?: boolean - collection?: string + ids?: string[]; + all?: boolean; + collection?: string; }) { return apiFetch("/admin/dead-letters/bulk/reindex", { method: "POST", body: JSON.stringify(body), - }) + }); } -- 2.51.2 From a748f4af804ea60108a7bce7ea337b9e9e2f7a6c Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 09:54:31 -0500 Subject: [PATCH 05/56] docs: add info about the XRPC proxy Signed-off-by: Trezy --- packages/docs/docs/getting-started/dashboard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/docs/getting-started/dashboard.md b/packages/docs/docs/getting-started/dashboard.md index b5292bf..2873c02 100644 --- a/packages/docs/docs/getting-started/dashboard.md +++ b/packages/docs/docs/getting-started/dashboard.md @@ -87,7 +87,7 @@ Configure instance-level settings: application name, logo, terms of service URL, Control which unrecognized XRPC methods are forwarded to their resolved authority. Choose from four modes: **Disabled** (block all proxy requests), **Open** (proxy everything — the default), **Allowlist** (only proxy NSIDs matching your patterns), or **Blocklist** (proxy everything except matching patterns). Allowlist and blocklist modes accept NSID patterns with trailing wildcards (e.g. `com.example.*`). Locally registered lexicons are always served regardless of this setting. See [XRPC Proxy](../reference/admin/xrpc-proxy.md) for the full API reference. -### ENV Variables +### Environment Variables View the current values of all environment variables that affect HappyView's behavior. This is a read-only view — values are set via your deployment environment, not the dashboard. -- 2.51.2 From 00f98365ed597bc16a866a3e6012614ad5c1da8a Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 09:59:07 -0500 Subject: [PATCH 06/56] docs: update the dashboard docs Signed-off-by: Trezy --- packages/docs/docs/getting-started/dashboard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/docs/getting-started/dashboard.md b/packages/docs/docs/getting-started/dashboard.md index 2873c02..b5292bf 100644 --- a/packages/docs/docs/getting-started/dashboard.md +++ b/packages/docs/docs/getting-started/dashboard.md @@ -87,7 +87,7 @@ Configure instance-level settings: application name, logo, terms of service URL, Control which unrecognized XRPC methods are forwarded to their resolved authority. Choose from four modes: **Disabled** (block all proxy requests), **Open** (proxy everything — the default), **Allowlist** (only proxy NSIDs matching your patterns), or **Blocklist** (proxy everything except matching patterns). Allowlist and blocklist modes accept NSID patterns with trailing wildcards (e.g. `com.example.*`). Locally registered lexicons are always served regardless of this setting. See [XRPC Proxy](../reference/admin/xrpc-proxy.md) for the full API reference. -### Environment Variables +### ENV Variables View the current values of all environment variables that affect HappyView's behavior. This is a read-only view — values are set via your deployment environment, not the dashboard. -- 2.51.2 From 3a59b4009f32de86fdbb3fed9f19aee4cb588078 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 17:16:10 -0500 Subject: [PATCH 07/56] feat: permissioned spaces implementation Signed-off-by: Trezy --- src/spaces/notifications.rs | 86 +++++++++++ src/spaces/sync.rs | 293 ++++++++++++++++++++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 src/spaces/notifications.rs create mode 100644 src/spaces/sync.rs diff --git a/src/spaces/notifications.rs b/src/spaces/notifications.rs new file mode 100644 index 0000000..3d17e12 --- /dev/null +++ b/src/spaces/notifications.rs @@ -0,0 +1,86 @@ +use serde::Deserialize; +use uuid::Uuid; + +use crate::db::DatabaseBackend; +use crate::error::AppError; +use crate::spaces::db; +use crate::spaces::types::*; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteNotification { + pub space_uri: String, + pub author_did: String, + pub collection: String, + pub rkey: String, + pub action: WriteAction, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WriteAction { + Create, + Update, + Delete, +} + +/// Process a write notification by queuing a sync pull for the affected member. +/// +/// This marks the member's sync state as pending so the next sync pass picks it up. +pub async fn handle_write_notification( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + notification: &WriteNotification, +) -> Result<(), AppError> { + let existing = db::get_sync_state(pool, backend, space_id, ¬ification.author_did).await?; + + let state = SpaceSyncState { + id: existing + .map(|s| s.id) + .unwrap_or_else(|| Uuid::new_v4().to_string()), + space_id: space_id.to_string(), + member_did: notification.author_did.clone(), + cursor: None, + last_synced_at: None, + status: SyncStatus::Pending, + error: None, + }; + + db::upsert_sync_state(pool, backend, &state).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_action_deserializes() { + let action: WriteAction = serde_json::from_str("\"create\"").unwrap(); + assert!(matches!(action, WriteAction::Create)); + + let action: WriteAction = serde_json::from_str("\"update\"").unwrap(); + assert!(matches!(action, WriteAction::Update)); + + let action: WriteAction = serde_json::from_str("\"delete\"").unwrap(); + assert!(matches!(action, WriteAction::Delete)); + } + + #[test] + fn write_notification_deserializes() { + let json = r#"{ + "spaceUri": "ats://did:plc:owner/com.example.forum/main", + "authorDid": "did:plc:alice", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "action": "create" + }"#; + + let notif: WriteNotification = serde_json::from_str(json).unwrap(); + assert_eq!(notif.author_did, "did:plc:alice"); + assert_eq!(notif.collection, "com.example.forum.post"); + assert!(matches!(notif.action, WriteAction::Create)); + } +} diff --git a/src/spaces/sync.rs b/src/spaces/sync.rs new file mode 100644 index 0000000..40d5f72 --- /dev/null +++ b/src/spaces/sync.rs @@ -0,0 +1,293 @@ +use uuid::Uuid; + +use crate::db::DatabaseBackend; +use crate::db::now_rfc3339; +use crate::error::AppError; +use crate::profile::resolve_pds_endpoint; +use crate::spaces::types::*; +use crate::spaces::{db, members}; + +/// Sync all members of a space by pulling records from their PDSes. +pub async fn sync_space( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + plc_url: &str, + space_id: &str, + collections: &[String], +) -> Result { + let resolved = members::resolve_members(pool, backend, space_id).await?; + let mut results = Vec::new(); + + for member in &resolved { + let result = sync_member( + http, + pool, + backend, + plc_url, + space_id, + &member.did, + collections, + ) + .await; + + results.push(MemberSyncResult { + did: member.did.clone(), + records_synced: result.as_ref().map(|r| r.records_synced).unwrap_or(0), + error: result.err().map(|e| e.to_string()), + }); + } + + let total = results.iter().map(|r| r.records_synced).sum(); + + Ok(SyncSpaceResult { + members_processed: results.len(), + total_records_synced: total, + member_results: results, + }) +} + +/// Sync records from a single member's PDS for a given space. +pub async fn sync_member( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + plc_url: &str, + space_id: &str, + member_did: &str, + collections: &[String], +) -> Result { + let state_id = match db::get_sync_state(pool, backend, space_id, member_did).await? { + Some(s) => s.id, + None => { + let id = Uuid::new_v4().to_string(); + let initial = SpaceSyncState { + id: id.clone(), + space_id: space_id.to_string(), + member_did: member_did.to_string(), + cursor: None, + last_synced_at: None, + status: SyncStatus::Pending, + error: None, + }; + db::upsert_sync_state(pool, backend, &initial).await?; + id + } + }; + + // Mark as syncing + let syncing_state = SpaceSyncState { + id: state_id.clone(), + space_id: space_id.to_string(), + member_did: member_did.to_string(), + cursor: None, + last_synced_at: None, + status: SyncStatus::Syncing, + error: None, + }; + db::upsert_sync_state(pool, backend, &syncing_state).await?; + + let result = pull_member_records( + http, + pool, + backend, + plc_url, + space_id, + member_did, + collections, + ) + .await; + + match result { + Ok(summary) => { + let done = SpaceSyncState { + id: state_id, + space_id: space_id.to_string(), + member_did: member_did.to_string(), + cursor: summary.cursor.clone(), + last_synced_at: Some(now_rfc3339()), + status: SyncStatus::Synced, + error: None, + }; + db::upsert_sync_state(pool, backend, &done).await?; + Ok(summary) + } + Err(e) => { + let err_state = SpaceSyncState { + id: state_id, + space_id: space_id.to_string(), + member_did: member_did.to_string(), + cursor: None, + last_synced_at: Some(now_rfc3339()), + status: SyncStatus::Error, + error: Some(e.to_string()), + }; + db::upsert_sync_state(pool, backend, &err_state).await?; + Err(e) + } + } +} + +async fn pull_member_records( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + plc_url: &str, + space_id: &str, + member_did: &str, + collections: &[String], +) -> Result { + let pds_url = resolve_pds_endpoint(http, plc_url, member_did).await?; + let mut total_records = 0usize; + let mut last_cursor = None; + + for collection in collections { + let mut cursor: Option = None; + loop { + let (records, next_cursor) = fetch_records_page( + http, + &pds_url, + member_did, + collection, + cursor.as_deref(), + 100, + ) + .await?; + + if records.is_empty() { + break; + } + + for record in &records { + let uri = record["uri"].as_str().unwrap_or(""); + let rkey = extract_rkey(uri); + let cid = record["cid"].as_str().unwrap_or("").to_string(); + let value = record + .get("value") + .cloned() + .unwrap_or(serde_json::Value::Null); + + let space_record_uri = format!("ats://{space_id}/{member_did}/{collection}/{rkey}"); + + let space_record = SpaceRecord { + uri: space_record_uri, + space_id: space_id.to_string(), + author_did: member_did.to_string(), + collection: collection.clone(), + rkey: rkey.to_string(), + record: value, + cid, + indexed_at: now_rfc3339(), + }; + + db::upsert_space_record(pool, backend, &space_record).await?; + total_records += 1; + } + + last_cursor = next_cursor.clone(); + cursor = next_cursor; + + if cursor.is_none() { + break; + } + } + } + + Ok(MemberSyncSummary { + records_synced: total_records, + cursor: last_cursor, + }) +} + +async fn fetch_records_page( + http: &reqwest::Client, + pds_url: &str, + repo: &str, + collection: &str, + cursor: Option<&str>, + limit: u32, +) -> Result<(Vec, Option), AppError> { + let mut url = format!( + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={}", + pds_url.trim_end_matches('/'), + repo, + collection, + limit, + ); + + if let Some(c) = cursor { + url.push_str(&format!("&cursor={c}")); + } + + let resp = http + .get(&url) + .send() + .await + .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + return Err(AppError::Internal(format!( + "PDS listRecords failed with {status} for {repo}/{collection}" + ))); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid PDS response: {e}")))?; + + let records = body["records"].as_array().cloned().unwrap_or_default(); + + let next_cursor = body["cursor"].as_str().map(|s| s.to_string()); + + Ok((records, next_cursor)) +} + +fn extract_rkey(uri: &str) -> &str { + uri.rsplit('/').next().unwrap_or("") +} + +// --------------------------------------------------------------------------- +// Result types +// --------------------------------------------------------------------------- + +pub struct SyncSpaceResult { + pub members_processed: usize, + pub total_records_synced: usize, + pub member_results: Vec, +} + +pub struct MemberSyncResult { + pub did: String, + pub records_synced: usize, + pub error: Option, +} + +pub struct MemberSyncSummary { + pub records_synced: usize, + pub cursor: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_rkey_from_at_uri() { + assert_eq!( + extract_rkey("at://did:plc:abc/app.bsky.feed.post/3k2abc"), + "3k2abc" + ); + } + + #[test] + fn extract_rkey_from_empty() { + assert_eq!(extract_rkey(""), ""); + } + + #[test] + fn extract_rkey_no_slash() { + assert_eq!(extract_rkey("singlevalue"), "singlevalue"); + } +} -- 2.51.2 From 90e5b71b2fc9af15b6107c658dc243aaffe7f84e Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 1 May 2026 11:35:57 -0500 Subject: [PATCH 08/56] feat: add control for experiments to the dashboard Signed-off-by: Trezy --- web/src/lib/api.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f05a348..25dadf5 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -345,6 +345,25 @@ export function setFeatureFlag(key: string, enabled: boolean) { return deleteSetting(key); } +// Feature Flags +export type FeatureFlag = { + key: string + name: string + description: string + enabled: boolean +} + +export function getFeatureFlags() { + return apiFetch("/admin/feature-flags") +} + +export function setFeatureFlag(key: string, enabled: boolean) { + if (enabled) { + return upsertSetting(key, "true") + } + return deleteSetting(key) +} + // Proxy config export type ProxyConfig = { mode: "disabled" | "open" | "allowlist" | "blocklist"; -- 2.51.2 From ffc4ca892d9706044e0e812c811e635b30b35aa6 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 1 May 2026 11:37:42 -0500 Subject: [PATCH 09/56] fix: remove public record aggregation from permissioned spaces Signed-off-by: Trezy --- src/spaces/notifications.rs | 86 ----------- src/spaces/sync.rs | 293 ------------------------------------ 2 files changed, 379 deletions(-) delete mode 100644 src/spaces/notifications.rs delete mode 100644 src/spaces/sync.rs diff --git a/src/spaces/notifications.rs b/src/spaces/notifications.rs deleted file mode 100644 index 3d17e12..0000000 --- a/src/spaces/notifications.rs +++ /dev/null @@ -1,86 +0,0 @@ -use serde::Deserialize; -use uuid::Uuid; - -use crate::db::DatabaseBackend; -use crate::error::AppError; -use crate::spaces::db; -use crate::spaces::types::*; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WriteNotification { - pub space_uri: String, - pub author_did: String, - pub collection: String, - pub rkey: String, - pub action: WriteAction, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum WriteAction { - Create, - Update, - Delete, -} - -/// Process a write notification by queuing a sync pull for the affected member. -/// -/// This marks the member's sync state as pending so the next sync pass picks it up. -pub async fn handle_write_notification( - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - space_id: &str, - notification: &WriteNotification, -) -> Result<(), AppError> { - let existing = db::get_sync_state(pool, backend, space_id, ¬ification.author_did).await?; - - let state = SpaceSyncState { - id: existing - .map(|s| s.id) - .unwrap_or_else(|| Uuid::new_v4().to_string()), - space_id: space_id.to_string(), - member_did: notification.author_did.clone(), - cursor: None, - last_synced_at: None, - status: SyncStatus::Pending, - error: None, - }; - - db::upsert_sync_state(pool, backend, &state).await?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn write_action_deserializes() { - let action: WriteAction = serde_json::from_str("\"create\"").unwrap(); - assert!(matches!(action, WriteAction::Create)); - - let action: WriteAction = serde_json::from_str("\"update\"").unwrap(); - assert!(matches!(action, WriteAction::Update)); - - let action: WriteAction = serde_json::from_str("\"delete\"").unwrap(); - assert!(matches!(action, WriteAction::Delete)); - } - - #[test] - fn write_notification_deserializes() { - let json = r#"{ - "spaceUri": "ats://did:plc:owner/com.example.forum/main", - "authorDid": "did:plc:alice", - "collection": "com.example.forum.post", - "rkey": "3k2abc", - "action": "create" - }"#; - - let notif: WriteNotification = serde_json::from_str(json).unwrap(); - assert_eq!(notif.author_did, "did:plc:alice"); - assert_eq!(notif.collection, "com.example.forum.post"); - assert!(matches!(notif.action, WriteAction::Create)); - } -} diff --git a/src/spaces/sync.rs b/src/spaces/sync.rs deleted file mode 100644 index 40d5f72..0000000 --- a/src/spaces/sync.rs +++ /dev/null @@ -1,293 +0,0 @@ -use uuid::Uuid; - -use crate::db::DatabaseBackend; -use crate::db::now_rfc3339; -use crate::error::AppError; -use crate::profile::resolve_pds_endpoint; -use crate::spaces::types::*; -use crate::spaces::{db, members}; - -/// Sync all members of a space by pulling records from their PDSes. -pub async fn sync_space( - http: &reqwest::Client, - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - plc_url: &str, - space_id: &str, - collections: &[String], -) -> Result { - let resolved = members::resolve_members(pool, backend, space_id).await?; - let mut results = Vec::new(); - - for member in &resolved { - let result = sync_member( - http, - pool, - backend, - plc_url, - space_id, - &member.did, - collections, - ) - .await; - - results.push(MemberSyncResult { - did: member.did.clone(), - records_synced: result.as_ref().map(|r| r.records_synced).unwrap_or(0), - error: result.err().map(|e| e.to_string()), - }); - } - - let total = results.iter().map(|r| r.records_synced).sum(); - - Ok(SyncSpaceResult { - members_processed: results.len(), - total_records_synced: total, - member_results: results, - }) -} - -/// Sync records from a single member's PDS for a given space. -pub async fn sync_member( - http: &reqwest::Client, - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - plc_url: &str, - space_id: &str, - member_did: &str, - collections: &[String], -) -> Result { - let state_id = match db::get_sync_state(pool, backend, space_id, member_did).await? { - Some(s) => s.id, - None => { - let id = Uuid::new_v4().to_string(); - let initial = SpaceSyncState { - id: id.clone(), - space_id: space_id.to_string(), - member_did: member_did.to_string(), - cursor: None, - last_synced_at: None, - status: SyncStatus::Pending, - error: None, - }; - db::upsert_sync_state(pool, backend, &initial).await?; - id - } - }; - - // Mark as syncing - let syncing_state = SpaceSyncState { - id: state_id.clone(), - space_id: space_id.to_string(), - member_did: member_did.to_string(), - cursor: None, - last_synced_at: None, - status: SyncStatus::Syncing, - error: None, - }; - db::upsert_sync_state(pool, backend, &syncing_state).await?; - - let result = pull_member_records( - http, - pool, - backend, - plc_url, - space_id, - member_did, - collections, - ) - .await; - - match result { - Ok(summary) => { - let done = SpaceSyncState { - id: state_id, - space_id: space_id.to_string(), - member_did: member_did.to_string(), - cursor: summary.cursor.clone(), - last_synced_at: Some(now_rfc3339()), - status: SyncStatus::Synced, - error: None, - }; - db::upsert_sync_state(pool, backend, &done).await?; - Ok(summary) - } - Err(e) => { - let err_state = SpaceSyncState { - id: state_id, - space_id: space_id.to_string(), - member_did: member_did.to_string(), - cursor: None, - last_synced_at: Some(now_rfc3339()), - status: SyncStatus::Error, - error: Some(e.to_string()), - }; - db::upsert_sync_state(pool, backend, &err_state).await?; - Err(e) - } - } -} - -async fn pull_member_records( - http: &reqwest::Client, - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - plc_url: &str, - space_id: &str, - member_did: &str, - collections: &[String], -) -> Result { - let pds_url = resolve_pds_endpoint(http, plc_url, member_did).await?; - let mut total_records = 0usize; - let mut last_cursor = None; - - for collection in collections { - let mut cursor: Option = None; - loop { - let (records, next_cursor) = fetch_records_page( - http, - &pds_url, - member_did, - collection, - cursor.as_deref(), - 100, - ) - .await?; - - if records.is_empty() { - break; - } - - for record in &records { - let uri = record["uri"].as_str().unwrap_or(""); - let rkey = extract_rkey(uri); - let cid = record["cid"].as_str().unwrap_or("").to_string(); - let value = record - .get("value") - .cloned() - .unwrap_or(serde_json::Value::Null); - - let space_record_uri = format!("ats://{space_id}/{member_did}/{collection}/{rkey}"); - - let space_record = SpaceRecord { - uri: space_record_uri, - space_id: space_id.to_string(), - author_did: member_did.to_string(), - collection: collection.clone(), - rkey: rkey.to_string(), - record: value, - cid, - indexed_at: now_rfc3339(), - }; - - db::upsert_space_record(pool, backend, &space_record).await?; - total_records += 1; - } - - last_cursor = next_cursor.clone(); - cursor = next_cursor; - - if cursor.is_none() { - break; - } - } - } - - Ok(MemberSyncSummary { - records_synced: total_records, - cursor: last_cursor, - }) -} - -async fn fetch_records_page( - http: &reqwest::Client, - pds_url: &str, - repo: &str, - collection: &str, - cursor: Option<&str>, - limit: u32, -) -> Result<(Vec, Option), AppError> { - let mut url = format!( - "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={}", - pds_url.trim_end_matches('/'), - repo, - collection, - limit, - ); - - if let Some(c) = cursor { - url.push_str(&format!("&cursor={c}")); - } - - let resp = http - .get(&url) - .send() - .await - .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; - - if !resp.status().is_success() { - let status = resp.status(); - return Err(AppError::Internal(format!( - "PDS listRecords failed with {status} for {repo}/{collection}" - ))); - } - - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| AppError::Internal(format!("invalid PDS response: {e}")))?; - - let records = body["records"].as_array().cloned().unwrap_or_default(); - - let next_cursor = body["cursor"].as_str().map(|s| s.to_string()); - - Ok((records, next_cursor)) -} - -fn extract_rkey(uri: &str) -> &str { - uri.rsplit('/').next().unwrap_or("") -} - -// --------------------------------------------------------------------------- -// Result types -// --------------------------------------------------------------------------- - -pub struct SyncSpaceResult { - pub members_processed: usize, - pub total_records_synced: usize, - pub member_results: Vec, -} - -pub struct MemberSyncResult { - pub did: String, - pub records_synced: usize, - pub error: Option, -} - -pub struct MemberSyncSummary { - pub records_synced: usize, - pub cursor: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extract_rkey_from_at_uri() { - assert_eq!( - extract_rkey("at://did:plc:abc/app.bsky.feed.post/3k2abc"), - "3k2abc" - ); - } - - #[test] - fn extract_rkey_from_empty() { - assert_eq!(extract_rkey(""), ""); - } - - #[test] - fn extract_rkey_no_slash() { - assert_eq!(extract_rkey("singlevalue"), "singlevalue"); - } -} -- 2.51.2 From 59eae463deb07bb8930a9ca177d8c19f2b66ce08 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 4 May 2026 23:30:14 -0500 Subject: [PATCH 10/56] feat: update JS SDKs to more closely match their @atproto cousins Signed-off-by: Trezy --- packages/docs/docs/sdk/oauth-client-browser.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/docs/docs/sdk/oauth-client-browser.md b/packages/docs/docs/sdk/oauth-client-browser.md index 9ee8c8f..376bf11 100644 --- a/packages/docs/docs/sdk/oauth-client-browser.md +++ b/packages/docs/docs/sdk/oauth-client-browser.md @@ -190,7 +190,17 @@ const info = session.getTokenInfo(); // { sub, scope, iss, aud } ``` -### Properties +## Revoke session + +```typescript +await client.revoke(session.did); +``` + +:::note +`logout()` still works as an alias for `revoke()`. +::: + +## Resolution utilities | Property | Type | Description | | -------- | -------- | ---------------------------------------- | -- 2.51.2 From c865bbd96a0cc5fd38339773bad73b6630d33ed0 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 6 May 2026 13:40:27 -0500 Subject: [PATCH 11/56] docs: add info for use with @atproto/api Signed-off-by: Trezy --- packages/docs/docs/sdk/overview.md | 2 ++ packages/oauth-client-browser/README.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/docs/docs/sdk/overview.md b/packages/docs/docs/sdk/overview.md index e072183..60fede4 100644 --- a/packages/docs/docs/sdk/overview.md +++ b/packages/docs/docs/sdk/overview.md @@ -13,6 +13,8 @@ HappyView provides JavaScript packages for building third-party apps that authen **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/api`?** `HappyViewSession` works directly as a session manager for `@atproto/api`'s `Agent` — just pass it to `new Agent(session)`. See [Using with @atproto/api](./oauth-client-browser.md#using-with-atprotoapi). + **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 atproto OAuth flow. **Building a server-side (Node.js) app?** Use `@happyview/oauth-client-node` — it handles handle resolution, DID resolution, PDS discovery, and the full OAuth flow server-side. Matches the API surface of `@atproto/oauth-client-node`. diff --git a/packages/oauth-client-browser/README.md b/packages/oauth-client-browser/README.md index 74be821..1db1709 100644 --- a/packages/oauth-client-browser/README.md +++ b/packages/oauth-client-browser/README.md @@ -70,6 +70,20 @@ const response = await session.fetchHandler( ); ``` +### Using with @atproto/api + +`HappyViewSession` works directly with `@atproto/api`'s `Agent`: + +```typescript +import { Agent } from "@atproto/api"; + +const result = await client.init(); +if (result) { + const agent = new Agent(result.session); + const profile = await agent.getProfile({ actor: agent.did }); +} +``` + ### Revoke Session ```typescript -- 2.51.2 From 9b3a86dc58c05e19721575897eabf4cadf468973 Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 7 May 2026 14:50:26 -0500 Subject: [PATCH 12/56] ci: sync main with dev after release Signed-off-by: Trezy --- .github/workflows/merge-main-to-dev.yml | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/merge-main-to-dev.yml diff --git a/.github/workflows/merge-main-to-dev.yml b/.github/workflows/merge-main-to-dev.yml new file mode 100644 index 0000000..e381a61 --- /dev/null +++ b/.github/workflows/merge-main-to-dev.yml @@ -0,0 +1,31 @@ +name: Merge main to dev + +on: + workflow_run: + workflows: [Release] + types: [completed] + branches: [main] + +jobs: + merge: + if: github.event.workflow_run.conclusion == 'success' + runs-on: depot-ubuntu-24.04 + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: dev + + - name: Merge main into dev + env: + GITHUB_TOKEN: ${{ secrets.DISPATCH_GH_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}" + git merge origin/main --no-edit + git push origin dev -- 2.51.2 From fba918ebf8a079feeb302a1ed6bbb9c5c5eac57c Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 7 May 2026 15:26:10 -0500 Subject: [PATCH 13/56] fix: fix base path not being respected by trailing slash redirects Signed-off-by: Trezy --- src/server.rs | 20 ++++++- tests/common/app.rs | 7 +++ tests/e2e_base_path.rs | 127 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 tests/e2e_base_path.rs diff --git a/src/server.rs b/src/server.rs index 5699604..8bc2887 100644 --- a/src/server.rs +++ b/src/server.rs @@ -138,9 +138,27 @@ pub fn router(state: AppState) -> Router { .fallback_service(serve_dir); let outer = if let Some(ref base_path) = state.config.base_path { + let bp = base_path.clone(); + let rewrite_redirects = + axum::middleware::from_fn(move |req, next: axum::middleware::Next| { + let bp = bp.clone(); + async move { + let mut response: Response = next.run(req).await; + if response.status().is_redirection() + && let Some(loc) = response.headers().get(header::LOCATION) + && let Ok(loc_str) = loc.to_str() + && loc_str.starts_with('/') + && !loc_str.starts_with(&bp) + && let Ok(new_loc) = format!("{}{}", bp, loc_str).parse() + { + response.headers_mut().insert(header::LOCATION, new_loc); + } + response + } + }); Router::new() .route("/health", get(health)) - .nest(base_path, app_routes) + .nest(base_path, app_routes.layer(rewrite_redirects)) } else { Router::new() .route("/health", get(health)) diff --git a/tests/common/app.rs b/tests/common/app.rs index 4b82744..d0b630a 100644 --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -169,6 +169,13 @@ impl TestApp { } } + pub async fn new_with_base_path(base_path: &str) -> Self { + let mut app = Self::new().await; + app.state.config.base_path = Some(base_path.to_string()); + app.router = server::router(app.state.clone()); + app + } + pub async fn new_with_encryption() -> Self { let mut app = Self::new().await; // Set a test encryption key (32 bytes) diff --git a/tests/e2e_base_path.rs b/tests/e2e_base_path.rs new file mode 100644 index 0000000..3370805 --- /dev/null +++ b/tests/e2e_base_path.rs @@ -0,0 +1,127 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use http_body_util::BodyExt; +use serial_test::serial; +use tower::ServiceExt; + +#[tokio::test] +#[serial] +#[ignore] +async fn health_at_root_when_base_path_set() { + let app = common::app::TestApp::new_with_base_path("/hv").await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"ok"); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn health_not_nested_under_base_path() { + let app = common::app::TestApp::new_with_base_path("/hv").await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/hv/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_ne!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn config_accessible_under_base_path() { + let app = common::app::TestApp::new_with_base_path("/hv").await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/hv/config") + .header("host", "127.0.0.1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn config_not_at_root_when_base_path_set() { + let app = common::app::TestApp::new_with_base_path("/hv").await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/config") + .header("host", "127.0.0.1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_ne!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn redirect_includes_base_path_prefix() { + let app = common::app::TestApp::new_with_base_path("/hv").await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/hv/login") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + if resp.status().is_redirection() { + let location = resp + .headers() + .get(header::LOCATION) + .expect("redirect should have Location header") + .to_str() + .unwrap(); + assert!( + location.starts_with("/hv"), + "redirect Location should include base path, got: {location}" + ); + } +} -- 2.51.2 From fdb6fac8581ccf6b0ee3478e49e60cc5a6d395ab Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 7 May 2026 19:04:04 -0500 Subject: [PATCH 14/56] fix: remove oauth redirect hop, and add explicit redirect for the root url Signed-off-by: Trezy --- packages/docs/docs/getting-started/production-deployment.md | 4 ++-- src/auth/routes.rs | 4 ++-- src/server.rs | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/docs/docs/getting-started/production-deployment.md b/packages/docs/docs/getting-started/production-deployment.md index 0a7b3c1..640a66d 100644 --- a/packages/docs/docs/getting-started/production-deployment.md +++ b/packages/docs/docs/getting-started/production-deployment.md @@ -63,7 +63,7 @@ example.com { # ATProto XRPC — rewrite to base path before proxying handle /xrpc/* { - uri prefix /hv + rewrite * /hv{uri} reverse_proxy happyview:3000 } @@ -74,7 +74,7 @@ example.com { } ``` -The `uri prefix /hv` directive prepends `/hv` to the request path before proxying, so `/xrpc/com.atproto.sync.getRecord` becomes `/hv/xrpc/com.atproto.sync.getRecord` — which matches HappyView's nested routes. +The `rewrite * /hv{uri}` directive prepends `/hv` to the request path before proxying, so `/xrpc/com.atproto.sync.getRecord` becomes `/hv/xrpc/com.atproto.sync.getRecord` — which matches HappyView's nested routes. ### Health checks with a base path diff --git a/src/auth/routes.rs b/src/auth/routes.rs index 019d773..cf9a61f 100644 --- a/src/auth/routes.rs +++ b/src/auth/routes.rs @@ -260,8 +260,8 @@ async fn callback( .config .base_path .as_ref() - .map(|bp| format!("{}/", bp)) - .unwrap_or_else(|| "/".into()); + .map(|bp| format!("{}/dashboard/", bp)) + .unwrap_or_else(|| "/dashboard/".into()); let redirect_url = redirect_url.unwrap_or(default_redirect); tracing::debug!(redirect_url = %redirect_url, "redirecting after callback"); diff --git a/src/server.rs b/src/server.rs index 8bc2887..31fc30c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,6 +1,6 @@ use axum::extract::{DefaultBodyLimit, State}; use axum::http::{Method, header}; -use axum::response::{IntoResponse, Response}; +use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use bytes::Bytes; @@ -135,6 +135,7 @@ pub fn router(state: AppState) -> Router { let app_routes = Router::new() .nest("/admin", admin::admin_routes(state.clone())) .merge(domain_routes) + .route("/", get(|| async { Redirect::to("/dashboard/") })) .fallback_service(serve_dir); let outer = if let Some(ref base_path) = state.config.base_path { -- 2.51.2 From 33028b1fa69c47b7f87216fa1839c21797bff9c8 Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 7 May 2026 20:01:24 -0500 Subject: [PATCH 15/56] fix: replace the base path sentinel in next.js text files Signed-off-by: Trezy --- entrypoint.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index ef23dd4..56cd88f 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -18,11 +18,11 @@ if [ -f "$MARKER" ]; then BP="${BP%/}" # Replace sentinel string in static files with actual base path - find "${STATIC}" -type f \( -name '*.html' -o -name '*.js' -o -name '*.css' \) \ + find "${STATIC}" -type f \( -name '*.html' -o -name '*.js' -o -name '*.css' -o -name '*.txt' \) \ -exec sed -i "s|${SENTINEL}|${BP}|g" {} + else # No base path: remove sentinel string from static files - find "${STATIC}" -type f \( -name '*.html' -o -name '*.js' -o -name '*.css' \) \ + find "${STATIC}" -type f \( -name '*.html' -o -name '*.js' -o -name '*.css' -o -name '*.txt' \) \ -exec sed -i "s|${SENTINEL}||g" {} + fi -- 2.51.2 From 04e8f504e3d73dfffc5c588ea41f0c730d6662ec Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 10:36:33 -0500 Subject: [PATCH 16/56] ci: merge workflows Signed-off-by: Trezy --- .github/workflows/ci.yml | 585 ++++++++++++++++++++++++ .github/workflows/docker-postgres.yml | 41 -- .github/workflows/docker.yml | 120 ----- .github/workflows/merge-main-to-dev.yml | 2 +- .github/workflows/mirror.yml | 17 - .github/workflows/release-sdk.yml | 185 -------- .github/workflows/release.yml | 144 ------ 7 files changed, 586 insertions(+), 508 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/docker-postgres.yml delete mode 100644 .github/workflows/docker.yml delete mode 100644 .github/workflows/mirror.yml delete mode 100644 .github/workflows/release-sdk.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7c39b4c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,585 @@ +name: CI + +on: + push: + branches: [main, dev, ci/merge-workflows] + pull_request: + branches: [main, dev, ci/merge-workflows] + release: + types: [published] + delete: + +env: + SQLX_OFFLINE: true + +jobs: + # --------------------------------------------------------------------------- + # Path detection — determines which job groups should run. + # Required because workflow-level `paths` filters can't vary per job. + # --------------------------------------------------------------------------- + changes: + if: github.event_name == 'push' || github.event_name == 'pull_request' + runs-on: depot-ubuntu-24.04 + outputs: + server: ${{ steps.filter.outputs.server }} + sdk: ${{ steps.filter.outputs.sdk }} + postgres: ${{ steps.filter.outputs.postgres }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Detect changed paths + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + server: + - '**' + - '!packages/**' + - '!.github/workflows/merge-main-to-dev.yml' + sdk: + - 'packages/oauth-client/**' + - 'packages/oauth-client-browser/**' + - 'packages/oauth-client-node/**' + - 'packages/lex-agent/**' + postgres: + - 'docker/Dockerfile.postgres' + - 'docker/init-databases.sh' + + # --------------------------------------------------------------------------- + # Server — unit tests, e2e tests, frontend build, lint + # --------------------------------------------------------------------------- + unit-tests: + needs: changes + if: needs.changes.outputs.server == 'true' + runs-on: depot-ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Cache cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: cargo-deps-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-deps-${{ runner.os }}- + + - name: Run unit tests + run: cargo test --lib + + e2e-tests: + needs: changes + if: needs.changes.outputs.server == 'true' + runs-on: depot-ubuntu-24.04 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: happyview + POSTGRES_PASSWORD: happyview + POSTGRES_DB: happyview_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Cache cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: cargo-deps-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-deps-${{ runner.os }}- + + - name: Add WASM target + run: rustup target add wasm32-unknown-unknown + + - name: Build test plugin + run: cargo build --manifest-path tests/fixtures/test_plugin/Cargo.toml --target wasm32-unknown-unknown --release + + - name: Run e2e tests + env: + TEST_DATABASE_URL: postgres://happyview:happyview@localhost:5432/happyview_test + run: cargo test --tests + + frontend: + needs: changes + if: needs.changes.outputs.server == 'true' + runs-on: depot-ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Build frontend + working-directory: web + run: npm ci && npm run build + + lint: + needs: changes + if: needs.changes.outputs.server == 'true' + runs-on: depot-ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Cache cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: cargo-deps-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-deps-${{ runner.os }}- + + - name: Check formatting + run: cargo fmt -- --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + # --------------------------------------------------------------------------- + # Server release + # --------------------------------------------------------------------------- + release: + if: >- + github.event_name == 'push' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + && needs.changes.outputs.server == 'true' + needs: [changes, unit-tests, e2e-tests, frontend, lint] + runs-on: depot-ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Release + env: + GITHUB_TOKEN: ${{ secrets.DISPATCH_GH_TOKEN }} + run: npx semantic-release + + # --------------------------------------------------------------------------- + # SDK — tests + per-package releases + # --------------------------------------------------------------------------- + sdk-tests: + needs: changes + if: needs.changes.outputs.sdk == 'true' + runs-on: depot-ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Build SDK packages + run: bun run --filter '@happyview/*' build + + - name: Typecheck SDK packages + run: bun run --filter '@happyview/*' typecheck + + - name: Test SDK packages + run: bun run --filter '@happyview/*' test + + release-oauth-client: + if: >- + github.event_name == 'push' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + && needs.changes.outputs.sdk == 'true' + needs: [changes, sdk-tests] + runs-on: depot-ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install dependencies + run: bun install + + - name: Build oauth-client + run: bun run --filter '@happyview/oauth-client' build + + - name: Configure npm auth for bun publish + run: echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > .npmrc + + - name: Release oauth-client + env: + GITHUB_TOKEN: ${{ secrets.DISPATCH_GH_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + working-directory: packages/oauth-client + run: npx semantic-release + + release-oauth-client-browser: + if: >- + github.event_name == 'push' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + && needs.changes.outputs.sdk == 'true' + needs: [changes, release-oauth-client] + runs-on: depot-ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install dependencies + run: bun install + + - name: Build SDK packages + run: bun run --filter '@happyview/*' build + + - name: Resolve workspace dependencies to published versions + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + DIST_TAG="latest" + else + DIST_TAG="dev" + fi + CORE_VERSION=$(npm view @happyview/oauth-client@${DIST_TAG} version) + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('packages/oauth-client-browser/package.json', 'utf8')); + if (pkg.dependencies['@happyview/oauth-client']?.startsWith('workspace:')) { + pkg.dependencies['@happyview/oauth-client'] = '^${CORE_VERSION}'; + fs.writeFileSync('packages/oauth-client-browser/package.json', JSON.stringify(pkg, null, 2) + '\n'); + } + " + + - name: Configure npm auth for bun publish + run: echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > .npmrc + + - name: Release oauth-client-browser + env: + GITHUB_TOKEN: ${{ secrets.DISPATCH_GH_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + working-directory: packages/oauth-client-browser + run: npx semantic-release + + release-oauth-client-node: + if: >- + github.event_name == 'push' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + && needs.changes.outputs.sdk == 'true' + needs: [changes, release-oauth-client] + runs-on: depot-ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install dependencies + run: bun install + + - name: Build SDK packages + run: bun run --filter '@happyview/*' build + + - name: Resolve workspace dependencies to published versions + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + DIST_TAG="latest" + else + DIST_TAG="dev" + fi + CORE_VERSION=$(npm view @happyview/oauth-client@${DIST_TAG} version) + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('packages/oauth-client-node/package.json', 'utf8')); + if (pkg.dependencies['@happyview/oauth-client']?.startsWith('workspace:')) { + pkg.dependencies['@happyview/oauth-client'] = '^${CORE_VERSION}'; + fs.writeFileSync('packages/oauth-client-node/package.json', JSON.stringify(pkg, null, 2) + '\n'); + } + " + + - name: Configure npm auth for bun publish + run: echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > .npmrc + + - name: Release oauth-client-node + env: + GITHUB_TOKEN: ${{ secrets.DISPATCH_GH_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + working-directory: packages/oauth-client-node + run: npx semantic-release + + release-lex-agent: + if: >- + github.event_name == 'push' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + && needs.changes.outputs.sdk == 'true' + needs: [changes, release-oauth-client] + runs-on: depot-ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install dependencies + run: bun install + + - name: Build SDK packages + run: bun run --filter '@happyview/*' build + + - name: Resolve workspace dependencies to published versions + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + DIST_TAG="latest" + else + DIST_TAG="dev" + fi + CORE_VERSION=$(npm view @happyview/oauth-client@${DIST_TAG} version) + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('packages/lex-agent/package.json', 'utf8')); + if (pkg.dependencies['@happyview/oauth-client']?.startsWith('workspace:')) { + pkg.dependencies['@happyview/oauth-client'] = '^${CORE_VERSION}'; + fs.writeFileSync('packages/lex-agent/package.json', JSON.stringify(pkg, null, 2) + '\n'); + } + " + + - name: Configure npm auth for bun publish + run: echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > .npmrc + + - name: Release lex-agent + env: + GITHUB_TOKEN: ${{ secrets.DISPATCH_GH_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + working-directory: packages/lex-agent + run: npx semantic-release + + # --------------------------------------------------------------------------- + # Docker — main app image (triggered by GitHub release) + # --------------------------------------------------------------------------- + docker: + if: github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v') + runs-on: ${{ matrix.runs-on }} + permissions: + contents: read + packages: write + id-token: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runs-on: depot-ubuntu-24.04 + - platform: linux/arm64 + runs-on: depot-ubuntu-24.04-arm + env: + GHCR_IMAGE: ghcr.io/${{ github.repository }} + ATCR_IMAGE: atcr.io/${{ secrets.ATCR_NAMESPACE }}/happyview + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # TODO: Re-enable once ATCR rate limit issue is resolved + # - name: Log in to ATCR + # uses: docker/login-action@v3 + # with: + # registry: atcr.io + # username: ${{ secrets.ATCR_USERNAME }} + # password: ${{ secrets.ATCR_PASSWORD }} + + - name: Extract GHCR metadata + id: meta-ghcr + uses: docker/metadata-action@v5 + with: + images: ${{ env.GHCR_IMAGE }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + # TODO: Re-enable once ATCR rate limit issue is resolved + # - name: Extract ATCR metadata + # id: meta-atcr + # uses: docker/metadata-action@v5 + # with: + # images: ${{ env.ATCR_IMAGE }} + # tags: | + # type=semver,pattern={{version}} + # type=semver,pattern={{major}}.{{minor}} + # type=semver,pattern={{major}} + # type=sha + + - name: Build and push to GHCR by digest + id: build + uses: docker/build-push-action@v5 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta-ghcr.outputs.labels }} + build-args: | + HAPPYVIEW_VERSION=${{ github.event.release.tag_name }} + cache-from: type=gha,scope=build-${{ matrix.platform }} + cache-to: type=gha,scope=build-${{ matrix.platform }},mode=max,ignore-error=true + outputs: type=image,"name=${{ env.GHCR_IMAGE }}",push-by-digest=true,name-canonical=true,push=true + + # TODO: Re-enable once ATCR rate limit issue is resolved + # - name: Push to ATCR by digest + # id: build-atcr + # continue-on-error: true + # uses: docker/build-push-action@v5 + # with: + # context: . + # platforms: ${{ matrix.platform }} + # labels: ${{ steps.meta-atcr.outputs.labels }} + # build-args: | + # HAPPYVIEW_VERSION=${{ github.event.release.tag_name }} + # cache-from: type=gha,scope=build-${{ matrix.platform }} + # outputs: type=image,"name=${{ env.ATCR_IMAGE }}",push-by-digest=true,name-canonical=true,push=true + + - name: Create GHCR manifest + run: | + GHCR_TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta-ghcr.outputs.json }}') + docker buildx imagetools create --append $GHCR_TAGS \ + ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }} 2>/dev/null || \ + docker buildx imagetools create $GHCR_TAGS \ + ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }} + + # TODO: Re-enable once ATCR rate limit issue is resolved + # - name: Create ATCR manifest + # if: steps.build-atcr.outcome == 'success' + # continue-on-error: true + # run: | + # ATCR_TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta-atcr.outputs.json }}') + # if [ -n "$ATCR_TAGS" ]; then + # docker buildx imagetools create --append $ATCR_TAGS \ + # ${{ env.ATCR_IMAGE }}@${{ steps.build-atcr.outputs.digest }} 2>/dev/null || \ + # docker buildx imagetools create $ATCR_TAGS \ + # ${{ env.ATCR_IMAGE }}@${{ steps.build-atcr.outputs.digest }} + # fi + + # --------------------------------------------------------------------------- + # Docker — Postgres image (only when Postgres Docker files change) + # --------------------------------------------------------------------------- + docker-postgres: + needs: changes + if: >- + github.event_name == 'push' + && needs.changes.outputs.postgres == 'true' + runs-on: depot-ubuntu-24.04 + permissions: + contents: read + packages: write + env: + GHCR_POSTGRES_IMAGE: ghcr.io/${{ github.repository }}-postgres + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: docker + file: docker/Dockerfile.postgres + push: true + tags: ${{ env.GHCR_POSTGRES_IMAGE }}:latest + cache-from: type=gha,scope=postgres + cache-to: type=gha,scope=postgres,mode=max,ignore-error=true + + # --------------------------------------------------------------------------- + # Mirror to Tangled + # --------------------------------------------------------------------------- + mirror: + if: github.event_name == 'push' || github.event_name == 'delete' + runs-on: depot-ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: yesolutions/mirror-action@master + with: + REMOTE: "git@tangled.org:gamesgamesgamesgames.games/happyview" + GIT_SSH_PRIVATE_KEY: ${{ secrets.TANGLED_SSH_PRIVATE_KEY }} + GIT_SSH_NO_VERIFY_HOST: "true" diff --git a/.github/workflows/docker-postgres.yml b/.github/workflows/docker-postgres.yml deleted file mode 100644 index fd57bb1..0000000 --- a/.github/workflows/docker-postgres.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Build and Publish Postgres Image - -on: - push: - branches: [main, dev] - paths: - - docker/Dockerfile.postgres - - docker/init-databases.sh - -env: - GHCR_POSTGRES_IMAGE: ghcr.io/${{ github.repository }}-postgres - -jobs: - postgres: - runs-on: depot-ubuntu-24.04 - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: docker - file: docker/Dockerfile.postgres - push: true - tags: ${{ env.GHCR_POSTGRES_IMAGE }}:latest - cache-from: type=gha,scope=postgres - cache-to: type=gha,scope=postgres,mode=max,ignore-error=true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 7af1714..0000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Build and Publish Docker Image - -on: - release: - types: [published] - -env: - GHCR_IMAGE: ghcr.io/${{ github.repository }} - ATCR_IMAGE: atcr.io/${{ secrets.ATCR_NAMESPACE }}/happyview - -jobs: - happyview: - if: startsWith(github.event.release.tag_name, 'v') - permissions: - contents: read - packages: write - id-token: write - strategy: - fail-fast: false - matrix: - include: - - platform: linux/amd64 - runs-on: depot-ubuntu-24.04 - - platform: linux/arm64 - runs-on: depot-ubuntu-24.04-arm - runs-on: ${{ matrix.runs-on }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # TODO: Re-enable once ATCR rate limit issue is resolved - # - name: Log in to ATCR - # uses: docker/login-action@v3 - # with: - # registry: atcr.io - # username: ${{ secrets.ATCR_USERNAME }} - # password: ${{ secrets.ATCR_PASSWORD }} - - - name: Extract GHCR metadata - id: meta-ghcr - uses: docker/metadata-action@v5 - with: - images: ${{ env.GHCR_IMAGE }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha - - # TODO: Re-enable once ATCR rate limit issue is resolved - # - name: Extract ATCR metadata - # id: meta-atcr - # uses: docker/metadata-action@v5 - # with: - # images: ${{ env.ATCR_IMAGE }} - # tags: | - # type=semver,pattern={{version}} - # type=semver,pattern={{major}}.{{minor}} - # type=semver,pattern={{major}} - # type=sha - - - name: Build and push to GHCR by digest - id: build - uses: docker/build-push-action@v5 - with: - context: . - platforms: ${{ matrix.platform }} - labels: ${{ steps.meta-ghcr.outputs.labels }} - build-args: | - HAPPYVIEW_VERSION=${{ github.event.release.tag_name }} - cache-from: type=gha,scope=build-${{ matrix.platform }} - cache-to: type=gha,scope=build-${{ matrix.platform }},mode=max,ignore-error=true - outputs: type=image,"name=${{ env.GHCR_IMAGE }}",push-by-digest=true,name-canonical=true,push=true - - # TODO: Re-enable once ATCR rate limit issue is resolved - # - name: Push to ATCR by digest - # id: build-atcr - # continue-on-error: true - # uses: docker/build-push-action@v5 - # with: - # context: . - # platforms: ${{ matrix.platform }} - # labels: ${{ steps.meta-atcr.outputs.labels }} - # build-args: | - # HAPPYVIEW_VERSION=${{ github.event.release.tag_name }} - # cache-from: type=gha,scope=build-${{ matrix.platform }} - # outputs: type=image,"name=${{ env.ATCR_IMAGE }}",push-by-digest=true,name-canonical=true,push=true - - - name: Create GHCR manifest - run: | - GHCR_TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta-ghcr.outputs.json }}') - docker buildx imagetools create --append $GHCR_TAGS \ - ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }} 2>/dev/null || \ - docker buildx imagetools create $GHCR_TAGS \ - ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }} - - # TODO: Re-enable once ATCR rate limit issue is resolved - # - name: Create ATCR manifest - # if: steps.build-atcr.outcome == 'success' - # continue-on-error: true - # run: | - # ATCR_TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta-atcr.outputs.json }}') - # if [ -n "$ATCR_TAGS" ]; then - # docker buildx imagetools create --append $ATCR_TAGS \ - # ${{ env.ATCR_IMAGE }}@${{ steps.build-atcr.outputs.digest }} 2>/dev/null || \ - # docker buildx imagetools create $ATCR_TAGS \ - # ${{ env.ATCR_IMAGE }}@${{ steps.build-atcr.outputs.digest }} - # fi - diff --git a/.github/workflows/merge-main-to-dev.yml b/.github/workflows/merge-main-to-dev.yml index e381a61..b240166 100644 --- a/.github/workflows/merge-main-to-dev.yml +++ b/.github/workflows/merge-main-to-dev.yml @@ -2,7 +2,7 @@ name: Merge main to dev on: workflow_run: - workflows: [Release] + workflows: [CI] types: [completed] branches: [main] diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml deleted file mode 100644 index dedd431..0000000 --- a/.github/workflows/mirror.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Mirror to Tangled - -on: [push, delete] - -jobs: - mirror: - runs-on: depot-ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: yesolutions/mirror-action@master - with: - REMOTE: "git@tangled.org:gamesgamesgamesgames.games/happyview" - GIT_SSH_PRIVATE_KEY: ${{ secrets.TANGLED_SSH_PRIVATE_KEY }} - GIT_SSH_NO_VERIFY_HOST: "true" diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml deleted file mode 100644 index 52ec657..0000000 --- a/.github/workflows/release-sdk.yml +++ /dev/null @@ -1,185 +0,0 @@ -name: Release SDK - -on: - push: - branches: [main, dev] - paths: - - ".github/workflows/release-sdk.yml" - - "packages/oauth-client/**" - - "packages/oauth-client-browser/**" - - "packages/lex-agent/**" - pull_request: - branches: [main, dev] - paths: - - ".github/workflows/release-sdk.yml" - - "packages/oauth-client/**" - - "packages/oauth-client-browser/**" - - "packages/lex-agent/**" - -jobs: - sdk-tests: - runs-on: depot-ubuntu-24.04 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Install dependencies - run: bun install - - - name: Build SDK packages - run: bun run --filter '@happyview/*' build - - - name: Typecheck SDK packages - run: bun run --filter '@happyview/*' typecheck - - - name: Test SDK packages - run: bun run --filter '@happyview/*' test - - release-oauth-client: - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') - needs: [sdk-tests] - runs-on: depot-ubuntu-24.04 - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - - - name: Install dependencies - run: bun install - - - name: Build oauth-client - run: bun run --filter '@happyview/oauth-client' build - - - name: Configure npm auth for bun publish - run: echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > .npmrc - - - name: Release oauth-client - env: - GITHUB_TOKEN: ${{ secrets.DISPATCH_GH_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - working-directory: packages/oauth-client - run: npx semantic-release - - release-oauth-client-browser: - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') - needs: [release-oauth-client] - runs-on: depot-ubuntu-24.04 - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - - - name: Install dependencies - run: bun install - - - name: Build SDK packages - run: bun run --filter '@happyview/*' build - - - name: Resolve workspace dependencies to published versions - run: | - if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then - DIST_TAG="latest" - else - DIST_TAG="dev" - fi - CORE_VERSION=$(npm view @happyview/oauth-client@${DIST_TAG} version) - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('packages/oauth-client-browser/package.json', 'utf8')); - if (pkg.dependencies['@happyview/oauth-client']?.startsWith('workspace:')) { - pkg.dependencies['@happyview/oauth-client'] = '^${CORE_VERSION}'; - fs.writeFileSync('packages/oauth-client-browser/package.json', JSON.stringify(pkg, null, 2) + '\n'); - } - " - - - name: Configure npm auth for bun publish - run: echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > .npmrc - - - name: Release oauth-client-browser - env: - GITHUB_TOKEN: ${{ secrets.DISPATCH_GH_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - working-directory: packages/oauth-client-browser - run: npx semantic-release - - release-lex-agent: - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') - needs: [release-oauth-client] - runs-on: depot-ubuntu-24.04 - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - - - name: Install dependencies - run: bun install - - - name: Build SDK packages - run: bun run --filter '@happyview/*' build - - - name: Resolve workspace dependencies to published versions - run: | - if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then - DIST_TAG="latest" - else - DIST_TAG="dev" - fi - CORE_VERSION=$(npm view @happyview/oauth-client@${DIST_TAG} version) - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('packages/lex-agent/package.json', 'utf8')); - if (pkg.dependencies['@happyview/oauth-client']?.startsWith('workspace:')) { - pkg.dependencies['@happyview/oauth-client'] = '^${CORE_VERSION}'; - fs.writeFileSync('packages/lex-agent/package.json', JSON.stringify(pkg, null, 2) + '\n'); - } - " - - - name: Configure npm auth for bun publish - run: echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > .npmrc - - - name: Release lex-agent - env: - GITHUB_TOKEN: ${{ secrets.DISPATCH_GH_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - working-directory: packages/lex-agent - run: npx semantic-release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a0eb717..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,144 +0,0 @@ -name: Release - -on: - push: - branches: [main, dev] - paths-ignore: - - ".github/workflows/mirror.yml" - - ".github/workflows/release-sdk.yml" - - "packages/**" - pull_request: - branches: [main, dev] - paths-ignore: - - ".github/workflows/mirror.yml" - - ".github/workflows/release-sdk.yml" - - "packages/**" - -env: - SQLX_OFFLINE: true - -jobs: - unit-tests: - runs-on: depot-ubuntu-24.04 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Cache cargo dependencies - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - key: cargo-deps-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} - restore-keys: | - cargo-deps-${{ runner.os }}- - - - name: Run unit tests - run: cargo test --lib - - e2e-tests: - runs-on: depot-ubuntu-24.04 - - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_USER: happyview - POSTGRES_PASSWORD: happyview - POSTGRES_DB: happyview_test - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Cache cargo dependencies - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - key: cargo-deps-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} - restore-keys: | - cargo-deps-${{ runner.os }}- - - - name: Add WASM target - run: rustup target add wasm32-unknown-unknown - - - name: Build test plugin - run: cargo build --manifest-path tests/fixtures/test_plugin/Cargo.toml --target wasm32-unknown-unknown --release - - - name: Run e2e tests - env: - TEST_DATABASE_URL: postgres://happyview:happyview@localhost:5432/happyview_test - run: cargo test --tests - - frontend: - runs-on: depot-ubuntu-24.04 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - - - name: Build frontend - working-directory: web - run: npm ci && npm run build - - lint: - runs-on: depot-ubuntu-24.04 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Cache cargo dependencies - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - key: cargo-deps-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} - restore-keys: | - cargo-deps-${{ runner.os }}- - - - name: Check formatting - run: cargo fmt -- --check - - - name: Clippy - run: cargo clippy --all-targets -- -D warnings - - release: - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') - needs: [unit-tests, e2e-tests, frontend, lint] - runs-on: depot-ubuntu-24.04 - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Release - env: - GITHUB_TOKEN: ${{ secrets.DISPATCH_GH_TOKEN }} - run: npx semantic-release -- 2.51.2 From a52ee7f8275c1459adc8534032e459388f5bc417 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 10:55:05 -0500 Subject: [PATCH 17/56] ci: update Node-based actions Signed-off-by: Trezy --- .github/workflows/ci.yml | 46 ++++++++++++------------- .github/workflows/merge-main-to-dev.yml | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c39b4c..31f2c51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: postgres: ${{ steps.filter.outputs.postgres }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Detect changed paths id: filter @@ -55,10 +55,10 @@ jobs: runs-on: depot-ubuntu-24.04 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Cache cargo dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -90,10 +90,10 @@ jobs: --health-retries 5 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Cache cargo dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -119,10 +119,10 @@ jobs: runs-on: depot-ubuntu-24.04 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 @@ -136,10 +136,10 @@ jobs: runs-on: depot-ubuntu-24.04 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Cache cargo dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -168,12 +168,12 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 @@ -191,7 +191,7 @@ jobs: runs-on: depot-ubuntu-24.04 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -219,7 +219,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -227,7 +227,7 @@ jobs: uses: oven-sh/setup-bun@v2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 @@ -258,7 +258,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -266,7 +266,7 @@ jobs: uses: oven-sh/setup-bun@v2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 @@ -314,7 +314,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -322,7 +322,7 @@ jobs: uses: oven-sh/setup-bun@v2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 @@ -370,7 +370,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -378,7 +378,7 @@ jobs: uses: oven-sh/setup-bun@v2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 @@ -438,7 +438,7 @@ jobs: ATCR_IMAGE: atcr.io/${{ secrets.ATCR_NAMESPACE }}/happyview steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -545,7 +545,7 @@ jobs: GHCR_POSTGRES_IMAGE: ghcr.io/${{ github.repository }}-postgres steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -574,7 +574,7 @@ jobs: if: github.event_name == 'push' || github.event_name == 'delete' runs-on: depot-ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/merge-main-to-dev.yml b/.github/workflows/merge-main-to-dev.yml index b240166..a923be4 100644 --- a/.github/workflows/merge-main-to-dev.yml +++ b/.github/workflows/merge-main-to-dev.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 ref: dev -- 2.51.2 From a198cb5c0a1d36a97ab5cf6239b9ca7a88eed4bd Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 10:56:23 -0500 Subject: [PATCH 18/56] ci: add test builds to cache Signed-off-by: Trezy --- .github/workflows/ci.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31f2c51..ed2df38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,15 +57,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - name: Cache cargo dependencies + - name: Cache cargo uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git - key: cargo-deps-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} restore-keys: | - cargo-deps-${{ runner.os }}- + cargo-${{ runner.os }}- - name: Run unit tests run: cargo test --lib @@ -92,15 +93,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - name: Cache cargo dependencies + - name: Cache cargo uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git - key: cargo-deps-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} restore-keys: | - cargo-deps-${{ runner.os }}- + cargo-${{ runner.os }}- - name: Add WASM target run: rustup target add wasm32-unknown-unknown -- 2.51.2 From 6bfe3cba76580f3394f4a71f55d356e0ba572a04 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 11:52:36 -0500 Subject: [PATCH 19/56] ci: trigger docker build from the main ci workflow Signed-off-by: Trezy --- .github/workflows/ci.yml | 31 ++++++++++++++++++++++--------- .releaserc.json | 3 ++- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed2df38..8652c19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,9 @@ name: CI on: push: - branches: [main, dev, ci/merge-workflows] + branches: [main, dev] pull_request: - branches: [main, dev, ci/merge-workflows] - release: - types: [published] + branches: [main, dev] delete: env: @@ -166,6 +164,8 @@ jobs: && needs.changes.outputs.server == 'true' needs: [changes, unit-tests, e2e-tests, frontend, lint] runs-on: depot-ubuntu-24.04 + outputs: + version: ${{ steps.semantic.outputs.version }} permissions: contents: write steps: @@ -180,6 +180,7 @@ jobs: node-version: 22 - name: Release + id: semantic env: GITHUB_TOKEN: ${{ secrets.DISPATCH_GH_TOKEN }} run: npx semantic-release @@ -421,7 +422,8 @@ jobs: # Docker — main app image (triggered by GitHub release) # --------------------------------------------------------------------------- docker: - if: github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v') + needs: release + if: needs.release.outputs.version != '' runs-on: ${{ matrix.runs-on }} permissions: contents: read @@ -460,15 +462,26 @@ jobs: # username: ${{ secrets.ATCR_USERNAME }} # password: ${{ secrets.ATCR_PASSWORD }} + - name: Parse version + id: version + run: | + VERSION="${{ needs.release.outputs.version }}" + MAJOR="${VERSION%%.*}" + MINOR="${VERSION#*.}" + MINOR="${MINOR%%.*}" + echo "full=${VERSION}" >> "$GITHUB_OUTPUT" + echo "major=${MAJOR}" >> "$GITHUB_OUTPUT" + echo "major_minor=${MAJOR}.${MINOR}" >> "$GITHUB_OUTPUT" + - name: Extract GHCR metadata id: meta-ghcr uses: docker/metadata-action@v5 with: images: ${{ env.GHCR_IMAGE }} tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} + type=raw,value=${{ steps.version.outputs.full }} + type=raw,value=${{ steps.version.outputs.major_minor }} + type=raw,value=${{ steps.version.outputs.major }} type=sha # TODO: Re-enable once ATCR rate limit issue is resolved @@ -491,7 +504,7 @@ jobs: platforms: ${{ matrix.platform }} labels: ${{ steps.meta-ghcr.outputs.labels }} build-args: | - HAPPYVIEW_VERSION=${{ github.event.release.tag_name }} + HAPPYVIEW_VERSION=v${{ needs.release.outputs.version }} cache-from: type=gha,scope=build-${{ matrix.platform }} cache-to: type=gha,scope=build-${{ matrix.platform }},mode=max,ignore-error=true outputs: type=image,"name=${{ env.GHCR_IMAGE }}",push-by-digest=true,name-canonical=true,push=true diff --git a/.releaserc.json b/.releaserc.json index 2c0efff..f031dec 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -7,6 +7,7 @@ "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", - "@semantic-release/github" + "@semantic-release/github", + "semantic-release-gha-output" ] } -- 2.51.2 From 267ab7a0a31b8a9f385acef1b4142d81869cf648 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 09:54:11 -0500 Subject: [PATCH 20/56] feat(xrpc-proxy): add settings to control the XRPC proxy Signed-off-by: Trezy --- web/src/lib/api.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 25dadf5..701ad5a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -381,6 +381,23 @@ export function updateProxyConfig(config: ProxyConfig) { }); } +// Proxy config +export type ProxyConfig = { + mode: "disabled" | "open" | "allowlist" | "blocklist" + nsids: string[] +} + +export function getProxyConfig() { + return apiFetch("/admin/settings/xrpc-proxy") +} + +export function updateProxyConfig(config: ProxyConfig) { + return apiFetch("/admin/settings/xrpc-proxy", { + method: "PUT", + body: JSON.stringify(config), + }) +} + // Labelers export function getLabelers() { return apiFetch("/admin/labelers"); -- 2.51.2 From a97c939acb414943d4f63a832eba1b26a585c102 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 09:54:31 -0500 Subject: [PATCH 21/56] docs: add info about the XRPC proxy Signed-off-by: Trezy --- packages/docs/docs/getting-started/dashboard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/docs/getting-started/dashboard.md b/packages/docs/docs/getting-started/dashboard.md index b5292bf..2873c02 100644 --- a/packages/docs/docs/getting-started/dashboard.md +++ b/packages/docs/docs/getting-started/dashboard.md @@ -87,7 +87,7 @@ Configure instance-level settings: application name, logo, terms of service URL, Control which unrecognized XRPC methods are forwarded to their resolved authority. Choose from four modes: **Disabled** (block all proxy requests), **Open** (proxy everything — the default), **Allowlist** (only proxy NSIDs matching your patterns), or **Blocklist** (proxy everything except matching patterns). Allowlist and blocklist modes accept NSID patterns with trailing wildcards (e.g. `com.example.*`). Locally registered lexicons are always served regardless of this setting. See [XRPC Proxy](../reference/admin/xrpc-proxy.md) for the full API reference. -### ENV Variables +### Environment Variables View the current values of all environment variables that affect HappyView's behavior. This is a read-only view — values are set via your deployment environment, not the dashboard. -- 2.51.2 From 82d68b26041ed2cc37f8972288bedcf01ee863d9 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 09:59:07 -0500 Subject: [PATCH 22/56] docs: update the dashboard docs Signed-off-by: Trezy --- packages/docs/docs/getting-started/dashboard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/docs/getting-started/dashboard.md b/packages/docs/docs/getting-started/dashboard.md index 2873c02..b5292bf 100644 --- a/packages/docs/docs/getting-started/dashboard.md +++ b/packages/docs/docs/getting-started/dashboard.md @@ -87,7 +87,7 @@ Configure instance-level settings: application name, logo, terms of service URL, Control which unrecognized XRPC methods are forwarded to their resolved authority. Choose from four modes: **Disabled** (block all proxy requests), **Open** (proxy everything — the default), **Allowlist** (only proxy NSIDs matching your patterns), or **Blocklist** (proxy everything except matching patterns). Allowlist and blocklist modes accept NSID patterns with trailing wildcards (e.g. `com.example.*`). Locally registered lexicons are always served regardless of this setting. See [XRPC Proxy](../reference/admin/xrpc-proxy.md) for the full API reference. -### Environment Variables +### ENV Variables View the current values of all environment variables that affect HappyView's behavior. This is a read-only view — values are set via your deployment environment, not the dashboard. -- 2.51.2 From 4ca2bd89a9f2bfe383857f571cd5a4d271f9b85a Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 17:16:10 -0500 Subject: [PATCH 23/56] feat: permissioned spaces implementation Signed-off-by: Trezy --- src/spaces/notifications.rs | 86 +++++++++++ src/spaces/sync.rs | 293 ++++++++++++++++++++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 src/spaces/notifications.rs create mode 100644 src/spaces/sync.rs diff --git a/src/spaces/notifications.rs b/src/spaces/notifications.rs new file mode 100644 index 0000000..3d17e12 --- /dev/null +++ b/src/spaces/notifications.rs @@ -0,0 +1,86 @@ +use serde::Deserialize; +use uuid::Uuid; + +use crate::db::DatabaseBackend; +use crate::error::AppError; +use crate::spaces::db; +use crate::spaces::types::*; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteNotification { + pub space_uri: String, + pub author_did: String, + pub collection: String, + pub rkey: String, + pub action: WriteAction, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WriteAction { + Create, + Update, + Delete, +} + +/// Process a write notification by queuing a sync pull for the affected member. +/// +/// This marks the member's sync state as pending so the next sync pass picks it up. +pub async fn handle_write_notification( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + notification: &WriteNotification, +) -> Result<(), AppError> { + let existing = db::get_sync_state(pool, backend, space_id, ¬ification.author_did).await?; + + let state = SpaceSyncState { + id: existing + .map(|s| s.id) + .unwrap_or_else(|| Uuid::new_v4().to_string()), + space_id: space_id.to_string(), + member_did: notification.author_did.clone(), + cursor: None, + last_synced_at: None, + status: SyncStatus::Pending, + error: None, + }; + + db::upsert_sync_state(pool, backend, &state).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_action_deserializes() { + let action: WriteAction = serde_json::from_str("\"create\"").unwrap(); + assert!(matches!(action, WriteAction::Create)); + + let action: WriteAction = serde_json::from_str("\"update\"").unwrap(); + assert!(matches!(action, WriteAction::Update)); + + let action: WriteAction = serde_json::from_str("\"delete\"").unwrap(); + assert!(matches!(action, WriteAction::Delete)); + } + + #[test] + fn write_notification_deserializes() { + let json = r#"{ + "spaceUri": "ats://did:plc:owner/com.example.forum/main", + "authorDid": "did:plc:alice", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "action": "create" + }"#; + + let notif: WriteNotification = serde_json::from_str(json).unwrap(); + assert_eq!(notif.author_did, "did:plc:alice"); + assert_eq!(notif.collection, "com.example.forum.post"); + assert!(matches!(notif.action, WriteAction::Create)); + } +} diff --git a/src/spaces/sync.rs b/src/spaces/sync.rs new file mode 100644 index 0000000..40d5f72 --- /dev/null +++ b/src/spaces/sync.rs @@ -0,0 +1,293 @@ +use uuid::Uuid; + +use crate::db::DatabaseBackend; +use crate::db::now_rfc3339; +use crate::error::AppError; +use crate::profile::resolve_pds_endpoint; +use crate::spaces::types::*; +use crate::spaces::{db, members}; + +/// Sync all members of a space by pulling records from their PDSes. +pub async fn sync_space( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + plc_url: &str, + space_id: &str, + collections: &[String], +) -> Result { + let resolved = members::resolve_members(pool, backend, space_id).await?; + let mut results = Vec::new(); + + for member in &resolved { + let result = sync_member( + http, + pool, + backend, + plc_url, + space_id, + &member.did, + collections, + ) + .await; + + results.push(MemberSyncResult { + did: member.did.clone(), + records_synced: result.as_ref().map(|r| r.records_synced).unwrap_or(0), + error: result.err().map(|e| e.to_string()), + }); + } + + let total = results.iter().map(|r| r.records_synced).sum(); + + Ok(SyncSpaceResult { + members_processed: results.len(), + total_records_synced: total, + member_results: results, + }) +} + +/// Sync records from a single member's PDS for a given space. +pub async fn sync_member( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + plc_url: &str, + space_id: &str, + member_did: &str, + collections: &[String], +) -> Result { + let state_id = match db::get_sync_state(pool, backend, space_id, member_did).await? { + Some(s) => s.id, + None => { + let id = Uuid::new_v4().to_string(); + let initial = SpaceSyncState { + id: id.clone(), + space_id: space_id.to_string(), + member_did: member_did.to_string(), + cursor: None, + last_synced_at: None, + status: SyncStatus::Pending, + error: None, + }; + db::upsert_sync_state(pool, backend, &initial).await?; + id + } + }; + + // Mark as syncing + let syncing_state = SpaceSyncState { + id: state_id.clone(), + space_id: space_id.to_string(), + member_did: member_did.to_string(), + cursor: None, + last_synced_at: None, + status: SyncStatus::Syncing, + error: None, + }; + db::upsert_sync_state(pool, backend, &syncing_state).await?; + + let result = pull_member_records( + http, + pool, + backend, + plc_url, + space_id, + member_did, + collections, + ) + .await; + + match result { + Ok(summary) => { + let done = SpaceSyncState { + id: state_id, + space_id: space_id.to_string(), + member_did: member_did.to_string(), + cursor: summary.cursor.clone(), + last_synced_at: Some(now_rfc3339()), + status: SyncStatus::Synced, + error: None, + }; + db::upsert_sync_state(pool, backend, &done).await?; + Ok(summary) + } + Err(e) => { + let err_state = SpaceSyncState { + id: state_id, + space_id: space_id.to_string(), + member_did: member_did.to_string(), + cursor: None, + last_synced_at: Some(now_rfc3339()), + status: SyncStatus::Error, + error: Some(e.to_string()), + }; + db::upsert_sync_state(pool, backend, &err_state).await?; + Err(e) + } + } +} + +async fn pull_member_records( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + plc_url: &str, + space_id: &str, + member_did: &str, + collections: &[String], +) -> Result { + let pds_url = resolve_pds_endpoint(http, plc_url, member_did).await?; + let mut total_records = 0usize; + let mut last_cursor = None; + + for collection in collections { + let mut cursor: Option = None; + loop { + let (records, next_cursor) = fetch_records_page( + http, + &pds_url, + member_did, + collection, + cursor.as_deref(), + 100, + ) + .await?; + + if records.is_empty() { + break; + } + + for record in &records { + let uri = record["uri"].as_str().unwrap_or(""); + let rkey = extract_rkey(uri); + let cid = record["cid"].as_str().unwrap_or("").to_string(); + let value = record + .get("value") + .cloned() + .unwrap_or(serde_json::Value::Null); + + let space_record_uri = format!("ats://{space_id}/{member_did}/{collection}/{rkey}"); + + let space_record = SpaceRecord { + uri: space_record_uri, + space_id: space_id.to_string(), + author_did: member_did.to_string(), + collection: collection.clone(), + rkey: rkey.to_string(), + record: value, + cid, + indexed_at: now_rfc3339(), + }; + + db::upsert_space_record(pool, backend, &space_record).await?; + total_records += 1; + } + + last_cursor = next_cursor.clone(); + cursor = next_cursor; + + if cursor.is_none() { + break; + } + } + } + + Ok(MemberSyncSummary { + records_synced: total_records, + cursor: last_cursor, + }) +} + +async fn fetch_records_page( + http: &reqwest::Client, + pds_url: &str, + repo: &str, + collection: &str, + cursor: Option<&str>, + limit: u32, +) -> Result<(Vec, Option), AppError> { + let mut url = format!( + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={}", + pds_url.trim_end_matches('/'), + repo, + collection, + limit, + ); + + if let Some(c) = cursor { + url.push_str(&format!("&cursor={c}")); + } + + let resp = http + .get(&url) + .send() + .await + .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + return Err(AppError::Internal(format!( + "PDS listRecords failed with {status} for {repo}/{collection}" + ))); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid PDS response: {e}")))?; + + let records = body["records"].as_array().cloned().unwrap_or_default(); + + let next_cursor = body["cursor"].as_str().map(|s| s.to_string()); + + Ok((records, next_cursor)) +} + +fn extract_rkey(uri: &str) -> &str { + uri.rsplit('/').next().unwrap_or("") +} + +// --------------------------------------------------------------------------- +// Result types +// --------------------------------------------------------------------------- + +pub struct SyncSpaceResult { + pub members_processed: usize, + pub total_records_synced: usize, + pub member_results: Vec, +} + +pub struct MemberSyncResult { + pub did: String, + pub records_synced: usize, + pub error: Option, +} + +pub struct MemberSyncSummary { + pub records_synced: usize, + pub cursor: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_rkey_from_at_uri() { + assert_eq!( + extract_rkey("at://did:plc:abc/app.bsky.feed.post/3k2abc"), + "3k2abc" + ); + } + + #[test] + fn extract_rkey_from_empty() { + assert_eq!(extract_rkey(""), ""); + } + + #[test] + fn extract_rkey_no_slash() { + assert_eq!(extract_rkey("singlevalue"), "singlevalue"); + } +} -- 2.51.2 From b7bca04f1c8cab8ff38c4e67c7db8b4d9abe7aa1 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 1 May 2026 11:35:57 -0500 Subject: [PATCH 24/56] feat: add control for experiments to the dashboard Signed-off-by: Trezy --- web/src/lib/api.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 701ad5a..1b91418 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -381,6 +381,25 @@ export function updateProxyConfig(config: ProxyConfig) { }); } +// Feature Flags +export type FeatureFlag = { + key: string + name: string + description: string + enabled: boolean +} + +export function getFeatureFlags() { + return apiFetch("/admin/feature-flags") +} + +export function setFeatureFlag(key: string, enabled: boolean) { + if (enabled) { + return upsertSetting(key, "true") + } + return deleteSetting(key) +} + // Proxy config export type ProxyConfig = { mode: "disabled" | "open" | "allowlist" | "blocklist" -- 2.51.2 From 91684f369eb71455783753229638bed19552ef1e Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 1 May 2026 11:37:42 -0500 Subject: [PATCH 25/56] fix: remove public record aggregation from permissioned spaces Signed-off-by: Trezy --- src/spaces/notifications.rs | 86 ----------- src/spaces/sync.rs | 293 ------------------------------------ 2 files changed, 379 deletions(-) delete mode 100644 src/spaces/notifications.rs delete mode 100644 src/spaces/sync.rs diff --git a/src/spaces/notifications.rs b/src/spaces/notifications.rs deleted file mode 100644 index 3d17e12..0000000 --- a/src/spaces/notifications.rs +++ /dev/null @@ -1,86 +0,0 @@ -use serde::Deserialize; -use uuid::Uuid; - -use crate::db::DatabaseBackend; -use crate::error::AppError; -use crate::spaces::db; -use crate::spaces::types::*; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WriteNotification { - pub space_uri: String, - pub author_did: String, - pub collection: String, - pub rkey: String, - pub action: WriteAction, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum WriteAction { - Create, - Update, - Delete, -} - -/// Process a write notification by queuing a sync pull for the affected member. -/// -/// This marks the member's sync state as pending so the next sync pass picks it up. -pub async fn handle_write_notification( - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - space_id: &str, - notification: &WriteNotification, -) -> Result<(), AppError> { - let existing = db::get_sync_state(pool, backend, space_id, ¬ification.author_did).await?; - - let state = SpaceSyncState { - id: existing - .map(|s| s.id) - .unwrap_or_else(|| Uuid::new_v4().to_string()), - space_id: space_id.to_string(), - member_did: notification.author_did.clone(), - cursor: None, - last_synced_at: None, - status: SyncStatus::Pending, - error: None, - }; - - db::upsert_sync_state(pool, backend, &state).await?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn write_action_deserializes() { - let action: WriteAction = serde_json::from_str("\"create\"").unwrap(); - assert!(matches!(action, WriteAction::Create)); - - let action: WriteAction = serde_json::from_str("\"update\"").unwrap(); - assert!(matches!(action, WriteAction::Update)); - - let action: WriteAction = serde_json::from_str("\"delete\"").unwrap(); - assert!(matches!(action, WriteAction::Delete)); - } - - #[test] - fn write_notification_deserializes() { - let json = r#"{ - "spaceUri": "ats://did:plc:owner/com.example.forum/main", - "authorDid": "did:plc:alice", - "collection": "com.example.forum.post", - "rkey": "3k2abc", - "action": "create" - }"#; - - let notif: WriteNotification = serde_json::from_str(json).unwrap(); - assert_eq!(notif.author_did, "did:plc:alice"); - assert_eq!(notif.collection, "com.example.forum.post"); - assert!(matches!(notif.action, WriteAction::Create)); - } -} diff --git a/src/spaces/sync.rs b/src/spaces/sync.rs deleted file mode 100644 index 40d5f72..0000000 --- a/src/spaces/sync.rs +++ /dev/null @@ -1,293 +0,0 @@ -use uuid::Uuid; - -use crate::db::DatabaseBackend; -use crate::db::now_rfc3339; -use crate::error::AppError; -use crate::profile::resolve_pds_endpoint; -use crate::spaces::types::*; -use crate::spaces::{db, members}; - -/// Sync all members of a space by pulling records from their PDSes. -pub async fn sync_space( - http: &reqwest::Client, - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - plc_url: &str, - space_id: &str, - collections: &[String], -) -> Result { - let resolved = members::resolve_members(pool, backend, space_id).await?; - let mut results = Vec::new(); - - for member in &resolved { - let result = sync_member( - http, - pool, - backend, - plc_url, - space_id, - &member.did, - collections, - ) - .await; - - results.push(MemberSyncResult { - did: member.did.clone(), - records_synced: result.as_ref().map(|r| r.records_synced).unwrap_or(0), - error: result.err().map(|e| e.to_string()), - }); - } - - let total = results.iter().map(|r| r.records_synced).sum(); - - Ok(SyncSpaceResult { - members_processed: results.len(), - total_records_synced: total, - member_results: results, - }) -} - -/// Sync records from a single member's PDS for a given space. -pub async fn sync_member( - http: &reqwest::Client, - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - plc_url: &str, - space_id: &str, - member_did: &str, - collections: &[String], -) -> Result { - let state_id = match db::get_sync_state(pool, backend, space_id, member_did).await? { - Some(s) => s.id, - None => { - let id = Uuid::new_v4().to_string(); - let initial = SpaceSyncState { - id: id.clone(), - space_id: space_id.to_string(), - member_did: member_did.to_string(), - cursor: None, - last_synced_at: None, - status: SyncStatus::Pending, - error: None, - }; - db::upsert_sync_state(pool, backend, &initial).await?; - id - } - }; - - // Mark as syncing - let syncing_state = SpaceSyncState { - id: state_id.clone(), - space_id: space_id.to_string(), - member_did: member_did.to_string(), - cursor: None, - last_synced_at: None, - status: SyncStatus::Syncing, - error: None, - }; - db::upsert_sync_state(pool, backend, &syncing_state).await?; - - let result = pull_member_records( - http, - pool, - backend, - plc_url, - space_id, - member_did, - collections, - ) - .await; - - match result { - Ok(summary) => { - let done = SpaceSyncState { - id: state_id, - space_id: space_id.to_string(), - member_did: member_did.to_string(), - cursor: summary.cursor.clone(), - last_synced_at: Some(now_rfc3339()), - status: SyncStatus::Synced, - error: None, - }; - db::upsert_sync_state(pool, backend, &done).await?; - Ok(summary) - } - Err(e) => { - let err_state = SpaceSyncState { - id: state_id, - space_id: space_id.to_string(), - member_did: member_did.to_string(), - cursor: None, - last_synced_at: Some(now_rfc3339()), - status: SyncStatus::Error, - error: Some(e.to_string()), - }; - db::upsert_sync_state(pool, backend, &err_state).await?; - Err(e) - } - } -} - -async fn pull_member_records( - http: &reqwest::Client, - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - plc_url: &str, - space_id: &str, - member_did: &str, - collections: &[String], -) -> Result { - let pds_url = resolve_pds_endpoint(http, plc_url, member_did).await?; - let mut total_records = 0usize; - let mut last_cursor = None; - - for collection in collections { - let mut cursor: Option = None; - loop { - let (records, next_cursor) = fetch_records_page( - http, - &pds_url, - member_did, - collection, - cursor.as_deref(), - 100, - ) - .await?; - - if records.is_empty() { - break; - } - - for record in &records { - let uri = record["uri"].as_str().unwrap_or(""); - let rkey = extract_rkey(uri); - let cid = record["cid"].as_str().unwrap_or("").to_string(); - let value = record - .get("value") - .cloned() - .unwrap_or(serde_json::Value::Null); - - let space_record_uri = format!("ats://{space_id}/{member_did}/{collection}/{rkey}"); - - let space_record = SpaceRecord { - uri: space_record_uri, - space_id: space_id.to_string(), - author_did: member_did.to_string(), - collection: collection.clone(), - rkey: rkey.to_string(), - record: value, - cid, - indexed_at: now_rfc3339(), - }; - - db::upsert_space_record(pool, backend, &space_record).await?; - total_records += 1; - } - - last_cursor = next_cursor.clone(); - cursor = next_cursor; - - if cursor.is_none() { - break; - } - } - } - - Ok(MemberSyncSummary { - records_synced: total_records, - cursor: last_cursor, - }) -} - -async fn fetch_records_page( - http: &reqwest::Client, - pds_url: &str, - repo: &str, - collection: &str, - cursor: Option<&str>, - limit: u32, -) -> Result<(Vec, Option), AppError> { - let mut url = format!( - "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={}", - pds_url.trim_end_matches('/'), - repo, - collection, - limit, - ); - - if let Some(c) = cursor { - url.push_str(&format!("&cursor={c}")); - } - - let resp = http - .get(&url) - .send() - .await - .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; - - if !resp.status().is_success() { - let status = resp.status(); - return Err(AppError::Internal(format!( - "PDS listRecords failed with {status} for {repo}/{collection}" - ))); - } - - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| AppError::Internal(format!("invalid PDS response: {e}")))?; - - let records = body["records"].as_array().cloned().unwrap_or_default(); - - let next_cursor = body["cursor"].as_str().map(|s| s.to_string()); - - Ok((records, next_cursor)) -} - -fn extract_rkey(uri: &str) -> &str { - uri.rsplit('/').next().unwrap_or("") -} - -// --------------------------------------------------------------------------- -// Result types -// --------------------------------------------------------------------------- - -pub struct SyncSpaceResult { - pub members_processed: usize, - pub total_records_synced: usize, - pub member_results: Vec, -} - -pub struct MemberSyncResult { - pub did: String, - pub records_synced: usize, - pub error: Option, -} - -pub struct MemberSyncSummary { - pub records_synced: usize, - pub cursor: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extract_rkey_from_at_uri() { - assert_eq!( - extract_rkey("at://did:plc:abc/app.bsky.feed.post/3k2abc"), - "3k2abc" - ); - } - - #[test] - fn extract_rkey_from_empty() { - assert_eq!(extract_rkey(""), ""); - } - - #[test] - fn extract_rkey_no_slash() { - assert_eq!(extract_rkey("singlevalue"), "singlevalue"); - } -} -- 2.51.2 From 173153345cca38ea0ac96469d68bd33ae75b09e9 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 4 May 2026 23:30:14 -0500 Subject: [PATCH 26/56] feat: update JS SDKs to more closely match their @atproto cousins Signed-off-by: Trezy --- packages/docs/docs/sdk/oauth-client-browser.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/docs/docs/sdk/oauth-client-browser.md b/packages/docs/docs/sdk/oauth-client-browser.md index 376bf11..d047b4d 100644 --- a/packages/docs/docs/sdk/oauth-client-browser.md +++ b/packages/docs/docs/sdk/oauth-client-browser.md @@ -183,13 +183,6 @@ const data = await response.json(); Pass a relative path (prepends the HappyView instance URL) or a full URL (used as-is). -### Token info - -```typescript -const info = session.getTokenInfo(); -// { sub, scope, iss, aud } -``` - ## Revoke session ```typescript -- 2.51.2 From deffbd7cdc6314aa2d59c112bdf19f2406526c47 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 12:43:27 -0500 Subject: [PATCH 27/56] fix: remove duplicate exports in api.ts from merge Signed-off-by: Trezy --- web/src/lib/api.ts | 55 ---------------------------------------------- 1 file changed, 55 deletions(-) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1b91418..f05a348 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -345,25 +345,6 @@ export function setFeatureFlag(key: string, enabled: boolean) { return deleteSetting(key); } -// Feature Flags -export type FeatureFlag = { - key: string - name: string - description: string - enabled: boolean -} - -export function getFeatureFlags() { - return apiFetch("/admin/feature-flags") -} - -export function setFeatureFlag(key: string, enabled: boolean) { - if (enabled) { - return upsertSetting(key, "true") - } - return deleteSetting(key) -} - // Proxy config export type ProxyConfig = { mode: "disabled" | "open" | "allowlist" | "blocklist"; @@ -381,42 +362,6 @@ export function updateProxyConfig(config: ProxyConfig) { }); } -// Feature Flags -export type FeatureFlag = { - key: string - name: string - description: string - enabled: boolean -} - -export function getFeatureFlags() { - return apiFetch("/admin/feature-flags") -} - -export function setFeatureFlag(key: string, enabled: boolean) { - if (enabled) { - return upsertSetting(key, "true") - } - return deleteSetting(key) -} - -// Proxy config -export type ProxyConfig = { - mode: "disabled" | "open" | "allowlist" | "blocklist" - nsids: string[] -} - -export function getProxyConfig() { - return apiFetch("/admin/settings/xrpc-proxy") -} - -export function updateProxyConfig(config: ProxyConfig) { - return apiFetch("/admin/settings/xrpc-proxy", { - method: "PUT", - body: JSON.stringify(config), - }) -} - // Labelers export function getLabelers() { return apiFetch("/admin/labelers"); -- 2.51.2 From df77c025024b9eba1ab9011742545cfab5ea1bc9 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 12:47:57 -0500 Subject: [PATCH 28/56] fix: prevent errors with IPv6 addresses when building loopback clients Signed-off-by: Trezy --- .../oauth-client-browser/src/__tests__/util.test.ts | 10 +++++++++- packages/oauth-client-browser/src/util.ts | 6 +++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/oauth-client-browser/src/__tests__/util.test.ts b/packages/oauth-client-browser/src/__tests__/util.test.ts index 3ae7bc2..333faa1 100644 --- a/packages/oauth-client-browser/src/__tests__/util.test.ts +++ b/packages/oauth-client-browser/src/__tests__/util.test.ts @@ -55,7 +55,15 @@ describe("buildLoopbackClientId", () => { { hostname: "localhost", pathname: "/", port: "3000" }, "::1", ); - expect(result).toContain("http%3A%2F%2F%3A%3A1%3A3000"); + expect(result).toContain(encodeURIComponent("http://[::1]:3000/")); + }); + + test("does not double-bracket already-bracketed IPv6", () => { + const result = buildLoopbackClientId( + { hostname: "[::1]", pathname: "/", port: "3000" }, + "[::1]", + ); + expect(result).toContain(encodeURIComponent("http://[::1]:3000/")); }); test("accepts [::1] hostname", () => { diff --git a/packages/oauth-client-browser/src/util.ts b/packages/oauth-client-browser/src/util.ts index a98d455..a9bea58 100644 --- a/packages/oauth-client-browser/src/util.ts +++ b/packages/oauth-client-browser/src/util.ts @@ -10,8 +10,12 @@ export function buildLoopbackClientId( ); } + const host = + localhost.includes(":") && !localhost.startsWith("[") + ? `[${localhost}]` + : localhost; const port = location.port ? `:${location.port}` : ""; - const redirectUri = `http://${localhost}${port}${location.pathname}`; + const redirectUri = `http://${host}${port}${location.pathname}`; const pathname = location.pathname === "/" ? "" : location.pathname; const encodedRedirect = encodeURIComponent(redirectUri); -- 2.51.2 From 57c93d44db5bd6d8ce4e9f1a84c70f63fce486d4 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 12:54:11 -0500 Subject: [PATCH 29/56] ci: fix and split SDK tests Signed-off-by: Trezy --- .github/workflows/ci.yml | 125 +++++++++++++++++++++++++++++++++------ 1 file changed, 106 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8652c19..1fd554b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,10 @@ jobs: runs-on: depot-ubuntu-24.04 outputs: server: ${{ steps.filter.outputs.server }} - sdk: ${{ steps.filter.outputs.sdk }} + oauth-client: ${{ steps.filter.outputs.oauth-client }} + oauth-client-browser: ${{ steps.filter.outputs.oauth-client-browser }} + oauth-client-node: ${{ steps.filter.outputs.oauth-client-node }} + lex-agent: ${{ steps.filter.outputs.lex-agent }} postgres: ${{ steps.filter.outputs.postgres }} steps: - name: Checkout repository @@ -35,10 +38,16 @@ jobs: - '**' - '!packages/**' - '!.github/workflows/merge-main-to-dev.yml' - sdk: + oauth-client: + - 'packages/oauth-client/**' + oauth-client-browser: - 'packages/oauth-client/**' - 'packages/oauth-client-browser/**' + oauth-client-node: + - 'packages/oauth-client/**' - 'packages/oauth-client-node/**' + lex-agent: + - 'packages/oauth-client/**' - 'packages/lex-agent/**' postgres: - 'docker/Dockerfile.postgres' @@ -186,11 +195,11 @@ jobs: run: npx semantic-release # --------------------------------------------------------------------------- - # SDK — tests + per-package releases + # SDK — per-package tests + releases # --------------------------------------------------------------------------- - sdk-tests: + test-oauth-client: needs: changes - if: needs.changes.outputs.sdk == 'true' + if: needs.changes.outputs.oauth-client == 'true' runs-on: depot-ubuntu-24.04 steps: - name: Checkout repository @@ -202,21 +211,99 @@ jobs: - name: Install dependencies run: bun install - - name: Build SDK packages - run: bun run --filter '@happyview/*' build + - name: Build + run: bun run --filter '@happyview/oauth-client' build + + - name: Typecheck + run: bun run --filter '@happyview/oauth-client' typecheck + + - name: Test + run: bun run --filter '@happyview/oauth-client' test + + test-oauth-client-browser: + needs: changes + if: needs.changes.outputs.oauth-client-browser == 'true' + runs-on: depot-ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Build oauth-client + run: bun run --filter '@happyview/oauth-client' build + + - name: Build oauth-client-browser + run: bun run --filter '@happyview/oauth-client-browser' build + + - name: Typecheck + run: bun run --filter '@happyview/oauth-client-browser' typecheck + + - name: Test + run: bun run --filter '@happyview/oauth-client-browser' test + + test-oauth-client-node: + needs: changes + if: needs.changes.outputs.oauth-client-node == 'true' + runs-on: depot-ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Build oauth-client + run: bun run --filter '@happyview/oauth-client' build + + - name: Build oauth-client-node + run: bun run --filter '@happyview/oauth-client-node' build + + - name: Typecheck + run: bun run --filter '@happyview/oauth-client-node' typecheck + + - name: Test + run: bun run --filter '@happyview/oauth-client-node' test + + test-lex-agent: + needs: changes + if: needs.changes.outputs.lex-agent == 'true' + runs-on: depot-ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Build oauth-client + run: bun run --filter '@happyview/oauth-client' build + + - name: Build lex-agent + run: bun run --filter '@happyview/lex-agent' build - - name: Typecheck SDK packages - run: bun run --filter '@happyview/*' typecheck + - name: Typecheck + run: bun run --filter '@happyview/lex-agent' typecheck - - name: Test SDK packages - run: bun run --filter '@happyview/*' test + - name: Test + run: bun run --filter '@happyview/lex-agent' test release-oauth-client: if: >- github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') - && needs.changes.outputs.sdk == 'true' - needs: [changes, sdk-tests] + && needs.changes.outputs.oauth-client == 'true' + needs: [changes, test-oauth-client] runs-on: depot-ubuntu-24.04 permissions: contents: write @@ -254,8 +341,8 @@ jobs: if: >- github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') - && needs.changes.outputs.sdk == 'true' - needs: [changes, release-oauth-client] + && needs.changes.outputs.oauth-client-browser == 'true' + needs: [changes, test-oauth-client-browser, release-oauth-client] runs-on: depot-ubuntu-24.04 permissions: contents: write @@ -310,8 +397,8 @@ jobs: if: >- github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') - && needs.changes.outputs.sdk == 'true' - needs: [changes, release-oauth-client] + && needs.changes.outputs.oauth-client-node == 'true' + needs: [changes, test-oauth-client-node, release-oauth-client] runs-on: depot-ubuntu-24.04 permissions: contents: write @@ -366,8 +453,8 @@ jobs: if: >- github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') - && needs.changes.outputs.sdk == 'true' - needs: [changes, release-oauth-client] + && needs.changes.outputs.lex-agent == 'true' + needs: [changes, test-lex-agent, release-oauth-client] runs-on: depot-ubuntu-24.04 permissions: contents: write -- 2.51.2 From 992e6e0ed93c94ad48e45f91fcae3d9b4e1f6a88 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 13:03:45 -0500 Subject: [PATCH 30/56] fix: update scopes type Signed-off-by: Trezy --- packages/oauth-client/src/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/oauth-client/src/session.ts b/packages/oauth-client/src/session.ts index 744d8bb..b1e5aa6 100644 --- a/packages/oauth-client/src/session.ts +++ b/packages/oauth-client/src/session.ts @@ -48,7 +48,7 @@ export class HappyViewSession { constructor(options: HappyViewSessionOptions) { this.did = options.did; - this.scopes = options.scopes; + this.scopes = options.scopes ? options.scopes.split(" ") : []; this.dpopKey = options.dpopKey; this.accessToken = options.accessToken; this.clientKey = options.clientKey; -- 2.51.2 From 73078bf370f5f73a0375b441388f8d491efac550 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 13:06:40 -0500 Subject: [PATCH 31/56] fix: prevent false match rewrites when a base path is in use Signed-off-by: Trezy --- src/server.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server.rs b/src/server.rs index 31fc30c..26bc268 100644 --- a/src/server.rs +++ b/src/server.rs @@ -149,7 +149,9 @@ pub fn router(state: AppState) -> Router { && let Some(loc) = response.headers().get(header::LOCATION) && let Ok(loc_str) = loc.to_str() && loc_str.starts_with('/') - && !loc_str.starts_with(&bp) + && !(loc_str.starts_with(&bp) + && (loc_str.len() == bp.len() + || loc_str.as_bytes().get(bp.len()) == Some(&b'/'))) && let Ok(new_loc) = format!("{}{}", bp, loc_str).parse() { response.headers_mut().insert(header::LOCATION, new_loc); -- 2.51.2 From 8840ac05b83da48b90c626777b4e044ef14d9e08 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 13:08:58 -0500 Subject: [PATCH 32/56] fix: cleanup broken sessions Signed-off-by: Trezy --- src/oauth/pds_write.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/oauth/pds_write.rs b/src/oauth/pds_write.rs index a0d2aa3..3da90b4 100644 --- a/src/oauth/pds_write.rs +++ b/src/oauth/pds_write.rs @@ -190,13 +190,16 @@ async fn retry_after_refresh( api_client_id = %creds.session.api_client_id, "refresh token permanently invalid, deleting broken session" ); - let _ = super::sessions::delete_dpop_session( + if let Err(del_err) = super::sessions::delete_dpop_session( pool, backend, &creds.session.api_client_id, &creds.session.user_did, ) - .await; + .await + { + tracing::error!(%del_err, "failed to delete broken DPoP session"); + } return Err(AppError::Auth( "session expired, please re-authenticate".into(), )); -- 2.51.2 From 35fbcc7bea6e85f4a20798d5b42d3ac67a8a404e Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 13:10:23 -0500 Subject: [PATCH 33/56] ci: install `semantic-release-gha-output` before running core release Signed-off-by: Trezy --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fd554b..080b041 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,7 +192,7 @@ jobs: id: semantic env: GITHUB_TOKEN: ${{ secrets.DISPATCH_GH_TOKEN }} - run: npx semantic-release + run: npx -p semantic-release -p semantic-release-gha-output semantic-release # --------------------------------------------------------------------------- # SDK — per-package tests + releases -- 2.51.2 From 28b2e8115ae2069deaf940e0fca532bfbb79be38 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 13:26:11 -0500 Subject: [PATCH 34/56] docs: add clarity to session hooks docs Signed-off-by: Trezy --- packages/docs/docs/sdk/oauth-client-browser.md | 2 +- packages/docs/docs/sdk/oauth-client-node.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/docs/docs/sdk/oauth-client-browser.md b/packages/docs/docs/sdk/oauth-client-browser.md index d047b4d..eef3bbe 100644 --- a/packages/docs/docs/sdk/oauth-client-browser.md +++ b/packages/docs/docs/sdk/oauth-client-browser.md @@ -228,7 +228,7 @@ const client = new HappyViewBrowserClient({ }); ``` -- `onSessionUpdate(did)` fires after a session is registered (from `callback()`) or restored. +- `onSessionUpdate(did)` fires after a new session is registered (from `callback()`). - `onSessionDelete(did)` fires after a session is revoked (from `revoke()`, `logout()`, or `session.signOut()`). ## Error handling diff --git a/packages/docs/docs/sdk/oauth-client-node.md b/packages/docs/docs/sdk/oauth-client-node.md index 25d9100..2b5d2b2 100644 --- a/packages/docs/docs/sdk/oauth-client-node.md +++ b/packages/docs/docs/sdk/oauth-client-node.md @@ -216,7 +216,7 @@ const client = new HappyViewNodeClient({ }); ``` -- `onSessionUpdate(did)` fires after a session is registered (from `callback()`) or restored. +- `onSessionUpdate(did)` fires after a new session is registered (from `callback()`). - `onSessionDelete(did)` fires after a session is revoked (from `revoke()` or `session.signOut()`). ## Error handling -- 2.51.2 From 9fe1989140df60a1b02a531d488e2a4c957046ef Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 8 May 2026 13:28:12 -0500 Subject: [PATCH 35/56] fix: cleanup derelict items after successful auth Signed-off-by: Trezy --- packages/oauth-client-node/src/node-client.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/oauth-client-node/src/node-client.ts b/packages/oauth-client-node/src/node-client.ts index 2ca23f9..f14bbc1 100644 --- a/packages/oauth-client-node/src/node-client.ts +++ b/packages/oauth-client-node/src/node-client.ts @@ -57,6 +57,7 @@ interface PendingAuthState { tokenEndpoint: string; state: string; issuer: string; + authUrl?: string; } interface AuthServerMetadata { @@ -187,6 +188,8 @@ export class HappyViewNodeClient extends HappyViewOAuthClient { request_uri: parData.request_uri, }), ); + pendingState.authUrl = url.href; + await this.storage.set(`pending-auth:${state}`, JSON.stringify(pendingState)); await this.storage.set(`pending-auth-url:${url.href}`, state); return url; } @@ -194,6 +197,8 @@ export class HappyViewNodeClient extends HappyViewOAuthClient { const url = new URL( `${authMeta.authorization_endpoint}?${authParams}`, ); + pendingState.authUrl = url.href; + await this.storage.set(`pending-auth:${state}`, JSON.stringify(pendingState)); await this.storage.set(`pending-auth-url:${url.href}`, state); return url; } @@ -320,6 +325,9 @@ export class HappyViewNodeClient extends HappyViewOAuthClient { }); await this.storage.delete(`pending-auth:${state}`); + if (pending.authUrl) { + await this.storage.delete(`pending-auth-url:${pending.authUrl}`); + } return { session, state }; } catch (err) { -- 2.51.2 From 71efd5683d7303286b8c7a6634b7b0da3f8c180a Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 11:43:13 -0500 Subject: [PATCH 36/56] feat: refactor permissioned spaces to better align with Dan's implementation Signed-off-by: Trezy --- .../postgres/20260511000000_add_space_did.sql | 4 + .../20260511100000_add_space_revision.sql | 1 + .../sqlite/20260511000000_add_space_did.sql | 3 + .../20260511100000_add_space_revision.sql | 1 + src/lua/atproto_api.rs | 10 +- src/lua/context.rs | 16 +- src/lua/mod.rs | 2 +- src/spaces/auth.rs | 38 +- src/spaces/credential.rs | 106 +++ src/spaces/db.rs | 309 ++++++-- src/spaces/members.rs | 11 +- src/spaces/mod.rs | 28 +- src/spaces/routes.rs | 706 +++++++++++++++--- src/spaces/types.rs | 5 +- 14 files changed, 986 insertions(+), 254 deletions(-) create mode 100644 migrations/postgres/20260511000000_add_space_did.sql create mode 100644 migrations/postgres/20260511100000_add_space_revision.sql create mode 100644 migrations/sqlite/20260511000000_add_space_did.sql create mode 100644 migrations/sqlite/20260511100000_add_space_revision.sql diff --git a/migrations/postgres/20260511000000_add_space_did.sql b/migrations/postgres/20260511000000_add_space_did.sql new file mode 100644 index 0000000..e33ff12 --- /dev/null +++ b/migrations/postgres/20260511000000_add_space_did.sql @@ -0,0 +1,4 @@ +ALTER TABLE spaces ADD COLUMN did TEXT; +UPDATE spaces SET did = owner_did; +ALTER TABLE spaces ALTER COLUMN did SET NOT NULL; +CREATE UNIQUE INDEX idx_spaces_did_type_skey ON spaces(did, type_nsid, skey); diff --git a/migrations/postgres/20260511100000_add_space_revision.sql b/migrations/postgres/20260511100000_add_space_revision.sql new file mode 100644 index 0000000..800df84 --- /dev/null +++ b/migrations/postgres/20260511100000_add_space_revision.sql @@ -0,0 +1 @@ +ALTER TABLE spaces ADD COLUMN revision TEXT; diff --git a/migrations/sqlite/20260511000000_add_space_did.sql b/migrations/sqlite/20260511000000_add_space_did.sql new file mode 100644 index 0000000..07747e4 --- /dev/null +++ b/migrations/sqlite/20260511000000_add_space_did.sql @@ -0,0 +1,3 @@ +ALTER TABLE spaces ADD COLUMN did TEXT; +UPDATE spaces SET did = owner_did; +CREATE UNIQUE INDEX idx_spaces_did_type_skey ON spaces(did, type_nsid, skey); diff --git a/migrations/sqlite/20260511100000_add_space_revision.sql b/migrations/sqlite/20260511100000_add_space_revision.sql new file mode 100644 index 0000000..800df84 --- /dev/null +++ b/migrations/sqlite/20260511100000_add_space_revision.sql @@ -0,0 +1 @@ +ALTER TABLE spaces ADD COLUMN revision TEXT; diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs index bde9321..e1669a6 100644 --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -280,7 +280,7 @@ pub fn register_atproto_api( let space = crate::spaces::db::get_space_by_address( &state.db, state.db_backend, - &uri.owner_did, + &uri.did, &uri.type_nsid, &uri.skey, ) @@ -321,7 +321,7 @@ pub fn register_atproto_api( let space = crate::spaces::db::get_space_by_address( &state.db, state.db_backend, - &uri.owner_did, + &uri.did, &uri.type_nsid, &uri.skey, ) @@ -361,7 +361,7 @@ pub fn register_atproto_api( let space = crate::spaces::db::get_space_by_address( &state.db, state.db_backend, - &uri.owner_did, + &uri.did, &uri.type_nsid, &uri.skey, ) @@ -416,7 +416,7 @@ pub fn register_atproto_api( let space = crate::spaces::db::get_space_by_address( &state.db, state.db_backend, - &uri.owner_did, + &uri.did, &uri.type_nsid, &uri.skey, ) @@ -433,9 +433,11 @@ pub fn register_atproto_api( &state.db, state.db_backend, &space.id, + None, collection.as_deref(), limit.min(100), cursor.as_deref(), + false, ) .await .map_err(|e| mlua::Error::runtime(format!("record query failed: {e}")))?; diff --git a/src/lua/context.rs b/src/lua/context.rs index eab578e..66e25be 100644 --- a/src/lua/context.rs +++ b/src/lua/context.rs @@ -5,8 +5,9 @@ use std::collections::HashMap; /// Optional space context passed to Lua scripts when the request is space-scoped. #[derive(Debug, Clone)] pub struct SpaceContext { - pub space_uri: String, + pub space: String, pub space_id: String, + pub did: String, pub owner_did: String, pub type_nsid: String, pub skey: String, @@ -17,8 +18,9 @@ fn set_space_context(lua: &Lua, space: Option<&SpaceContext>) -> LuaResult<()> { match space { Some(ctx) => { let table = lua.create_table()?; - table.set("space_uri", ctx.space_uri.as_str())?; + table.set("space", ctx.space.as_str())?; table.set("space_id", ctx.space_id.as_str())?; + table.set("did", ctx.did.as_str())?; table.set("owner_did", ctx.owner_did.as_str())?; table.set("type_nsid", ctx.type_nsid.as_str())?; table.set("skey", ctx.skey.as_str())?; @@ -269,8 +271,9 @@ mod tests { let lua = create_sandbox().unwrap(); let params = HashMap::new(); let space = SpaceContext { - space_uri: "ats://did:plc:owner/com.example.forum/main".into(), + space: "ats://did:plc:owner/com.example.forum/main".into(), space_id: "space-123".into(), + did: "did:plc:owner".into(), owner_did: "did:plc:owner".into(), type_nsid: "com.example.forum".into(), skey: "main".into(), @@ -288,14 +291,11 @@ mod tests { let globals = lua.globals(); let space_table: mlua::Table = globals.get("space").unwrap(); assert_eq!( - space_table.get::("space_uri").unwrap(), + space_table.get::("space").unwrap(), "ats://did:plc:owner/com.example.forum/main" ); assert_eq!(space_table.get::("space_id").unwrap(), "space-123"); - assert_eq!( - space_table.get::("owner_did").unwrap(), - "did:plc:owner" - ); + assert_eq!(space_table.get::("did").unwrap(), "did:plc:owner"); } #[test] diff --git a/src/lua/mod.rs b/src/lua/mod.rs index 69cb3cc..58733eb 100644 --- a/src/lua/mod.rs +++ b/src/lua/mod.rs @@ -5,7 +5,7 @@ mod execute; mod http_api; mod record; pub(crate) mod sandbox; -mod tid; +pub(crate) mod tid; mod xrpc_api; #[allow(unused_imports)] diff --git a/src/spaces/auth.rs b/src/spaces/auth.rs index 2b70089..9996c1b 100644 --- a/src/spaces/auth.rs +++ b/src/spaces/auth.rs @@ -9,7 +9,7 @@ use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; use crate::error::AppError; use crate::plugin::encryption::{decrypt, encrypt}; use crate::spaces::credential::{ - DEFAULT_CREDENTIAL_TTL_SECS, SpaceCredentialClaims, sign_credential, verify_credential, + DEFAULT_CREDENTIAL_TTL_SECS, SpaceCredentialClaims, sign_credential, }; use crate::spaces::types::{AccessMode, Space}; @@ -37,9 +37,9 @@ pub async fn issue_credential( let exp = now + DEFAULT_CREDENTIAL_TTL_SECS; let claims = SpaceCredentialClaims { - iss: space.owner_did.clone(), + iss: space.did.clone(), sub: subject_did.to_string(), - space: format!("{}/{}/{}", space.owner_did, space.type_nsid, space.skey), + space: format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey), scope: "read".into(), iat: now, exp, @@ -57,19 +57,6 @@ pub async fn issue_credential( Ok(IssuedCredential { token, expires_at }) } -pub async fn refresh_credential( - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - encryption_key: &[u8; 32], - space: &Space, - current_token: &str, -) -> Result { - let public_jwk = get_public_key(pool, backend, encryption_key, space).await?; - let claims = verify_credential(current_token, &public_jwk)?; - - issue_credential(pool, backend, encryption_key, space, &claims.sub, None).await -} - pub fn check_app_access(space: &Space, client_id: Option<&str>) -> Result<(), AppError> { let Some(client_id) = client_id else { return Ok(()); @@ -148,7 +135,7 @@ async fn get_or_create_signing_key( sqlx::query(&insert_sql) .bind(Uuid::new_v4().to_string()) - .bind(&space.owner_did) + .bind(&space.did) .bind(&space.id) .bind(&encrypted_signing) .bind(&encrypted_rotation) @@ -161,21 +148,6 @@ async fn get_or_create_signing_key( Ok(keypair.private_jwk) } -async fn get_public_key( - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - encryption_key: &[u8; 32], - space: &Space, -) -> Result { - let private_jwk = get_or_create_signing_key(pool, backend, encryption_key, space).await?; - Ok(serde_json::json!({ - "kty": "EC", - "crv": "P-256", - "x": private_jwk["x"], - "y": private_jwk["y"], - })) -} - struct SpaceKeypair { private_jwk: serde_json::Value, } @@ -252,6 +224,7 @@ mod tests { fn test_space(access_mode: AccessMode) -> Space { Space { id: "test-space".into(), + did: "did:plc:owner".into(), owner_did: "did:plc:owner".into(), type_nsid: "com.example.forum".into(), skey: "main".into(), @@ -262,6 +235,7 @@ mod tests { app_denylist: None, managing_app_did: None, config: SpaceConfig::default(), + revision: None, created_at: String::new(), updated_at: String::new(), } diff --git a/src/spaces/credential.rs b/src/spaces/credential.rs index a2bbbc3..d20054f 100644 --- a/src/spaces/credential.rs +++ b/src/spaces/credential.rs @@ -7,6 +7,43 @@ use crate::error::AppError; use crate::profile; pub const DEFAULT_CREDENTIAL_TTL_SECS: u64 = 4 * 60 * 60; // 4 hours +pub const GRANT_TTL_SECS: u64 = 5 * 60; // 5 minutes + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemberGrantClaims { + pub sub: String, + pub space: String, + pub scope: String, + pub iat: u64, + pub exp: u64, +} + +pub fn sign_grant(claims: &MemberGrantClaims, secret: &[u8; 32]) -> Result { + let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256); + let key = jsonwebtoken::EncodingKey::from_secret(secret); + jsonwebtoken::encode(&header, claims, &key) + .map_err(|e| AppError::Internal(format!("failed to sign member grant: {e}"))) +} + +pub fn verify_grant(token: &str, secret: &[u8; 32]) -> Result { + let key = jsonwebtoken::DecodingKey::from_secret(secret); + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); + validation.required_spec_claims.clear(); + validation.validate_exp = false; + let data = jsonwebtoken::decode::(token, &key, &validation) + .map_err(|e| AppError::Auth(format!("invalid member grant: {e}")))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + if now > data.claims.exp { + return Err(AppError::Auth("member grant has expired".into())); + } + + Ok(data.claims) +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpaceCredentialClaims { @@ -281,4 +318,73 @@ mod tests { let result = verify_credential("not-a-jwt", &keypair.public_jwk); assert!(result.is_err()); } + + fn test_secret() -> [u8; 32] { + [0xAB; 32] + } + + #[test] + fn grant_sign_and_verify_roundtrip() { + let secret = test_secret(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let claims = MemberGrantClaims { + sub: "did:plc:member".into(), + space: "ats://did:plc:space/com.example.forum/main".into(), + scope: "read".into(), + iat: now, + exp: now + GRANT_TTL_SECS, + }; + + let token = sign_grant(&claims, &secret).unwrap(); + let verified = verify_grant(&token, &secret).unwrap(); + + assert_eq!(verified.sub, claims.sub); + assert_eq!(verified.space, claims.space); + assert_eq!(verified.scope, claims.scope); + } + + #[test] + fn grant_rejects_wrong_secret() { + let secret1 = [0xAB; 32]; + let secret2 = [0xCD; 32]; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let claims = MemberGrantClaims { + sub: "did:plc:member".into(), + space: "ats://did:plc:space/com.example.forum/main".into(), + scope: "read".into(), + iat: now, + exp: now + GRANT_TTL_SECS, + }; + + let token = sign_grant(&claims, &secret1).unwrap(); + let result = verify_grant(&token, &secret2); + assert!(result.is_err()); + } + + #[test] + fn grant_rejects_expired() { + let secret = test_secret(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let claims = MemberGrantClaims { + sub: "did:plc:member".into(), + space: "ats://did:plc:space/com.example.forum/main".into(), + scope: "read".into(), + iat: now - 600, + exp: now - 300, + }; + + let token = sign_grant(&claims, &secret).unwrap(); + let result = verify_grant(&token, &secret); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("expired")); + } } diff --git a/src/spaces/db.rs b/src/spaces/db.rs index 5577a50..88d19b3 100644 --- a/src/spaces/db.rs +++ b/src/spaces/db.rs @@ -24,12 +24,13 @@ pub async fn create_space( .map(|v| serde_json::to_string(v).unwrap_or_default()); let sql = adapt_sql( - "INSERT INTO spaces (id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO spaces (id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) .bind(&space.id) + .bind(&space.did) .bind(&space.owner_did) .bind(&space.type_nsid) .bind(&space.skey) @@ -55,7 +56,7 @@ pub async fn get_space( id: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at FROM spaces WHERE id = ?", + "SELECT id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, revision, created_at, updated_at FROM spaces WHERE id = ?", backend, ); @@ -71,17 +72,17 @@ pub async fn get_space( pub async fn get_space_by_address( pool: &sqlx::AnyPool, backend: DatabaseBackend, - owner_did: &str, + did: &str, type_nsid: &str, skey: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at FROM spaces WHERE owner_did = ? AND type_nsid = ? AND skey = ?", + "SELECT id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, revision, created_at, updated_at FROM spaces WHERE did = ? AND type_nsid = ? AND skey = ?", backend, ); let row: Option = sqlx::query_as(&sql) - .bind(owner_did) + .bind(did) .bind(type_nsid) .bind(skey) .fetch_optional(pool) @@ -94,15 +95,15 @@ pub async fn get_space_by_address( pub async fn list_spaces_by_owner( pool: &sqlx::AnyPool, backend: DatabaseBackend, - owner_did: &str, + did: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at FROM spaces WHERE owner_did = ? ORDER BY created_at DESC", + "SELECT id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, revision, created_at, updated_at FROM spaces WHERE owner_did = ? ORDER BY created_at DESC", backend, ); let rows: Vec = sqlx::query_as(&sql) - .bind(owner_did) + .bind(did) .fetch_all(pool) .await .map_err(|e| AppError::Internal(format!("failed to list spaces: {e}")))?; @@ -110,6 +111,58 @@ pub async fn list_spaces_by_owner( rows.into_iter().map(parse_space_row).collect() } +pub struct SpaceView { + pub uri: String, + pub is_owner: bool, +} + +pub async fn list_spaces_for_user( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + did: &str, + limit: i64, + cursor: Option<&str>, +) -> Result, AppError> { + let (sql, has_cursor) = if cursor.is_some() { + ( + adapt_sql( + "SELECT s.did, s.owner_did, s.type_nsid, s.skey, sm.created_at FROM space_members sm JOIN spaces s ON s.id = sm.space_id WHERE sm.member_did = ? AND sm.created_at > ? ORDER BY sm.created_at ASC LIMIT ?", + backend, + ), + true, + ) + } else { + ( + adapt_sql( + "SELECT s.did, s.owner_did, s.type_nsid, s.skey, sm.created_at FROM space_members sm JOIN spaces s ON s.id = sm.space_id WHERE sm.member_did = ? ORDER BY sm.created_at ASC LIMIT ?", + backend, + ), + false, + ) + }; + + let mut query = sqlx::query_as::<_, (String, String, String, String, String)>(&sql).bind(did); + if has_cursor { + query = query.bind(cursor.unwrap()); + } + query = query.bind(limit); + + let rows = query + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list spaces for user: {e}")))?; + + Ok(rows + .into_iter() + .map( + |(space_did, owner_did, type_nsid, skey, _created_at)| SpaceView { + uri: format!("ats://{}/{}/{}", space_did, type_nsid, skey), + is_owner: owner_did == did, + }, + ) + .collect()) +} + pub async fn update_space( pool: &sqlx::AnyPool, backend: DatabaseBackend, @@ -170,6 +223,7 @@ type SpaceRow = ( String, String, String, + String, Option, Option, String, @@ -177,40 +231,43 @@ type SpaceRow = ( Option, Option, String, + Option, String, String, ); fn parse_space_row(r: SpaceRow) -> Result { - let access_mode = AccessMode::parse(&r.6) - .ok_or_else(|| AppError::Internal(format!("invalid access_mode: {}", r.6)))?; + let access_mode = AccessMode::parse(&r.7) + .ok_or_else(|| AppError::Internal(format!("invalid access_mode: {}", r.7)))?; let app_allowlist: Option> = - r.7.as_deref() + r.8.as_deref() .map(serde_json::from_str) .transpose() .map_err(|e| AppError::Internal(format!("invalid app_allowlist: {e}")))?; let app_denylist: Option> = - r.8.as_deref() + r.9.as_deref() .map(serde_json::from_str) .transpose() .map_err(|e| AppError::Internal(format!("invalid app_denylist: {e}")))?; - let config: SpaceConfig = serde_json::from_str(&r.10) + let config: SpaceConfig = serde_json::from_str(&r.11) .map_err(|e| AppError::Internal(format!("invalid space config: {e}")))?; Ok(Space { id: r.0, - owner_did: r.1, - type_nsid: r.2, - skey: r.3, - display_name: r.4, - description: r.5, + did: r.1, + owner_did: r.2, + type_nsid: r.3, + skey: r.4, + display_name: r.5, + description: r.6, access_mode, app_allowlist, app_denylist, - managing_app_did: r.9, + managing_app_did: r.10, config, - created_at: r.11, - updated_at: r.12, + revision: r.12, + created_at: r.13, + updated_at: r.14, }) } @@ -232,7 +289,7 @@ pub async fn add_member( sqlx::query(&sql) .bind(&member.id) .bind(&member.space_id) - .bind(&member.member_did) + .bind(&member.did) .bind(member.access.as_str()) .bind(member.is_delegation as i32) .bind(&member.granted_by) @@ -248,7 +305,7 @@ pub async fn remove_member( pool: &sqlx::AnyPool, backend: DatabaseBackend, space_id: &str, - member_did: &str, + did: &str, ) -> Result { let sql = adapt_sql( "DELETE FROM space_members WHERE space_id = ? AND member_did = ?", @@ -257,7 +314,7 @@ pub async fn remove_member( let result = sqlx::query(&sql) .bind(space_id) - .bind(member_did) + .bind(did) .execute(pool) .await .map_err(|e| AppError::Internal(format!("failed to remove member: {e}")))?; @@ -269,7 +326,7 @@ pub async fn get_member( pool: &sqlx::AnyPool, backend: DatabaseBackend, space_id: &str, - member_did: &str, + did: &str, ) -> Result, AppError> { let sql = adapt_sql( "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE space_id = ? AND member_did = ?", @@ -278,7 +335,7 @@ pub async fn get_member( let row: Option = sqlx::query_as(&sql) .bind(space_id) - .bind(member_did) + .bind(did) .fetch_optional(pool) .await .map_err(|e| AppError::Internal(format!("failed to get member: {e}")))?; @@ -308,7 +365,7 @@ pub async fn list_direct_members( pub async fn list_spaces_for_member( pool: &sqlx::AnyPool, backend: DatabaseBackend, - member_did: &str, + did: &str, ) -> Result, AppError> { let sql = adapt_sql( "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE member_did = ? ORDER BY created_at ASC", @@ -316,7 +373,7 @@ pub async fn list_spaces_for_member( ); let rows: Vec = sqlx::query_as(&sql) - .bind(member_did) + .bind(did) .fetch_all(pool) .await .map_err(|e| AppError::Internal(format!("failed to list spaces for member: {e}")))?; @@ -333,7 +390,7 @@ fn parse_member_row(r: MemberRow) -> Result { Ok(SpaceMember { id: r.0, space_id: r.1, - member_did: r.2, + did: r.2, access, is_delegation: r.4 != 0, granted_by: r.5, @@ -422,56 +479,49 @@ pub async fn get_space_record_by_parts( row.map(parse_record_row).transpose() } +#[allow(clippy::too_many_arguments)] pub async fn list_space_records( pool: &sqlx::AnyPool, backend: DatabaseBackend, space_id: &str, + repo: Option<&str>, collection: Option<&str>, limit: i64, cursor: Option<&str>, + reverse: bool, ) -> Result, AppError> { - let (sql, has_collection, has_cursor) = match (collection, cursor) { - (Some(_), Some(_)) => ( - adapt_sql( - "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND collection = ? AND indexed_at > ? ORDER BY indexed_at ASC LIMIT ?", - backend, - ), - true, - true, - ), - (Some(_), None) => ( - adapt_sql( - "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND collection = ? ORDER BY indexed_at ASC LIMIT ?", - backend, - ), - true, - false, - ), - (None, Some(_)) => ( - adapt_sql( - "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND indexed_at > ? ORDER BY indexed_at ASC LIMIT ?", - backend, - ), - false, - true, - ), - (None, None) => ( - adapt_sql( - "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? ORDER BY indexed_at ASC LIMIT ?", - backend, - ), - false, - false, - ), + let mut conditions = vec!["space_id = ?".to_string()]; + if repo.is_some() { + conditions.push("author_did = ?".to_string()); + } + if collection.is_some() { + conditions.push("collection = ?".to_string()); + } + let (cursor_op, order) = if reverse { + ("indexed_at < ?", "DESC") + } else { + ("indexed_at > ?", "ASC") }; + if cursor.is_some() { + conditions.push(cursor_op.to_string()); + } - let mut query = sqlx::query_as::<_, RecordRow>(&sql).bind(space_id); + let where_clause = conditions.join(" AND "); + let raw = format!( + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE {} ORDER BY indexed_at {} LIMIT ?", + where_clause, order + ); + let sql = adapt_sql(&raw, backend); - if has_collection { - query = query.bind(collection.unwrap()); + let mut query = sqlx::query_as::<_, RecordRow>(&sql).bind(space_id); + if let Some(r) = repo { + query = query.bind(r); } - if has_cursor { - query = query.bind(cursor.unwrap()); + if let Some(c) = collection { + query = query.bind(c); + } + if let Some(cur) = cursor { + query = query.bind(cur); } query = query.bind(limit); @@ -483,6 +533,79 @@ pub async fn list_space_records( rows.into_iter().map(parse_record_row).collect() } +pub async fn insert_space_record( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + record: &SpaceRecord, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let record_json = serde_json::to_string(&record.record) + .map_err(|e| AppError::Internal(format!("failed to serialize record: {e}")))?; + + let sql = adapt_sql( + "INSERT INTO space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + backend, + ); + + sqlx::query(&sql) + .bind(&record.uri) + .bind(&record.space_id) + .bind(&record.author_did) + .bind(&record.collection) + .bind(&record.rkey) + .bind(&record_json) + .bind(&record.cid) + .bind(&now) + .execute(pool) + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("UNIQUE") || msg.contains("duplicate") || msg.contains("unique") { + AppError::Conflict("Record already exists".into()) + } else { + AppError::Internal(format!("failed to create space record: {e}")) + } + })?; + + Ok(()) +} + +pub async fn upsert_space_record_with_swap( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + record: &SpaceRecord, + swap_cid: &str, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let record_json = serde_json::to_string(&record.record) + .map_err(|e| AppError::Internal(format!("failed to serialize record: {e}")))?; + + let sql = adapt_sql( + "UPDATE space_records SET record = ?, cid = ?, indexed_at = ? WHERE uri = ? AND cid = ?", + backend, + ); + + let result = sqlx::query(&sql) + .bind(&record_json) + .bind(&record.cid) + .bind(&now) + .bind(&record.uri) + .bind(swap_cid) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to update space record: {e}")))?; + + if result.rows_affected() == 0 { + let existing = get_space_record(pool, backend, &record.uri).await?; + if existing.is_some() { + return Err(AppError::Conflict("Record CID mismatch".into())); + } + return Err(AppError::NotFound("Record not found".into())); + } + + Ok(()) +} + pub async fn delete_space_record( pool: &sqlx::AnyPool, backend: DatabaseBackend, @@ -499,6 +622,58 @@ pub async fn delete_space_record( Ok(result.rows_affected() > 0) } +pub async fn delete_space_record_with_swap( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + uri: &str, + swap_cid: &str, +) -> Result { + let sql = adapt_sql( + "DELETE FROM space_records WHERE uri = ? AND cid = ?", + backend, + ); + + let result = sqlx::query(&sql) + .bind(uri) + .bind(swap_cid) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete space record: {e}")))?; + + if result.rows_affected() == 0 { + let existing = get_space_record(pool, backend, uri).await?; + if existing.is_some() { + return Err(AppError::Conflict("Record CID mismatch".into())); + } + return Err(AppError::NotFound("Record not found".into())); + } + + Ok(true) +} + +pub async fn update_space_revision( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + revision: &str, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "UPDATE spaces SET revision = ?, updated_at = ? WHERE id = ?", + backend, + ); + + sqlx::query(&sql) + .bind(revision) + .bind(&now) + .bind(space_id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to update space revision: {e}")))?; + + Ok(()) +} + type RecordRow = ( String, String, diff --git a/src/spaces/members.rs b/src/spaces/members.rs index a7212d7..477df80 100644 --- a/src/spaces/members.rs +++ b/src/spaces/members.rs @@ -77,7 +77,7 @@ fn resolve_members_recursive<'a>( .await?; } } else { - merge_access(resolved, &member.member_did, member.access); + merge_access(resolved, &member.did, member.access); } } @@ -93,14 +93,13 @@ async fn resolve_delegation_target( backend: DatabaseBackend, member: &SpaceMember, ) -> Result, AppError> { - if member.member_did.starts_with("ats://") { - let uri = SpaceUri::parse(&member.member_did)?; + if member.did.starts_with("ats://") { + let uri = SpaceUri::parse(&member.did)?; let space = - db::get_space_by_address(pool, backend, &uri.owner_did, &uri.type_nsid, &uri.skey) - .await?; + db::get_space_by_address(pool, backend, &uri.did, &uri.type_nsid, &uri.skey).await?; Ok(space.map(|s| s.id)) } else { - let space = db::get_space(pool, backend, &member.member_did).await?; + let space = db::get_space(pool, backend, &member.did).await?; Ok(space.map(|s| s.id)) } } diff --git a/src/spaces/mod.rs b/src/spaces/mod.rs index 385f183..8dc3c7d 100644 --- a/src/spaces/mod.rs +++ b/src/spaces/mod.rs @@ -10,11 +10,11 @@ use std::fmt; /// A parsed `ats://` URI for addressing permissioned data. /// -/// Full form: `ats:///////` -/// Space-only form: `ats:////` +/// Full form: `ats:///////` +/// Space-only form: `ats:////` #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct SpaceUri { - pub owner_did: String, + pub did: String, pub type_nsid: String, pub skey: String, pub user_did: Option, @@ -32,7 +32,7 @@ impl SpaceUri { if parts.len() < 3 { return Err(AppError::BadRequest( - "SpaceUri requires at least owner_did/type_nsid/skey".into(), + "SpaceUri requires at least did/type_nsid/skey".into(), )); } @@ -42,7 +42,7 @@ impl SpaceUri { )); } - let owner_did = parts[0].to_string(); + let did = parts[0].to_string(); let type_nsid = parts[1].to_string(); let skey = parts[2].to_string(); @@ -61,7 +61,7 @@ impl SpaceUri { }; Ok(SpaceUri { - owner_did, + did, type_nsid, skey, user_did, @@ -71,7 +71,7 @@ impl SpaceUri { } pub fn space_uri(&self) -> String { - format!("ats://{}/{}/{}", self.owner_did, self.type_nsid, self.skey) + format!("ats://{}/{}/{}", self.did, self.type_nsid, self.skey) } pub fn is_record_uri(&self) -> bool { @@ -85,11 +85,7 @@ impl SpaceUri { impl fmt::Display for SpaceUri { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "ats://{}/{}/{}", - self.owner_did, self.type_nsid, self.skey - )?; + write!(f, "ats://{}/{}/{}", self.did, self.type_nsid, self.skey)?; if let (Some(user), Some(col), Some(rkey)) = (&self.user_did, &self.collection, &self.rkey) { write!(f, "/{}/{}/{}", user, col, rkey)?; @@ -105,7 +101,7 @@ mod tests { #[test] fn parse_space_uri() { let uri = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main").unwrap(); - assert_eq!(uri.owner_did, "did:plc:abc123"); + assert_eq!(uri.did, "did:plc:abc123"); assert_eq!(uri.type_nsid, "com.example.forum"); assert_eq!(uri.skey, "main"); assert!(uri.is_space_uri()); @@ -119,7 +115,7 @@ mod tests { "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", ) .unwrap(); - assert_eq!(uri.owner_did, "did:plc:abc123"); + assert_eq!(uri.did, "did:plc:abc123"); assert_eq!(uri.type_nsid, "com.example.forum"); assert_eq!(uri.skey, "main"); assert_eq!(uri.user_did.as_deref(), Some("did:plc:user1")); @@ -132,7 +128,7 @@ mod tests { #[test] fn display_space_uri() { let uri = SpaceUri { - owner_did: "did:plc:abc123".into(), + did: "did:plc:abc123".into(), type_nsid: "com.example.forum".into(), skey: "main".into(), user_did: None, @@ -148,7 +144,7 @@ mod tests { #[test] fn display_record_uri() { let uri = SpaceUri { - owner_did: "did:plc:abc123".into(), + did: "did:plc:abc123".into(), type_nsid: "com.example.forum".into(), skey: "main".into(), user_did: Some("did:plc:user1".into()), diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs index 543707c..29abc02 100644 --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -11,6 +11,7 @@ use crate::AppState; use crate::auth::XrpcClaims; use crate::db::{adapt_sql, now_rfc3339}; use crate::error::AppError; +use crate::lua::tid::generate_tid; use crate::spaces::types::*; use crate::spaces::{SpaceUri, db, members}; @@ -21,6 +22,7 @@ use crate::spaces::{SpaceUri, db, members}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CreateSpaceInput { + #[serde(rename = "type")] type_nsid: String, skey: String, display_name: Option, @@ -33,25 +35,27 @@ struct CreateSpaceInput { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SpaceUriQuery { - space_uri: String, + space: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ListSpacesQuery { - owner_did: Option, + did: Option, + limit: Option, + cursor: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct DeleteSpaceInput { - space_uri: String, + space: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateSpaceInput { - space_uri: String, + space: String, display_name: Option>, description: Option>, access_mode: Option, @@ -64,24 +68,26 @@ struct UpdateSpaceInput { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PutRecordInput { - space_uri: String, + space: String, collection: String, rkey: String, record: serde_json::Value, + swap_record: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct DeleteRecordInput { - space_uri: String, + space: String, collection: String, rkey: String, + swap_record: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GetRecordQuery { - space_uri: String, + space: String, collection: String, rkey: String, } @@ -89,17 +95,19 @@ struct GetRecordQuery { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ListRecordsQuery { - space_uri: String, + space: String, + repo: Option, collection: Option, limit: Option, cursor: Option, + reverse: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AddMemberInput { - space_uri: String, - member_did: String, + space: String, + did: String, access: Option, is_delegation: Option, } @@ -107,14 +115,14 @@ struct AddMemberInput { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RemoveMemberInput { - space_uri: String, - member_did: String, + space: String, + did: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CreateInviteInput { - space_uri: String, + space: String, access: Option, max_uses: Option, expires_at: Option, @@ -129,21 +137,59 @@ struct RedeemInviteInput { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RevokeInviteInput { - space_uri: String, + space: String, invite_id: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct GetCredentialInput { - space_uri: String, +struct GetMemberGrantInput { + space: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GetSpaceCredentialInput { + grant: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateRecordInput { + space: String, + collection: String, + record: serde_json::Value, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct RefreshCredentialInput { - space_uri: String, - credential: String, +struct ApplyWritesInput { + space: String, + swap_commit: Option, + writes: Vec, +} + +#[derive(Deserialize)] +#[serde(tag = "action", rename_all = "camelCase")] +enum WriteOp { + Create { + collection: String, + rkey: Option, + value: serde_json::Value, + }, + Update { + collection: String, + rkey: String, + value: serde_json::Value, + #[serde(rename = "swapRecord")] + swap_record: Option, + }, + Delete { + collection: String, + rkey: String, + #[serde(rename = "swapRecord")] + swap_record: Option, + }, } // --------------------------------------------------------------------------- @@ -161,11 +207,16 @@ pub fn space_routes() -> Router { .route(&format!("/xrpc/{NS}.space.delete"), post(delete_space)) .route(&format!("/xrpc/{NS}.space.update"), post(update_space)) // Records + .route( + &format!("/xrpc/{NS}.space.createRecord"), + post(create_record), + ) .route(&format!("/xrpc/{NS}.space.putRecord"), post(put_record)) .route( &format!("/xrpc/{NS}.space.deleteRecord"), post(delete_record), ) + .route(&format!("/xrpc/{NS}.space.applyWrites"), post(apply_writes)) .route(&format!("/xrpc/{NS}.space.getRecord"), get(get_record)) .route(&format!("/xrpc/{NS}.space.listRecords"), get(list_records)) // Members @@ -191,12 +242,12 @@ pub fn space_routes() -> Router { .route(&format!("/xrpc/{NS}.space.invite.list"), get(list_invites)) // Credentials .route( - &format!("/xrpc/{NS}.space.getCredential"), - post(get_credential), + &format!("/xrpc/{NS}.space.getMemberGrant"), + post(get_member_grant), ) .route( - &format!("/xrpc/{NS}.space.refreshCredential"), - post(refresh_credential), + &format!("/xrpc/{NS}.space.getSpaceCredential"), + post(get_space_credential), ) } @@ -216,7 +267,7 @@ async fn resolve_space(state: &AppState, space_uri: &str) -> Result, ) -> Result { if let Some(token) = space_credential { - let space_uri = format!( - "ats://{}/{}/{}", - space.owner_did, space.type_nsid, space.skey - ); + let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); match crate::spaces::credential::verify_external_credential( token, &state.http, @@ -319,9 +367,7 @@ async fn create_space( let did = claims.did().to_string(); if input.type_nsid.is_empty() || input.skey.is_empty() { - return Err(AppError::BadRequest( - "type_nsid and skey are required".into(), - )); + return Err(AppError::BadRequest("type and skey are required".into())); } let existing = db::get_space_by_address( @@ -340,6 +386,7 @@ async fn create_space( let space = Space { id: Uuid::new_v4().to_string(), + did: did.clone(), owner_did: did.clone(), type_nsid: input.type_nsid, skey: input.skey, @@ -350,6 +397,7 @@ async fn create_space( app_denylist: None, managing_app_did: input.managing_app_did, config: input.config.unwrap_or_default(), + revision: None, created_at: now_rfc3339(), updated_at: now_rfc3339(), }; @@ -360,7 +408,7 @@ async fn create_space( let member = SpaceMember { id: Uuid::new_v4().to_string(), space_id: space.id.clone(), - member_did: did.clone(), + did: did.clone(), access: SpaceAccess::Write, is_delegation: false, granted_by: Some(did), @@ -368,13 +416,9 @@ async fn create_space( }; db::add_member(&state.db, state.db_backend, &member).await?; - let space_uri = format!( - "ats://{}/{}/{}", - space.owner_did, space.type_nsid, space.skey - ); + let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); let body = serde_json::json!({ - "spaceUri": space_uri, - "space": space, + "uri": space_uri, }); let mut response = Json(body).into_response(); @@ -387,7 +431,7 @@ async fn get_space( xrpc_claims: XrpcClaims, Query(query): Query, ) -> Result, AppError> { - let space = resolve_space(&state, &query.space_uri).await?; + let space = resolve_space(&state, &query.space).await?; // If the space's membership is not public, require auth + membership if !space.config.membership_public { @@ -400,12 +444,9 @@ async fn get_space( } } - let space_uri = format!( - "ats://{}/{}/{}", - space.owner_did, space.type_nsid, space.skey - ); + let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); Ok(Json(serde_json::json!({ - "spaceUri": space_uri, + "uri": space_uri, "space": space, }))) } @@ -416,20 +457,38 @@ async fn list_spaces( Query(query): Query, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let did = claims.did().to_string(); + let did = query.did.unwrap_or_else(|| claims.did().to_string()); + let limit = query.limit.unwrap_or(50).min(100); - let owner = query.owner_did.as_deref().unwrap_or(&did); - let spaces = db::list_spaces_by_owner(&state.db, state.db_backend, owner).await?; + let views = db::list_spaces_for_user( + &state.db, + state.db_backend, + &did, + limit, + query.cursor.as_deref(), + ) + .await?; + + let cursor = if views.len() as i64 == limit { + views.last().map(|v| v.uri.clone()) + } else { + None + }; - let spaces_with_uris: Vec = spaces + let spaces_json: Vec = views .into_iter() - .map(|s| { - let uri = format!("ats://{}/{}/{}", s.owner_did, s.type_nsid, s.skey); - serde_json::json!({ "spaceUri": uri, "space": s }) + .map(|v| { + serde_json::json!({ + "uri": v.uri, + "isOwner": v.is_owner, + }) }) .collect(); - Ok(Json(serde_json::json!({ "spaces": spaces_with_uris }))) + Ok(Json(serde_json::json!({ + "spaces": spaces_json, + "cursor": cursor, + }))) } async fn delete_space( @@ -438,7 +497,7 @@ async fn delete_space( Json(input): Json, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space_uri).await?; + let space = resolve_space(&state, &input.space).await?; require_space_admin(&state, &space, claims.did()).await?; db::delete_space(&state.db, state.db_backend, &space.id).await?; @@ -452,7 +511,7 @@ async fn update_space( Json(input): Json, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let mut space = resolve_space(&state, &input.space_uri).await?; + let mut space = resolve_space(&state, &input.space).await?; require_space_admin(&state, &space, claims.did()).await?; if let Some(name) = input.display_name { @@ -479,12 +538,9 @@ async fn update_space( db::update_space(&state.db, state.db_backend, &space).await?; - let space_uri = format!( - "ats://{}/{}/{}", - space.owner_did, space.type_nsid, space.skey - ); + let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); Ok(Json(serde_json::json!({ - "spaceUri": space_uri, + "uri": space_uri, "space": space, }))) } @@ -493,6 +549,51 @@ async fn update_space( // Record handlers // --------------------------------------------------------------------------- +async fn create_record( + State(state): State, + xrpc_claims: XrpcClaims, + headers: HeaderMap, + Json(input): Json, +) -> Result { + let claims = require_auth(&xrpc_claims)?; + let did = claims.did().to_string(); + let space = resolve_space(&state, &input.space).await?; + let cred = extract_space_credential(&headers); + require_membership(&state, &space, &did, true, cred.as_deref()).await?; + + let rkey = generate_tid(); + let cid = content_cid(&input.record); + let record_uri = format!( + "ats://{}/{}/{}/{}/{}/{}", + space.did, space.type_nsid, space.skey, did, input.collection, rkey + ); + + let record = SpaceRecord { + uri: record_uri.clone(), + space_id: space.id.clone(), + author_did: did, + collection: input.collection, + rkey, + record: input.record, + cid: cid.clone(), + indexed_at: now_rfc3339(), + }; + + db::insert_space_record(&state.db, state.db_backend, &record).await?; + + let rev = generate_tid(); + db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?; + + let body = serde_json::json!({ + "uri": record_uri, + "cid": cid, + }); + + let mut response = Json(body).into_response(); + *response.status_mut() = StatusCode::CREATED; + Ok(response) +} + async fn put_record( State(state): State, xrpc_claims: XrpcClaims, @@ -501,19 +602,19 @@ async fn put_record( ) -> Result { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); - let space = resolve_space(&state, &input.space_uri).await?; + let space = resolve_space(&state, &input.space).await?; let cred = extract_space_credential(&headers); require_membership(&state, &space, &did, true, cred.as_deref()).await?; let cid = content_cid(&input.record); let record_uri = format!( "ats://{}/{}/{}/{}/{}/{}", - space.owner_did, space.type_nsid, space.skey, did, input.collection, input.rkey + space.did, space.type_nsid, space.skey, did, input.collection, input.rkey ); let record = SpaceRecord { uri: record_uri.clone(), - space_id: space.id, + space_id: space.id.clone(), author_did: did, collection: input.collection, rkey: input.rkey, @@ -522,7 +623,14 @@ async fn put_record( indexed_at: now_rfc3339(), }; - db::upsert_space_record(&state.db, state.db_backend, &record).await?; + if let Some(swap_cid) = input.swap_record { + db::upsert_space_record_with_swap(&state.db, state.db_backend, &record, &swap_cid).await?; + } else { + db::upsert_space_record(&state.db, state.db_backend, &record).await?; + } + + let rev = generate_tid(); + db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?; let body = serde_json::json!({ "uri": record_uri, @@ -541,31 +649,163 @@ async fn delete_record( ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); - let space = resolve_space(&state, &input.space_uri).await?; + let space = resolve_space(&state, &input.space).await?; let record_uri = format!( "ats://{}/{}/{}/{}/{}/{}", - space.owner_did, space.type_nsid, space.skey, did, input.collection, input.rkey + space.did, space.type_nsid, space.skey, did, input.collection, input.rkey ); - let record = db::get_space_record(&state.db, state.db_backend, &record_uri).await?; - match record { - Some(r) if r.author_did != did => { - return Err(AppError::Forbidden( - "You can only delete your own records".into(), - )); - } - None => { - return Err(AppError::NotFound("Record not found".into())); + if let Some(swap_cid) = input.swap_record { + db::delete_space_record_with_swap(&state.db, state.db_backend, &record_uri, &swap_cid) + .await?; + } else { + let record = db::get_space_record(&state.db, state.db_backend, &record_uri).await?; + match record { + Some(r) if r.author_did != did => { + return Err(AppError::Forbidden( + "You can only delete your own records".into(), + )); + } + None => { + return Err(AppError::NotFound("Record not found".into())); + } + _ => {} } - _ => {} + db::delete_space_record(&state.db, state.db_backend, &record_uri).await?; } - db::delete_space_record(&state.db, state.db_backend, &record_uri).await?; + let rev = generate_tid(); + db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?; Ok(Json(serde_json::json!({ "success": true }))) } +async fn apply_writes( + State(state): State, + xrpc_claims: XrpcClaims, + headers: HeaderMap, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let did = claims.did().to_string(); + let space = resolve_space(&state, &input.space).await?; + let cred = extract_space_credential(&headers); + require_membership(&state, &space, &did, true, cred.as_deref()).await?; + + if let Some(ref expected_rev) = input.swap_commit { + match &space.revision { + Some(current_rev) if current_rev != expected_rev => { + return Err(AppError::Conflict("swapCommit mismatch".into())); + } + None if !expected_rev.is_empty() => { + return Err(AppError::Conflict("swapCommit mismatch".into())); + } + _ => {} + } + } + + let mut results = Vec::with_capacity(input.writes.len()); + + for op in input.writes { + match op { + WriteOp::Create { + collection, + rkey, + value, + } => { + let rkey = rkey.unwrap_or_else(generate_tid); + let cid = content_cid(&value); + let record_uri = format!( + "ats://{}/{}/{}/{}/{}/{}", + space.did, space.type_nsid, space.skey, did, collection, rkey + ); + let record = SpaceRecord { + uri: record_uri.clone(), + space_id: space.id.clone(), + author_did: did.clone(), + collection, + rkey, + record: value, + cid: cid.clone(), + indexed_at: now_rfc3339(), + }; + db::insert_space_record(&state.db, state.db_backend, &record).await?; + results.push(serde_json::json!({ + "uri": record_uri, + "cid": cid, + })); + } + WriteOp::Update { + collection, + rkey, + value, + swap_record, + } => { + let cid = content_cid(&value); + let record_uri = format!( + "ats://{}/{}/{}/{}/{}/{}", + space.did, space.type_nsid, space.skey, did, collection, rkey + ); + let record = SpaceRecord { + uri: record_uri.clone(), + space_id: space.id.clone(), + author_did: did.clone(), + collection, + rkey, + record: value, + cid: cid.clone(), + indexed_at: now_rfc3339(), + }; + if let Some(swap_cid) = swap_record { + db::upsert_space_record_with_swap( + &state.db, + state.db_backend, + &record, + &swap_cid, + ) + .await?; + } else { + db::upsert_space_record(&state.db, state.db_backend, &record).await?; + } + results.push(serde_json::json!({ + "uri": record_uri, + "cid": cid, + })); + } + WriteOp::Delete { + collection, + rkey, + swap_record, + } => { + let record_uri = format!( + "ats://{}/{}/{}/{}/{}/{}", + space.did, space.type_nsid, space.skey, did, collection, rkey + ); + if let Some(swap_cid) = swap_record { + db::delete_space_record_with_swap( + &state.db, + state.db_backend, + &record_uri, + &swap_cid, + ) + .await?; + } else { + db::delete_space_record(&state.db, state.db_backend, &record_uri).await?; + } + results.push(serde_json::json!({})); + } + } + } + + let rev = generate_tid(); + db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?; + + Ok(Json(serde_json::json!({ + "results": results, + }))) +} + async fn get_record( State(state): State, xrpc_claims: XrpcClaims, @@ -573,7 +813,7 @@ async fn get_record( Query(query): Query, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &query.space_uri).await?; + let space = resolve_space(&state, &query.space).await?; let cred = extract_space_credential(&headers); require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; @@ -589,10 +829,8 @@ async fn get_record( Ok(Json(serde_json::json!({ "uri": record.uri, - "space": query.space_uri, - "collection": record.collection, - "record": record.record, "cid": record.cid, + "value": record.record, }))) } @@ -603,18 +841,29 @@ async fn list_records( Query(query): Query, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &query.space_uri).await?; + let space = resolve_space(&state, &query.space).await?; let cred = extract_space_credential(&headers); require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; + let repo = query.repo.as_deref().or_else(|| { + if cred.is_some() { + None + } else { + Some(claims.did()) + } + }); + let limit = query.limit.unwrap_or(50).min(100); + let reverse = query.reverse.unwrap_or(false); let records = db::list_space_records( &state.db, state.db_backend, &space.id, + repo, query.collection.as_deref(), limit, query.cursor.as_deref(), + reverse, ) .await?; @@ -624,10 +873,8 @@ async fn list_records( .into_iter() .map(|r| { serde_json::json!({ - "uri": r.uri, - "space": query.space_uri, "collection": r.collection, - "record": r.record, + "rkey": r.rkey, "cid": r.cid, }) }) @@ -649,7 +896,7 @@ async fn list_members( headers: HeaderMap, Query(query): Query, ) -> Result, AppError> { - let space = resolve_space(&state, &query.space_uri).await?; + let space = resolve_space(&state, &query.space).await?; if !space.config.membership_public { let claims = require_auth(&xrpc_claims)?; @@ -668,11 +915,10 @@ async fn add_member( Json(input): Json, ) -> Result { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space_uri).await?; + let space = resolve_space(&state, &input.space).await?; require_space_admin(&state, &space, claims.did()).await?; - let existing = - db::get_member(&state.db, state.db_backend, &space.id, &input.member_did).await?; + let existing = db::get_member(&state.db, state.db_backend, &space.id, &input.did).await?; if existing.is_some() { return Err(AppError::Conflict( "Member already exists in this space".into(), @@ -682,7 +928,7 @@ async fn add_member( let member = SpaceMember { id: Uuid::new_v4().to_string(), space_id: space.id, - member_did: input.member_did, + did: input.did, access: input.access.unwrap_or(SpaceAccess::Read), is_delegation: input.is_delegation.unwrap_or(false), granted_by: Some(claims.did().to_string()), @@ -702,11 +948,10 @@ async fn remove_member( Json(input): Json, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space_uri).await?; + let space = resolve_space(&state, &input.space).await?; require_space_admin(&state, &space, claims.did()).await?; - let removed = - db::remove_member(&state.db, state.db_backend, &space.id, &input.member_did).await?; + let removed = db::remove_member(&state.db, state.db_backend, &space.id, &input.did).await?; if !removed { return Err(AppError::NotFound("Member not found in this space".into())); @@ -725,7 +970,7 @@ async fn create_invite( Json(input): Json, ) -> Result { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space_uri).await?; + let space = resolve_space(&state, &input.space).await?; require_space_admin(&state, &space, claims.did()).await?; let mut token_bytes = [0u8; 24]; @@ -802,7 +1047,7 @@ async fn redeem_invite( let member = SpaceMember { id: Uuid::new_v4().to_string(), space_id: invite.space_id.clone(), - member_did: did, + did, access: invite.access, is_delegation: false, granted_by: Some(invite.created_by.clone()), @@ -813,10 +1058,10 @@ async fn redeem_invite( db::increment_invite_uses(&state.db, state.db_backend, &invite.id).await?; let space = db::get_space(&state.db, state.db_backend, &invite.space_id).await?; - let space_uri = space.map(|s| format!("ats://{}/{}/{}", s.owner_did, s.type_nsid, s.skey)); + let space_uri = space.map(|s| format!("ats://{}/{}/{}", s.did, s.type_nsid, s.skey)); let mut response = Json(serde_json::json!({ - "spaceUri": space_uri, + "uri": space_uri, "access": member.access, })) .into_response(); @@ -830,7 +1075,7 @@ async fn revoke_invite( Json(input): Json, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space_uri).await?; + let space = resolve_space(&state, &input.space).await?; require_space_admin(&state, &space, claims.did()).await?; let revoked = db::revoke_invite(&state.db, state.db_backend, &input.invite_id).await?; @@ -847,7 +1092,7 @@ async fn list_invites( Query(query): Query, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &query.space_uri).await?; + let space = resolve_space(&state, &query.space).await?; require_space_admin(&state, &space, claims.did()).await?; let invites = db::list_invites(&state.db, state.db_backend, &space.id).await?; @@ -875,14 +1120,14 @@ async fn list_invites( // Credential handlers // --------------------------------------------------------------------------- -async fn get_credential( +async fn get_member_grant( State(state): State, xrpc_claims: XrpcClaims, - Json(input): Json, + Json(input): Json, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); - let space = resolve_space(&state, &input.space_uri).await?; + let space = resolve_space(&state, &input.space).await?; require_membership(&state, &space, &did, false, None).await?; @@ -890,41 +1135,56 @@ async fn get_credential( AppError::Internal("TOKEN_ENCRYPTION_KEY is required for space credentials".into()) })?; - let client_id = claims.client_key().map(|k| k.to_string()); - let issued = crate::spaces::auth::issue_credential( - &state.db, - state.db_backend, - encryption_key, - &space, - &did, - client_id.as_deref(), - ) - .await?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let exp = now + crate::spaces::credential::GRANT_TTL_SECS; + + let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let grant_claims = crate::spaces::credential::MemberGrantClaims { + sub: did, + space: space_uri, + scope: "read".into(), + iat: now, + exp, + }; + + let grant = crate::spaces::credential::sign_grant(&grant_claims, encryption_key)?; + + let expires_at = chrono::DateTime::from_timestamp(exp as i64, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default(); Ok(Json(serde_json::json!({ - "credential": issued.token, - "expiresAt": issued.expires_at, + "grant": grant, + "expiresAt": expires_at, }))) } -async fn refresh_credential( +async fn get_space_credential( State(state): State, xrpc_claims: XrpcClaims, - Json(input): Json, + Json(input): Json, ) -> Result, AppError> { - let _claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space_uri).await?; + let claims = require_auth(&xrpc_claims)?; let encryption_key = state.config.token_encryption_key.as_ref().ok_or_else(|| { AppError::Internal("TOKEN_ENCRYPTION_KEY is required for space credentials".into()) })?; - let issued = crate::spaces::auth::refresh_credential( + let grant_claims = crate::spaces::credential::verify_grant(&input.grant, encryption_key)?; + + let space = resolve_space(&state, &grant_claims.space).await?; + + let client_id = claims.client_key().map(|k| k.to_string()); + let issued = crate::spaces::auth::issue_credential( &state.db, state.db_backend, encryption_key, &space, - &input.credential, + &grant_claims.sub, + client_id.as_deref(), ) .await?; @@ -933,3 +1193,211 @@ async fn refresh_credential( "expiresAt": issued.expires_at, }))) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn content_cid_deterministic() { + let record = json!({"text": "hello"}); + let cid1 = content_cid(&record); + let cid2 = content_cid(&record); + assert_eq!(cid1, cid2); + assert!(cid1.starts_with("bafyrei")); + } + + #[test] + fn content_cid_changes_for_different_records() { + let a = content_cid(&json!({"text": "hello"})); + let b = content_cid(&json!({"text": "world"})); + assert_ne!(a, b); + } + + #[test] + fn deserialize_create_record_input() { + let input: CreateRecordInput = serde_json::from_value(json!({ + "space": "ats://did:plc:abc/com.example.forum/main", + "collection": "com.example.forum.post", + "record": { "text": "hello" } + })) + .unwrap(); + assert_eq!(input.space, "ats://did:plc:abc/com.example.forum/main"); + assert_eq!(input.collection, "com.example.forum.post"); + assert_eq!(input.record["text"], "hello"); + } + + #[test] + fn deserialize_put_record_with_swap() { + let input: PutRecordInput = serde_json::from_value(json!({ + "space": "ats://did:plc:abc/com.example.forum/main", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "record": { "text": "updated" }, + "swapRecord": "bafyrei123" + })) + .unwrap(); + assert_eq!(input.swap_record.as_deref(), Some("bafyrei123")); + } + + #[test] + fn deserialize_put_record_without_swap() { + let input: PutRecordInput = serde_json::from_value(json!({ + "space": "ats://did:plc:abc/com.example.forum/main", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "record": { "text": "hello" } + })) + .unwrap(); + assert_eq!(input.swap_record, None); + } + + #[test] + fn deserialize_delete_record_with_swap() { + let input: DeleteRecordInput = serde_json::from_value(json!({ + "space": "ats://did:plc:abc/com.example.forum/main", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "swapRecord": "bafyrei456" + })) + .unwrap(); + assert_eq!(input.swap_record.as_deref(), Some("bafyrei456")); + } + + #[test] + fn deserialize_write_op_create() { + let op: WriteOp = serde_json::from_value(json!({ + "action": "create", + "collection": "com.example.forum.post", + "value": { "text": "new post" } + })) + .unwrap(); + match op { + WriteOp::Create { + collection, + rkey, + value, + } => { + assert_eq!(collection, "com.example.forum.post"); + assert_eq!(rkey, None); + assert_eq!(value["text"], "new post"); + } + _ => panic!("expected Create"), + } + } + + #[test] + fn deserialize_write_op_create_with_rkey() { + let op: WriteOp = serde_json::from_value(json!({ + "action": "create", + "collection": "com.example.forum.post", + "rkey": "custom-key", + "value": { "text": "new post" } + })) + .unwrap(); + match op { + WriteOp::Create { rkey, .. } => { + assert_eq!(rkey.as_deref(), Some("custom-key")); + } + _ => panic!("expected Create"), + } + } + + #[test] + fn deserialize_write_op_update() { + let op: WriteOp = serde_json::from_value(json!({ + "action": "update", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "value": { "text": "updated" }, + "swapRecord": "bafyrei789" + })) + .unwrap(); + match op { + WriteOp::Update { + collection, + rkey, + swap_record, + .. + } => { + assert_eq!(collection, "com.example.forum.post"); + assert_eq!(rkey, "3k2abc"); + assert_eq!(swap_record.as_deref(), Some("bafyrei789")); + } + _ => panic!("expected Update"), + } + } + + #[test] + fn deserialize_write_op_delete() { + let op: WriteOp = serde_json::from_value(json!({ + "action": "delete", + "collection": "com.example.forum.post", + "rkey": "3k2abc" + })) + .unwrap(); + match op { + WriteOp::Delete { + collection, + rkey, + swap_record, + } => { + assert_eq!(collection, "com.example.forum.post"); + assert_eq!(rkey, "3k2abc"); + assert_eq!(swap_record, None); + } + _ => panic!("expected Delete"), + } + } + + #[test] + fn deserialize_apply_writes_input() { + let input: ApplyWritesInput = serde_json::from_value(json!({ + "space": "ats://did:plc:abc/com.example.forum/main", + "swapCommit": "tid123", + "writes": [ + { + "action": "create", + "collection": "com.example.forum.post", + "value": { "text": "post 1" } + }, + { + "action": "delete", + "collection": "com.example.forum.post", + "rkey": "old-key" + } + ] + })) + .unwrap(); + assert_eq!(input.space, "ats://did:plc:abc/com.example.forum/main"); + assert_eq!(input.swap_commit.as_deref(), Some("tid123")); + assert_eq!(input.writes.len(), 2); + } + + #[test] + fn deserialize_apply_writes_without_swap_commit() { + let input: ApplyWritesInput = serde_json::from_value(json!({ + "space": "ats://did:plc:abc/com.example.forum/main", + "writes": [ + { + "action": "create", + "collection": "com.example.forum.post", + "value": { "text": "post" } + } + ] + })) + .unwrap(); + assert_eq!(input.swap_commit, None); + } + + #[test] + fn deserialize_write_op_rejects_unknown_action() { + let result = serde_json::from_value::(json!({ + "action": "unknown", + "collection": "test", + "rkey": "key" + })); + assert!(result.is_err()); + } +} diff --git a/src/spaces/types.rs b/src/spaces/types.rs index c62c116..db5f902 100644 --- a/src/spaces/types.rs +++ b/src/spaces/types.rs @@ -72,7 +72,9 @@ impl fmt::Display for AccessMode { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Space { pub id: String, + pub did: String, pub owner_did: String, + #[serde(rename = "type")] pub type_nsid: String, pub skey: String, pub display_name: Option, @@ -82,6 +84,7 @@ pub struct Space { pub app_denylist: Option>, pub managing_app_did: Option, pub config: SpaceConfig, + pub revision: Option, pub created_at: String, pub updated_at: String, } @@ -100,7 +103,7 @@ pub struct SpaceConfig { pub struct SpaceMember { pub id: String, pub space_id: String, - pub member_did: String, + pub did: String, pub access: SpaceAccess, pub is_delegation: bool, pub granted_by: Option, -- 2.51.2 From 6db92e008d107a8b8dbc9568034c4eda36492ca0 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 11:43:13 -0500 Subject: [PATCH 37/56] feat: refactor permissioned spaces to better align with Dan's implementation Signed-off-by: Trezy --- src/auth/middleware.rs | 44 ++++++++--- src/delegation/add_delegate.rs | 2 +- src/delegation/get_account.rs | 2 +- src/delegation/link_account.rs | 2 +- src/delegation/list_accounts.rs | 2 +- src/delegation/list_delegates.rs | 2 +- src/delegation/remove_delegate.rs | 2 +- src/delegation/unlink_account.rs | 2 +- src/dev_happyview/create_client.rs | 2 +- src/dev_happyview/delete_client.rs | 2 +- src/dev_happyview/get_client.rs | 2 +- src/dev_happyview/list_clients.rs | 2 +- src/repo/upload_blob.rs | 2 +- src/server.rs | 2 +- src/spaces/credential.rs | 84 +++++++++++++++++++- src/spaces/routes.rs | 119 ++++++++++++++++++++--------- src/xrpc/mod.rs | 4 +- 17 files changed, 215 insertions(+), 62 deletions(-) diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 0bb8ffa..c64a42e 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -220,11 +220,14 @@ pub async fn resolve_dpop_claims( /// XRPC-specific claims extractor. /// -/// Only accepts DPoP auth (`Authorization: DPoP ` + `DPoP` proof + `X-Client-Key`). -/// Cookie auth, Bearer API keys, and service JWTs are rejected on XRPC routes. -/// Wraps `Option` — `None` means anonymous (client-key-only) access. +/// Accepts DPoP auth (`Authorization: DPoP `) or Bearer space credential +/// JWTs (`Authorization: Bearer `). Cookie auth, Bearer API keys, +/// and service JWTs are rejected on XRPC routes. #[derive(Debug, Clone)] -pub struct XrpcClaims(pub Option); +pub struct XrpcClaims { + pub identity: Option, + pub space_credential: Option, +} impl FromRequestParts for XrpcClaims { type Rejection = AppError; @@ -242,19 +245,36 @@ impl FromRequestParts for XrpcClaims { Some(h) if h.starts_with("DPoP ") => { let token = &h[5..]; let claims = resolve_dpop_claims(state, parts, token).await?; - Ok(XrpcClaims(Some(claims))) + Ok(XrpcClaims { + identity: Some(claims), + space_credential: None, + }) } Some(h) if h.starts_with("Bearer ") => { - Err(AppError::Auth( - "XRPC routes do not accept Bearer auth. Use DPoP auth or omit the Authorization header for anonymous access.".into(), - )) - } - Some(_) => { - Err(AppError::Auth("invalid Authorization scheme".into())) + let token = &h[7..]; + match crate::spaces::credential::peek_jwt_typ(token) { + Some(typ) if typ == "space_credential" => { + let sub = + crate::spaces::credential::peek_credential_sub(token).ok_or_else( + || AppError::Auth("invalid space credential payload".into()), + )?; + Ok(XrpcClaims { + identity: Some(Claims::internal(sub)), + space_credential: Some(token.to_string()), + }) + } + _ => Err(AppError::Auth( + "XRPC routes do not accept Bearer auth. Use DPoP auth, a space credential, or omit the Authorization header for anonymous access.".into(), + )), + } } + Some(_) => Err(AppError::Auth("invalid Authorization scheme".into())), None => { // No auth header — anonymous access (client-key only) - Ok(XrpcClaims(None)) + Ok(XrpcClaims { + identity: None, + space_credential: None, + }) } } } diff --git a/src/delegation/add_delegate.rs b/src/delegation/add_delegate.rs index a4ecf0f..d00800c 100644 --- a/src/delegation/add_delegate.rs +++ b/src/delegation/add_delegate.rs @@ -27,7 +27,7 @@ pub async fn add_delegate( Json(input): Json, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("addDelegate requires authentication".into()))?; let caller_did = claims.did().to_string(); diff --git a/src/delegation/get_account.rs b/src/delegation/get_account.rs index bc93b87..f4a9668 100644 --- a/src/delegation/get_account.rs +++ b/src/delegation/get_account.rs @@ -21,7 +21,7 @@ pub async fn get_account( Query(params): Query, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("getAccount requires authentication".into()))?; super::verify_client_scope(&state, &claims, ¶ms.did).await?; diff --git a/src/delegation/link_account.rs b/src/delegation/link_account.rs index 0e80f44..3f22109 100644 --- a/src/delegation/link_account.rs +++ b/src/delegation/link_account.rs @@ -24,7 +24,7 @@ pub async fn link_account( Json(input): Json, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("linkAccount requires authentication".into()))?; let caller_did = claims.did().to_string(); diff --git a/src/delegation/list_accounts.rs b/src/delegation/list_accounts.rs index 96d2fb0..78a34af 100644 --- a/src/delegation/list_accounts.rs +++ b/src/delegation/list_accounts.rs @@ -14,7 +14,7 @@ pub async fn list_accounts( xrpc_claims: XrpcClaims, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("listAccounts requires authentication".into()))?; let caller_client_id = super::resolve_caller_client_id(&state, &claims).await?; diff --git a/src/delegation/list_delegates.rs b/src/delegation/list_delegates.rs index 5a0299f..b132e07 100644 --- a/src/delegation/list_delegates.rs +++ b/src/delegation/list_delegates.rs @@ -22,7 +22,7 @@ pub async fn list_delegates( Query(params): Query, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("listDelegates requires authentication".into()))?; super::verify_client_scope(&state, &claims, ¶ms.account_did).await?; diff --git a/src/delegation/remove_delegate.rs b/src/delegation/remove_delegate.rs index 456dd7f..eaede71 100644 --- a/src/delegation/remove_delegate.rs +++ b/src/delegation/remove_delegate.rs @@ -26,7 +26,7 @@ pub async fn remove_delegate( Json(input): Json, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("removeDelegate requires authentication".into()))?; let caller_did = claims.did().to_string(); diff --git a/src/delegation/unlink_account.rs b/src/delegation/unlink_account.rs index 3251af1..1a1e6e6 100644 --- a/src/delegation/unlink_account.rs +++ b/src/delegation/unlink_account.rs @@ -24,7 +24,7 @@ pub async fn unlink_account( Json(input): Json, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("unlinkAccount requires authentication".into()))?; let caller_did = claims.did().to_string(); diff --git a/src/dev_happyview/create_client.rs b/src/dev_happyview/create_client.rs index 0863190..1ab5ad8 100644 --- a/src/dev_happyview/create_client.rs +++ b/src/dev_happyview/create_client.rs @@ -21,7 +21,7 @@ pub async fn create_api_client( ) -> Result { // 1. Require DPoP auth let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("createApiClient requires DPoP authentication".into()))?; // 2. Rate-limit the request (procedure type) diff --git a/src/dev_happyview/delete_client.rs b/src/dev_happyview/delete_client.rs index 26fbce1..0dd4483 100644 --- a/src/dev_happyview/delete_client.rs +++ b/src/dev_happyview/delete_client.rs @@ -17,7 +17,7 @@ pub async fn delete_api_client( ) -> Result { // 1. Require DPoP auth let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("deleteApiClient requires DPoP authentication".into()))?; // 2. Rate-limit the request (procedure type) diff --git a/src/dev_happyview/get_client.rs b/src/dev_happyview/get_client.rs index 11d7e66..df58823 100644 --- a/src/dev_happyview/get_client.rs +++ b/src/dev_happyview/get_client.rs @@ -21,7 +21,7 @@ pub async fn get_api_client( Query(params): Query, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("getApiClient requires DPoP authentication".into()))?; let check = if let Some(client_key) = claims.client_key() { diff --git a/src/dev_happyview/list_clients.rs b/src/dev_happyview/list_clients.rs index 3e29874..6c17ecd 100644 --- a/src/dev_happyview/list_clients.rs +++ b/src/dev_happyview/list_clients.rs @@ -14,7 +14,7 @@ pub async fn list_api_clients( xrpc_claims: XrpcClaims, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("listApiClients requires DPoP authentication".into()))?; let check = if let Some(client_key) = claims.client_key() { diff --git a/src/repo/upload_blob.rs b/src/repo/upload_blob.rs index f0a23e2..452d1fd 100644 --- a/src/repo/upload_blob.rs +++ b/src/repo/upload_blob.rs @@ -18,7 +18,7 @@ pub async fn upload_blob( body: Bytes, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("uploadBlob requires DPoP authentication".into()))?; let check = if let Some(client_key) = claims.client_key() { let cost = state diff --git a/src/server.rs b/src/server.rs index 26bc268..479c67c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -311,7 +311,7 @@ async fn get_profile( xrpc_claims: XrpcClaims, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("getProfile requires DPoP authentication".into()))?; let check = if let Some(client_key) = claims.client_key() { let cost = state diff --git a/src/spaces/credential.rs b/src/spaces/credential.rs index d20054f..33c429e 100644 --- a/src/spaces/credential.rs +++ b/src/spaces/credential.rs @@ -9,6 +9,25 @@ use crate::profile; pub const DEFAULT_CREDENTIAL_TTL_SECS: u64 = 4 * 60 * 60; // 4 hours pub const GRANT_TTL_SECS: u64 = 5 * 60; // 5 minutes +/// Peek at a JWT's header to check its `typ` field without verifying the signature. +pub fn peek_jwt_typ(token: &str) -> Option { + let header_b64 = token.split('.').next()?; + let header_bytes = URL_SAFE_NO_PAD.decode(header_b64).ok()?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes).ok()?; + header["typ"].as_str().map(|s| s.to_string()) +} + +/// Peek at a space credential JWT's payload to extract the `sub` (user DID) without verifying. +pub fn peek_credential_sub(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return None; + } + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).ok()?; + let claims: SpaceCredentialClaims = serde_json::from_slice(&payload_bytes).ok()?; + Some(claims.sub) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemberGrantClaims { pub sub: String, @@ -72,7 +91,7 @@ pub fn sign_credential( let header = serde_json::json!({ "alg": "ES256", - "typ": "JWT", + "typ": "space_credential", }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); @@ -104,6 +123,12 @@ pub fn verify_credential( return Err(AppError::Auth("credential alg must be ES256".into())); } + if header["typ"].as_str() != Some("space_credential") { + return Err(AppError::Auth( + "credential typ must be space_credential".into(), + )); + } + let x_b64 = public_jwk["x"] .as_str() .ok_or_else(|| AppError::Auth("public key missing x".into()))?; @@ -387,4 +412,61 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("expired")); } + + #[test] + fn credential_has_space_credential_typ() { + let keypair = generate_dpop_keypair().unwrap(); + let claims = make_claims(); + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); + assert_eq!(peek_jwt_typ(&token).as_deref(), Some("space_credential")); + } + + #[test] + fn peek_jwt_typ_returns_none_for_garbage() { + assert_eq!(peek_jwt_typ("not-a-jwt"), None); + assert_eq!(peek_jwt_typ(""), None); + } + + #[test] + fn peek_credential_sub_extracts_did() { + let keypair = generate_dpop_keypair().unwrap(); + let claims = make_claims(); + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); + assert_eq!( + peek_credential_sub(&token).as_deref(), + Some("did:plc:requester") + ); + } + + #[test] + fn peek_credential_sub_returns_none_for_garbage() { + assert_eq!(peek_credential_sub("not-a-jwt"), None); + } + + #[test] + fn verify_rejects_wrong_typ() { + let keypair = generate_dpop_keypair().unwrap(); + let claims = make_claims(); + + let d_b64 = keypair.private_jwk["d"].as_str().unwrap(); + let d_bytes = URL_SAFE_NO_PAD.decode(d_b64).unwrap(); + let signing_key = p256::ecdsa::SigningKey::from_bytes((&d_bytes[..]).into()).unwrap(); + + let header = serde_json::json!({ "alg": "ES256", "typ": "JWT" }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + let sig: p256::ecdsa::Signature = + p256::ecdsa::signature::Signer::sign(&signing_key, message.as_bytes()); + let token = format!( + "{}.{}.{}", + header_b64, + payload_b64, + URL_SAFE_NO_PAD.encode(sig.to_bytes()) + ); + + let result = verify_credential(&token, &keypair.public_jwk); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("typ")); + } } diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs index 29abc02..adaf9cf 100644 --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -1,5 +1,5 @@ use axum::extract::{Query, State}; -use axum::http::{HeaderMap, StatusCode}; +use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; @@ -201,11 +201,11 @@ const NS: &str = "dev.happyview"; pub fn space_routes() -> Router { Router::new() // Space CRUD - .route(&format!("/xrpc/{NS}.space.create"), post(create_space)) - .route(&format!("/xrpc/{NS}.space.get"), get(get_space)) - .route(&format!("/xrpc/{NS}.space.list"), get(list_spaces)) - .route(&format!("/xrpc/{NS}.space.delete"), post(delete_space)) - .route(&format!("/xrpc/{NS}.space.update"), post(update_space)) + .route(&format!("/xrpc/{NS}.space.createSpace"), post(create_space)) + .route(&format!("/xrpc/{NS}.space.getSpace"), get(get_space)) + .route(&format!("/xrpc/{NS}.space.listSpaces"), get(list_spaces)) + .route(&format!("/xrpc/{NS}.space.deleteSpace"), post(delete_space)) + .route(&format!("/xrpc/{NS}.space.updateSpace"), post(update_space)) // Records .route( &format!("/xrpc/{NS}.space.createRecord"), @@ -227,6 +227,34 @@ pub fn space_routes() -> Router { post(remove_member), ) // Invites + .route( + &format!("/xrpc/{NS}.space.createInvite"), + post(create_invite), + ) + .route( + &format!("/xrpc/{NS}.space.redeemInvite"), + post(redeem_invite), + ) + .route( + &format!("/xrpc/{NS}.space.revokeInvite"), + post(revoke_invite), + ) + .route(&format!("/xrpc/{NS}.space.listInvites"), get(list_invites)) + // Credentials + .route( + &format!("/xrpc/{NS}.space.getMemberGrant"), + post(get_member_grant), + ) + .route( + &format!("/xrpc/{NS}.space.getSpaceCredential"), + post(get_space_credential), + ) + // Legacy aliases (will be removed in a future release) + .route(&format!("/xrpc/{NS}.space.create"), post(create_space)) + .route(&format!("/xrpc/{NS}.space.get"), get(get_space)) + .route(&format!("/xrpc/{NS}.space.list"), get(list_spaces)) + .route(&format!("/xrpc/{NS}.space.delete"), post(delete_space)) + .route(&format!("/xrpc/{NS}.space.update"), post(update_space)) .route( &format!("/xrpc/{NS}.space.invite.create"), post(create_invite), @@ -257,9 +285,9 @@ pub fn space_routes() -> Router { fn require_auth(claims: &XrpcClaims) -> Result<&crate::auth::Claims, AppError> { claims - .0 + .identity .as_ref() - .ok_or_else(|| AppError::Auth("This endpoint requires DPoP authentication".into())) + .ok_or_else(|| AppError::Auth("This endpoint requires authentication".into())) } async fn resolve_space(state: &AppState, space_uri: &str) -> Result { @@ -293,13 +321,6 @@ async fn require_space_admin(state: &AppState, space: &Space, did: &str) -> Resu )) } -fn extract_space_credential(headers: &HeaderMap) -> Option { - headers - .get("x-space-credential") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) -} - async fn require_membership( state: &AppState, space: &Space, @@ -552,14 +573,19 @@ async fn update_space( async fn create_record( State(state): State, xrpc_claims: XrpcClaims, - headers: HeaderMap, Json(input): Json, ) -> Result { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); let space = resolve_space(&state, &input.space).await?; - let cred = extract_space_credential(&headers); - require_membership(&state, &space, &did, true, cred.as_deref()).await?; + require_membership( + &state, + &space, + &did, + true, + xrpc_claims.space_credential.as_deref(), + ) + .await?; let rkey = generate_tid(); let cid = content_cid(&input.record); @@ -597,14 +623,19 @@ async fn create_record( async fn put_record( State(state): State, xrpc_claims: XrpcClaims, - headers: HeaderMap, Json(input): Json, ) -> Result { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); let space = resolve_space(&state, &input.space).await?; - let cred = extract_space_credential(&headers); - require_membership(&state, &space, &did, true, cred.as_deref()).await?; + require_membership( + &state, + &space, + &did, + true, + xrpc_claims.space_credential.as_deref(), + ) + .await?; let cid = content_cid(&input.record); let record_uri = format!( @@ -684,14 +715,19 @@ async fn delete_record( async fn apply_writes( State(state): State, xrpc_claims: XrpcClaims, - headers: HeaderMap, Json(input): Json, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); let space = resolve_space(&state, &input.space).await?; - let cred = extract_space_credential(&headers); - require_membership(&state, &space, &did, true, cred.as_deref()).await?; + require_membership( + &state, + &space, + &did, + true, + xrpc_claims.space_credential.as_deref(), + ) + .await?; if let Some(ref expected_rev) = input.swap_commit { match &space.revision { @@ -809,13 +845,18 @@ async fn apply_writes( async fn get_record( State(state): State, xrpc_claims: XrpcClaims, - headers: HeaderMap, Query(query): Query, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; let space = resolve_space(&state, &query.space).await?; - let cred = extract_space_credential(&headers); - require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; + require_membership( + &state, + &space, + claims.did(), + false, + xrpc_claims.space_credential.as_deref(), + ) + .await?; let record = db::get_space_record_by_parts( &state.db, @@ -837,16 +878,21 @@ async fn get_record( async fn list_records( State(state): State, xrpc_claims: XrpcClaims, - headers: HeaderMap, Query(query): Query, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; let space = resolve_space(&state, &query.space).await?; - let cred = extract_space_credential(&headers); - require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; + require_membership( + &state, + &space, + claims.did(), + false, + xrpc_claims.space_credential.as_deref(), + ) + .await?; let repo = query.repo.as_deref().or_else(|| { - if cred.is_some() { + if xrpc_claims.space_credential.is_some() { None } else { Some(claims.did()) @@ -893,15 +939,20 @@ async fn list_records( async fn list_members( State(state): State, xrpc_claims: XrpcClaims, - headers: HeaderMap, Query(query): Query, ) -> Result, AppError> { let space = resolve_space(&state, &query.space).await?; if !space.config.membership_public { let claims = require_auth(&xrpc_claims)?; - let cred = extract_space_credential(&headers); - require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; + require_membership( + &state, + &space, + claims.did(), + false, + xrpc_claims.space_credential.as_deref(), + ) + .await?; } let resolved = members::resolve_members(&state.db, state.db_backend, &space.id).await?; diff --git a/src/xrpc/mod.rs b/src/xrpc/mod.rs index 093f1e7..1865c15 100644 --- a/src/xrpc/mod.rs +++ b/src/xrpc/mod.rs @@ -258,7 +258,7 @@ pub async fn xrpc_get( ) -> Result { let raw_query = raw_query.unwrap_or_default(); let mut params = parse_query_params(&raw_query); - let claims = xrpc_claims.0; + let claims = xrpc_claims.identity; let rate_key = resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)?; @@ -349,7 +349,7 @@ pub async fn xrpc_post( let raw_query = raw_query.unwrap_or_default(); let mut params = parse_query_params(&raw_query); let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("XRPC procedures require DPoP authentication".into()))?; let rate_key = resolve_client_key(&state, Some(&claims), &parts, ¶ms)?; -- 2.51.2 From 19b1253e0ef571fd6daea5728fb608ea5b13ed5a Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 12:37:21 -0500 Subject: [PATCH 38/56] docs: add docs for permissioned spaces Signed-off-by: Trezy --- packages/docs/docs/experimental/spaces.md | 102 ++++++ .../docs/experimental/spaces/changelog.md | 64 ++++ .../docs/experimental/spaces/credentials.md | 128 ++++++++ .../docs/docs/experimental/spaces/invites.md | 134 ++++++++ .../experimental/spaces/managing-spaces.md | 135 ++++++++ .../docs/docs/experimental/spaces/members.md | 125 ++++++++ .../docs/docs/experimental/spaces/records.md | 292 ++++++++++++++++++ packages/docs/sidebars.ts | 47 +++ 8 files changed, 1027 insertions(+) create mode 100644 packages/docs/docs/experimental/spaces.md create mode 100644 packages/docs/docs/experimental/spaces/changelog.md create mode 100644 packages/docs/docs/experimental/spaces/credentials.md create mode 100644 packages/docs/docs/experimental/spaces/invites.md create mode 100644 packages/docs/docs/experimental/spaces/managing-spaces.md create mode 100644 packages/docs/docs/experimental/spaces/members.md create mode 100644 packages/docs/docs/experimental/spaces/records.md diff --git a/packages/docs/docs/experimental/spaces.md b/packages/docs/docs/experimental/spaces.md new file mode 100644 index 0000000..41d9e0e --- /dev/null +++ b/packages/docs/docs/experimental/spaces.md @@ -0,0 +1,102 @@ +# Permissioned Spaces + +:::caution Experimental +Permissioned Spaces are experimental and the API will change. This implementation follows Daniel Holmgren's [Permissioned Data Diaries](https://dholms.leaflet.pub/3meluqcwky22a) and aligns structurally with the `permissioned-data` branch on `bluesky-social/atproto`, but uses a `dev.happyview` namespace to allow iteration while the official spec stabilizes. +::: + +Spaces are containers for permissioned data in atproto. Unlike regular public records that live in a user's repo, space records are gated by membership — only members can read or write data within a space. + +## Concepts + +A **space** is identified by three components: + +- **Space DID** — the space's own decentralized identifier (for personal spaces, this is the user's DID) +- **Type** — the space type as an NSID, describing the modality (e.g. a forum, a group chat, a photo album) +- **Space key (skey)** — a short string differentiating multiple spaces of the same type + +These form the space URI: `ats:////` + +A **space record** adds three more components to the URI: the author's DID, the collection NSID, and the record key: + +``` +ats://///// +``` + +## Feature flag + +In HappyView, spaces are gated behind the `feature.spaces_enabled` instance setting. Enable it in the dashboard under **Settings** or via the admin API: + +```sh +curl -X PUT http://127.0.0.1:3000/admin/settings/feature.spaces_enabled \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"value": "true"}' +``` + +When disabled, all `/xrpc/dev.happyview.space.*` endpoints return `501 Not Implemented`. + +## Endpoints + +All space endpoints live under the `dev.happyview.space` namespace and require [DPoP authentication](../getting-started/authentication.md). + +| Endpoint | Method | Description | +| ---------------------------------------- | ------ | ------------------------------------- | +| `dev.happyview.space.createSpace` | POST | Create a space | +| `dev.happyview.space.getSpace` | GET | Get a space by URI | +| `dev.happyview.space.listSpaces` | GET | List spaces by membership | +| `dev.happyview.space.updateSpace` | POST | Update space metadata | +| `dev.happyview.space.deleteSpace` | POST | Delete a space | +| `dev.happyview.space.createRecord` | POST | Create a record (auto-generated rkey) | +| `dev.happyview.space.putRecord` | POST | Write a record | +| `dev.happyview.space.getRecord` | GET | Get a record | +| `dev.happyview.space.listRecords` | GET | List records | +| `dev.happyview.space.deleteRecord` | POST | Delete a record | +| `dev.happyview.space.applyWrites` | POST | Batch write operations | +| `dev.happyview.space.addMember` | POST | Add a member | +| `dev.happyview.space.removeMember` | POST | Remove a member | +| `dev.happyview.space.listMembers` | GET | List resolved members | +| `dev.happyview.space.createInvite` | POST | Create an invite | +| `dev.happyview.space.redeemInvite` | POST | Redeem an invite | +| `dev.happyview.space.revokeInvite` | POST | Revoke an invite | +| `dev.happyview.space.listInvites` | GET | List invites | +| `dev.happyview.space.getMemberGrant` | POST | Prove membership (step 1) | +| `dev.happyview.space.getSpaceCredential` | POST | Get a space credential (step 2) | + +## Access model + +Spaces have an **access mode** that controls third-party app access: + +- **`default_allow`** — any app can access (with optional denylist) +- **`default_deny`** — only explicitly allowed apps can access + +Individual users access spaces through **membership**. Members have either `read` or `write` access. Write access implies read. The space creator is automatically added as a write member. + +Spaces also support **delegation** — adding another space as a member, which transitively grants access to all members of the delegated space. + +## Divergences from the reference spec + +HappyView mostly mirrors [Daniel Holmgren's `permissioned-data` branch](https://github.com/bluesky-social/atproto/tree/permissioned-data) but diverges in some areas. These will narrow as the official spec stabilizes. + +### HappyView extensions (not in the reference branch) + +- **`isDelegation` on members** allows spaces to be members of other spaces +- **`displayName`, `description`, `accessMode` on spaces** — the reference space model is minimal (`uri`, `isOwner`, `isMember`, `createdAt`) +- **`appAllowlist` / `appDenylist` / `managingAppDid`** — app-level access control layer +- **`config` object** on spaces (e.g. `membershipPublic`, `recordsPublic`) +- **Invite system** — `createInvite`, `redeemInvite`, `revokeInvite`, `listInvites` +- **`read` / `write` access levels** — the reference branch treats membership as binary + +### Reference features not yet implemented + +- **Oplogs** — `getRepoOplog`, `getMemberOplog`, `getRepoState`, `getMemberState` (sync primitives for space data) +- **Push notifications** — `notifyWrite`, `notifyMembership` (service-to-service event delivery) +- **Space-scoped blobs** — `uploadBlob` for blobs within a space context +- **Owner record deletion** — in the reference branch the space owner can delete any record; HappyView restricts `deleteRecord` to the record's author only + +## Next steps + +- [Managing Spaces](spaces/managing-spaces.md) — create, update, and delete spaces +- [Members](spaces/members.md) — manage membership and delegation +- [Records](spaces/records.md) — read and write permissioned data +- [Credentials](spaces/credentials.md) — cross-service authentication for spaces +- [Invites](spaces/invites.md) — invite-based membership diff --git a/packages/docs/docs/experimental/spaces/changelog.md b/packages/docs/docs/experimental/spaces/changelog.md new file mode 100644 index 0000000..deaf36e --- /dev/null +++ b/packages/docs/docs/experimental/spaces/changelog.md @@ -0,0 +1,64 @@ +# Changelog + +## Latest + +### New endpoints + +- **`createRecord`:** create a record with an auto-generated TID rkey instead of requiring the caller to supply one +- **`applyWrites`:** batch multiple create, update, and delete operations in a single request + +### Optimistic concurrency + +- **`swapRecord`:** optional CID-based concurrency guard on `putRecord`, `deleteRecord`, and individual operations within `applyWrites`. Returns `409 Conflict` when the record's current CID doesn't match. +- **`swapCommit`:** optional revision-based concurrency guard on `applyWrites`. Asserts the space's current revision before applying any writes. Returns `409 Conflict` on mismatch. +- Spaces now track a `revision` field (TID) that advances on every write. + +### Space DID separation + +- Spaces now have their own `did` field, distinct from the `owner_did` of the space creator. For personal spaces these are the same DID; multi-party spaces will have their own DID. +- All URI construction and lookups use the space's DID. Ownership checks use `owner_did`. +- New database migration adds the `did` column to the `spaces` table. + +### Two-step credential flow + +- Replaced the single `getCredential` endpoint with a two-step flow: + 1. **`getMemberGrant`:** proves membership and returns an HMAC-SHA256 grant (5-minute TTL) + 2. **`getSpaceCredential`:** exchanges the grant for an ES256 space credential JWT (4-hour TTL) +- Removed the `refreshCredential` endpoint (just repeat the two-step flow) + +### Bearer auth for space credentials + +- Space credentials are now passed as standard `Authorization: Bearer ` instead of a custom `X-Space-Credential` header. HappyView distinguishes credentials from other Bearer tokens by checking the JWT `typ` header (`space_credential`), matching Dan's reference implementation. +- No DPoP auth or client key needed when authenticating via space credential. + +### Endpoint naming + +- Space CRUD endpoints renamed to verbNoun format: `space.create` → `space.createSpace`, `space.get` → `space.getSpace`, `space.list` → `space.listSpaces`, `space.update` → `space.updateSpace`, `space.delete` → `space.deleteSpace`. +- Invite endpoints moved out of the `invite.*` sub-namespace: `invite.create` → `space.createInvite`, `invite.redeem` → `space.redeemInvite`, `invite.revoke` → `space.revokeInvite`, `invite.list` → `space.listInvites`. +- Old endpoint names are still available as legacy aliases and will be removed in a future release. + +### Bug fixes + +- Fixed `WriteOp` serde deserialization. `swapRecord` fields in `update` and `delete` operations now correctly deserialize from camelCase JSON. +- Credential `iss` claim now uses the space's DID instead of the owner's DID. +- `SpaceUri` parsing updated to use `did` (space DID) instead of `owner_did`. + +--- + +## v2.5.0 + +_Released 2026-05-05_ + +Initial release of Permissioned Spaces behind the `feature.spaces_enabled` experimental flag. + +### Features + +- Space CRUD: `create`, `get`, `list`, `update`, `delete` +- Record operations: `putRecord`, `getRecord`, `listRecords`, `deleteRecord` +- Membership management: `addMember`, `removeMember`, `listMembers` +- Invite system: `invite.create`, `invite.redeem`, `invite.revoke`, `invite.list` +- `ats://` URI scheme for addressing permissioned data +- Access model with `default_allow` / `default_deny` modes and app allowlists/denylists +- Space credentials for cross-service read access via `X-Space-Credential` header +- Delegation: adding a space as a member transitively grants access to its members +- Lua scripting context includes space metadata (`space.did`, `space.owner_did`, `space.type_nsid`, `space.skey`) diff --git a/packages/docs/docs/experimental/spaces/credentials.md b/packages/docs/docs/experimental/spaces/credentials.md new file mode 100644 index 0000000..afd8855 --- /dev/null +++ b/packages/docs/docs/experimental/spaces/credentials.md @@ -0,0 +1,128 @@ +# Credentials + +:::caution Experimental +This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. +::: + +Space credentials are short-lived JWTs for cross-service access to space data. A member proves their membership to get a grant, exchanges the grant for a credential JWT, then passes it to an external service that needs to read the space's records. + +## How credentials work + +Credential issuance is a two-step process: + +```mermaid +sequenceDiagram + participant App as Client App + participant HV as HappyView + participant Svc as External Service + + App->>HV: POST dev.happyview.space.getMemberGrant
(DPoP auth, must be a member) + HV->>HV: Verify membership + HV-->>App: grant token + expiresAt + + App->>HV: POST dev.happyview.space.getSpaceCredential
(DPoP auth, grant token) + HV->>HV: Verify grant
Check app access (allow/deny list)
Sign credential with space keypair + HV-->>App: credential JWT + expiresAt + + App->>Svc: Request with Authorization: Bearer credential + Svc->>HV: Read space records
(Bearer credential) + HV->>HV: Verify credential signature
via space DID doc + HV-->>Svc: Record data +``` + +Credentials are ES256 JWTs signed with a P-256 keypair unique to each space. The keypair is generated on first credential request and stored encrypted (AES-256-GCM). + +## Step 1: Get a member grant + +The caller must be an authenticated member of the space. The grant is a short-lived token (5 minutes) that proves membership. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main" + }' +``` + +**Response:** + +```json +{ + "grant": "eyJhbGciOiJIUzI1NiJ9...", + "expiresAt": "2026-05-09T12:05:00Z" +} +``` + +## Step 2: Get a space credential + +Exchange the grant for a space credential JWT. The credential is signed by the space's keypair and has a 4-hour TTL. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "grant": "eyJhbGciOiJIUzI1NiJ9..." + }' +``` + +**Response:** + +```json +{ + "credential": "eyJhbGciOiJFUzI1NiJ9...", + "expiresAt": "2026-05-09T16:00:00Z" +} +``` + +### Credential claims + +The JWT payload contains: + +| Claim | Description | +|---|---| +| `iss` | The space's DID (who signed it) | +| `sub` | The member's DID (who it was issued to) | +| `space` | The full `ats://` space URI | +| `scope` | Access level (`read`) | +| `iat` | Issued at (Unix timestamp) | +| `exp` | Expiry (Unix timestamp) | + +## Using a credential + +Pass the credential as a standard Bearer token in the `Authorization` header. HappyView distinguishes space credentials from other tokens by checking the JWT header's `typ` field (`space_credential`). + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...' \ + -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6InNwYWNlX2NyZWRlbnRpYWwifQ...' +``` + +No DPoP auth or client key is needed when authenticating via space credential — the credential itself is sufficient. The user's identity comes from the `sub` claim in the JWT. + +HappyView verifies the credential by resolving the issuer's DID document, extracting the signing key, and validating the JWT signature and expiry. If valid, the request is treated as if the credential's `sub` is a member of the space. + +## App access control + +Before issuing a credential, HappyView checks whether the calling app (identified by its DPoP client key) is allowed to access the space: + +- **`default_allow` mode**: any app can get credentials unless it's on the `appDenylist` +- **`default_deny` mode**: only apps on the `appAllowlist` can get credentials + +If no client key is present in the DPoP claims, the check is skipped (direct user access without an app intermediary). + +## External credential verification + +HappyView can also verify credentials issued by *other* HappyView instances or space-aware services. When a Bearer space credential is presented, HappyView: + +1. Decodes the JWT without verification to extract the `iss` (issuer DID) +2. Resolves the issuer's DID document +3. Extracts the signing key from the DID doc +4. Verifies the JWT signature and expiry +5. Checks that the `space` claim matches the requested space + +A credential issued by one instance can be used to read from another instance that hosts the same space's data. diff --git a/packages/docs/docs/experimental/spaces/invites.md b/packages/docs/docs/experimental/spaces/invites.md new file mode 100644 index 0000000..b0e15ce --- /dev/null +++ b/packages/docs/docs/experimental/spaces/invites.md @@ -0,0 +1,134 @@ +# Invites + +:::caution Experimental +This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. +::: + +Invites let space owners distribute membership tokens without knowing recipients' DIDs in advance. + +:::info HappyView Extension +Invites are a HappyView-specific feature, not part of the AT Protocol spaces spec. They may be replaced by a different mechanism in the future. +::: + +## Creating an invite + +Only the space owner or a super admin can create invites. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createInvite' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "access": "write", + "maxUses": 10, + "expiresAt": "2026-06-01T00:00:00Z" + }' +``` + +**Input:** + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `space` | string | Yes | | The space this invite is for | +| `access` | string | No | `read` | Access level granted on redemption (`read` or `write`) | +| `maxUses` | integer | No | unlimited | Maximum number of times the invite can be redeemed | +| `expiresAt` | string (datetime) | No | never | When the invite expires | + +**Response (201):** + +```json +{ + "inviteId": "uuid", + "token": "a1b2c3d4e5f6...", + "access": "write", + "maxUses": 10, + "expiresAt": "2026-06-01T00:00:00Z" +} +``` + +:::warning +The `token` is only returned once. It is stored as a SHA-256 hash — HappyView cannot recover the plaintext. +::: + +## Redeeming an invite + +Any authenticated user can redeem an invite token to join the space. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "token": "a1b2c3d4e5f6..." + }' +``` + +**Response (201):** + +```json +{ + "uri": "ats://did:plc:abc123/com.example.forum/main", + "access": "write" +} +``` + +Redemption fails if: + +- The token is invalid (no matching hash found) +- The invite has been revoked +- The invite has reached its `maxUses` +- The invite has expired +- The user is already a member of the space + +## Revoking an invite + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.revokeInvite' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "inviteId": "uuid" + }' +``` + +Revoking an invite prevents future redemptions but does not remove members who already redeemed it. + +## Listing invites + +Only the space owner or a super admin can list invites. + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.listInvites?space=ats://did:plc:abc123/com.example.forum/main' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Response:** + +```json +{ + "invites": [ + { + "id": "uuid", + "access": "write", + "maxUses": 10, + "uses": 3, + "expiresAt": "2026-06-01T00:00:00Z", + "revoked": false, + "createdBy": "did:plc:abc123", + "createdAt": "2026-05-09T12:00:00Z" + } + ] +} +``` + +The token itself is never returned in list responses — only the invite metadata. diff --git a/packages/docs/docs/experimental/spaces/managing-spaces.md b/packages/docs/docs/experimental/spaces/managing-spaces.md new file mode 100644 index 0000000..caff754 --- /dev/null +++ b/packages/docs/docs/experimental/spaces/managing-spaces.md @@ -0,0 +1,135 @@ +# Managing Spaces + +:::caution Experimental +This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. +::: + +## Creating a space + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createSpace' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "type": "com.example.forum", + "skey": "main", + "displayName": "My Forum", + "description": "A place for discussion", + "accessMode": "default_allow" + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ---------------- | ------------- | -------- | ------------------------------------------------- | +| `type` | string (NSID) | Yes | The space type; describes what this space is for | +| `skey` | string | Yes | Space key; differentiates spaces of the same type | +| `displayName` | string | No | Human-readable name | +| `description` | string | No | Description of the space | +| `accessMode` | string | No | `default_allow` (default) or `default_deny` | +| `managingAppDid` | string | No | DID of the application that manages this space | +| `config` | object | No | Space configuration (see below) | + +**Response (201):** + +```json +{ + "uri": "ats://did:plc:abc123/com.example.forum/main" +} +``` + +The creator is automatically added as a write member. Use [`dev.happyview.space.getSpace`](#getting-a-space) to retrieve the full space object. + +### Space configuration + +The `config` object supports: + +| Field | Type | Default | Description | +| ------------------ | ------- | ------- | --------------------------------------------------------- | +| `membershipPublic` | boolean | `false` | Whether the member list is visible without authentication | +| `recordsPublic` | boolean | `false` | Whether records are readable without membership | + +Additional fields are preserved as-is. + +## Getting a space + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +If `membershipPublic` is `false`, the caller must be authenticated and be a member (or the owner) to see the space. Non-members receive a `404 Not Found`. + +## Listing spaces + +Returns spaces where the authenticated user is a member. + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Parameters:** + +| Field | Type | Required | Default | Description | +| -------- | ------- | -------- | ------- | ---------------------------- | +| `limit` | integer | No | 50 | Max spaces to return (1-100) | +| `cursor` | string | No | | Pagination cursor | + +**Response:** + +```json +{ + "spaces": [ + { + "uri": "ats://did:plc:abc123/com.example.forum/main", + "isOwner": true + } + ], + "cursor": "2026-05-09T12:00:00Z" +} +``` + +## Updating a space + +Only the space owner or a HappView super admin can update a space. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.updateSpace' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "displayName": "Updated Forum Name", + "accessMode": "default_deny", + "appAllowlist": ["did:web:myapp.example.com"] + }' +``` + +All fields except `space` are optional. Only provided fields are updated. To clear an optional field, pass `null`. + +## Deleting a space + +Only the space owner or a HappyView super admin can delete a space. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{"space": "ats://did:plc:abc123/com.example.forum/main"}' +``` + +:::warning +Deleting a space does not currently cascade to records, members, or credentials. This behavior may change. +::: diff --git a/packages/docs/docs/experimental/spaces/members.md b/packages/docs/docs/experimental/spaces/members.md new file mode 100644 index 0000000..24299a8 --- /dev/null +++ b/packages/docs/docs/experimental/spaces/members.md @@ -0,0 +1,125 @@ +# Members + +:::caution Experimental +This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. +::: + +Membership determines who can read and write within a space. Members have either `read` or `write` access — write implies read. + +## Adding a member + +Only the space owner or a super admin can add members. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "did": "did:plc:newmember", + "access": "write", + "isDelegation": false + }' +``` + +**Input:** + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `space` | string | Yes | | The space to add the member to | +| `did` | string | Yes | | DID of the member (or space for delegation) | +| `access` | string | No | `read` | `read` or `write` | +| `isDelegation` | boolean | No | `false` | Whether this member is a delegated space | + +**Response (201):** + +```json +{ + "member": { + "id": "uuid", + "spaceId": "space-uuid", + "did": "did:plc:newmember", + "access": "write", + "isDelegation": false, + "grantedBy": "did:plc:abc123", + "createdAt": "2026-05-09T12:00:00Z" + } +} +``` + +## Removing a member + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.removeMember' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "did": "did:plc:newmember" + }' +``` + +## Listing members + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +If the space's `membershipPublic` config is `true`, this endpoint is accessible without authentication. Otherwise, the caller must be authenticated and be a member. + +The response returns the **resolved** member list — delegation chains are traversed and flattened: + +```json +{ + "members": [ + { "did": "did:plc:abc123", "access": "write" }, + { "did": "did:plc:newmember", "access": "write" }, + { "did": "did:plc:delegated-user", "access": "read" } + ] +} +``` + +## Delegation + +A space can be added as a member of another space by setting `isDelegation: true`. This transitively grants access to all members of the delegated space. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "did": "ats://did:plc:org/com.example.team/engineering", + "access": "read", + "isDelegation": true + }' +``` + +Delegation chains are resolved up to 10 levels deep. When a user appears in multiple chains, the highest access level wins (`write` > `read`). + +### Example: nested teams + +```mermaid +graph TD + A[Forum Space] -->|delegation, write| B[Engineering Team Space] + A -->|delegation, read| C[Design Team Space] + B -->|member, write| D[Alice] + B -->|member, write| E[Bob] + C -->|member, read| F[Carol] + C -->|member, read| D +``` + +In this example: +- Alice has `write` access (via Engineering) +- Bob has `write` access (via Engineering) +- Carol has `read` access (via Design) +- Alice also appears in Design, but `write` wins over `read` diff --git a/packages/docs/docs/experimental/spaces/records.md b/packages/docs/docs/experimental/spaces/records.md new file mode 100644 index 0000000..b58dfa9 --- /dev/null +++ b/packages/docs/docs/experimental/spaces/records.md @@ -0,0 +1,292 @@ +# Records + +:::caution Experimental +This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. +::: + +Space records are stored separately from public AT Protocol records. They follow the same URI pattern but use the `ats://` scheme and include the space identity: + +``` +ats:// did:plc:abcdefghijklmnop1234567890 / com.example.forum / main / did:plc:author / com.example.forum.post / abcdefghijklmnop1234567890 + └── space DID ───────────────────┘ └── space type ─┘ └── skey ─┘ └── author ──┘ └── collection ──────┘ └── rkey ────────────────┘ +``` + +## Creating a record + +Requires `write` membership in the space. The rkey is auto-generated using a TID. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createRecord' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "collection": "com.example.forum.post", + "record": { + "$type": "com.example.forum.post", + "text": "Hello from the forum!", + "createdAt": "2026-05-09T12:00:00Z" + } + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------------ | ------------- | -------- | ----------------------- | +| `space` | string | Yes | The space to write into | +| `collection` | string (NSID) | Yes | The record collection | +| `record` | object | Yes | The record data | + +**Response (201):** + +```json +{ + "uri": "ats://did:plc:abc123/com.example.forum/main/did:plc:author/com.example.forum.post/3l2tkbx7225co", + "cid": "bafyrei..." +} +``` + +`createRecord` always inserts a new record. If a record with the generated URI already exists, it returns `409 Conflict`. + +## Writing a record (put) + +Requires `write` membership in the space. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.putRecord' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "record": { + "$type": "com.example.forum.post", + "text": "Hello from the forum!", + "createdAt": "2026-05-09T12:00:00Z" + } + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------------ | ------------- | -------- | ---------------------------------------------------------------- | +| `space` | string | Yes | The space to write into | +| `collection` | string (NSID) | Yes | The record collection | +| `rkey` | string | Yes | The record key | +| `record` | object | Yes | The record data | +| `swapRecord` | string | No | Expected CID of the existing record (for optimistic concurrency) | + +**Response (201):** + +```json +{ + "uri": "ats://did:plc:abc123/com.example.forum/main/did:plc:author/com.example.forum.post/3k2abc", + "cid": "bafyrei..." +} +``` + +The author DID is taken from the authenticated user. You can only write records as yourself, so the URI's author component will always be your DID. + +`putRecord` performs an upsert: if a record with the same collection + rkey already exists for this author in this space, it's overwritten. Use `swapRecord` to prevent unintended overwrites (see [Optimistic concurrency](#optimistic-concurrency) below). + +## Getting a record + +Requires `read` membership (or a valid [space credential](credentials.md)). + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Response:** + +```json +{ + "uri": "ats://did:plc:abc123/com.example.forum/main/did:plc:author/com.example.forum.post/3k2abc", + "cid": "bafyrei...", + "value": { + "$type": "com.example.forum.post", + "text": "Hello from the forum!", + "createdAt": "2026-05-09T12:00:00Z" + } +} +``` + +## Listing records + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Parameters:** + +| Field | Type | Required | Default | Description | +| ------------ | ------- | -------- | ------- | --------------------------------- | +| `space` | string | Yes | | The space to list from | +| `repo` | string | No | | Filter by author DID | +| `collection` | string | No | | Filter by collection NSID | +| `limit` | integer | No | 50 | Max records to return (1-100) | +| `cursor` | string | No | | Pagination cursor | +| `reverse` | boolean | No | `false` | Reverse sort order (oldest first) | + +**Response:** + +```json +{ + "records": [ + { + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "cid": "bafyrei..." + } + ], + "cursor": "2026-05-09T12:00:00Z" +} +``` + +## Deleting a record + +You can only delete your own records. Requires `write` membership. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "collection": "com.example.forum.post", + "rkey": "3k2abc" + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------------ | ------------- | -------- | ---------------------------------------------------------------- | +| `space` | string | Yes | The space containing the record | +| `collection` | string (NSID) | Yes | The record collection | +| `rkey` | string | Yes | The record key | +| `swapRecord` | string | No | Expected CID of the existing record (for optimistic concurrency) | + +Attempting to delete another user's record returns `403 Forbidden`. + +## Batch writes (applyWrites) + +`applyWrites` performs multiple create, update, and delete operations in a single request. Requires `write` membership. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.applyWrites' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "writes": [ + { + "action": "create", + "collection": "com.example.forum.post", + "value": { "$type": "com.example.forum.post", "text": "First post" } + }, + { + "action": "update", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "value": { "$type": "com.example.forum.post", "text": "Edited post" }, + "swapRecord": "bafyrei..." + }, + { + "action": "delete", + "collection": "com.example.forum.post", + "rkey": "old-post" + } + ] + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ---------------------------------------------------- | +| `space` | string | Yes | The space to write into | +| `swapCommit` | string | No | Expected space revision (for optimistic concurrency) | +| `writes` | array | Yes | List of write operations | + +Each write operation has an `action` field: + +| Action | Fields | Description | +| -------- | -------------------------------------------- | ---------------------------------------------------- | +| `create` | `collection`, `value`, `rkey?` | Insert a new record. Auto-generates rkey if omitted. | +| `update` | `collection`, `rkey`, `value`, `swapRecord?` | Upsert a record. | +| `delete` | `collection`, `rkey`, `swapRecord?` | Delete a record. | + +**Response:** + +```json +{ + "results": [ + { "uri": "ats://...", "cid": "bafyrei..." }, + { "uri": "ats://...", "cid": "bafyrei..." }, + {} + ] +} +``` + +Each entry in `results` corresponds to the write at the same index. Create and update operations return `uri` and `cid`; delete operations return an empty object. + +## Optimistic concurrency + +`swapRecord` and `swapCommit` provide optimistic concurrency control to prevent lost updates when multiple clients write to the same space. + +### swapRecord + +Pass the `swapRecord` field on `putRecord`, `deleteRecord`, or individual operations within `applyWrites`. The value is the CID of the record you expect to be replacing. If the record's current CID doesn't match, the operation fails with `409 Conflict`. + +```json +{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "record": { "text": "updated safely" }, + "swapRecord": "bafyrei_old_cid" +} +``` + +### swapCommit + +Pass the `swapCommit` field on `applyWrites` to assert the space's current revision. If another client has written to the space since you last read its state, the operation fails with `409 Conflict` before any writes are applied. + +The space's current revision is available as `revision` in the space object returned by `dev.happyview.space.getSpace`. + +```json +{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "swapCommit": "3l2tkbx7225co", + "writes": [...] +} +``` + +## Cross-service access + +Records can also be read using a [space credential](credentials.md) instead of direct membership. Pass the credential as a Bearer token: + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?...' \ + -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6InNwYWNlX2NyZWRlbnRpYWwifQ...' +``` + +A feed generator or other service that isn't a direct member can use a credential issued by the space owner to read data without joining the space. No DPoP auth is needed — the credential itself authenticates the request. diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index 2fecf3e..553e6bc 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -298,6 +298,53 @@ const sidebars: SidebarsConfig = { }, ], }, + { + type: "category", + label: "Experimental", + items: [ + { + type: "category", + label: "Permissioned Spaces", + items: [ + { + type: "doc", + id: "experimental/spaces", + label: "Overview", + }, + { + type: "doc", + id: "experimental/spaces/managing-spaces", + label: "Managing Spaces", + }, + { + type: "doc", + id: "experimental/spaces/members", + label: "Members", + }, + { + type: "doc", + id: "experimental/spaces/records", + label: "Records", + }, + { + type: "doc", + id: "experimental/spaces/credentials", + label: "Credentials", + }, + { + type: "doc", + id: "experimental/spaces/invites", + label: "Invites", + }, + { + type: "doc", + id: "experimental/spaces/changelog", + label: "Changelog", + }, + ], + }, + ], + }, { type: "category", label: "Reference", -- 2.51.2 From ec0f6242654d8951c3930663af21427b46d53102 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 12:53:24 -0500 Subject: [PATCH 39/56] fix: remove route dupes Signed-off-by: Trezy --- src/spaces/routes.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs index adaf9cf..f0d27a1 100644 --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -268,15 +268,6 @@ pub fn space_routes() -> Router { post(revoke_invite), ) .route(&format!("/xrpc/{NS}.space.invite.list"), get(list_invites)) - // Credentials - .route( - &format!("/xrpc/{NS}.space.getMemberGrant"), - post(get_member_grant), - ) - .route( - &format!("/xrpc/{NS}.space.getSpaceCredential"), - post(get_space_credential), - ) } // --------------------------------------------------------------------------- -- 2.51.2 From fecc4106c48be64b7cd38470dc44111e032509e6 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 13:21:23 -0500 Subject: [PATCH 40/56] fix: scopes types in oauth-client sdk after bad conflict resolution Signed-off-by: Trezy --- packages/oauth-client/src/client.ts | 4 ++-- packages/oauth-client/src/session.ts | 6 +++--- packages/oauth-client/src/types.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/oauth-client/src/client.ts b/packages/oauth-client/src/client.ts index e163783..d4ba498 100644 --- a/packages/oauth-client/src/client.ts +++ b/packages/oauth-client/src/client.ts @@ -164,7 +164,7 @@ export class HappyViewOAuthClient { accessToken: params.accessToken, clientKey: this.clientKey, instanceUrl: this.instanceUrl, - scopes: params.scopes, + scopes: data.scopes, pdsUrl: params.pdsUrl, issuer: params.issuer, }; @@ -180,7 +180,7 @@ export class HappyViewOAuthClient { accessToken: params.accessToken, clientKey: this.clientKey, instanceUrl: this.instanceUrl, - scopes: params.scopes, + scopes: data.scopes, pdsUrl: params.pdsUrl, issuer: params.issuer, fetch: this._fetch, diff --git a/packages/oauth-client/src/session.ts b/packages/oauth-client/src/session.ts index b1e5aa6..53bcbc3 100644 --- a/packages/oauth-client/src/session.ts +++ b/packages/oauth-client/src/session.ts @@ -7,7 +7,7 @@ export interface HappyViewSessionOptions { accessToken: string; clientKey: string; instanceUrl: string; - scopes?: string; + scopes: string[]; pdsUrl?: string; issuer?: string; fetch?: typeof globalThis.fetch; @@ -48,12 +48,12 @@ export class HappyViewSession { constructor(options: HappyViewSessionOptions) { this.did = options.did; - this.scopes = options.scopes ? options.scopes.split(" ") : []; + this.scopes = options.scopes; this.dpopKey = options.dpopKey; this.accessToken = options.accessToken; this.clientKey = options.clientKey; this.instanceUrl = options.instanceUrl.replace(/\/+$/, ""); - this._scopes = options.scopes; + this._scopes = options.scopes.join(" ") || undefined; this._pdsUrl = options.pdsUrl; this._issuer = options.issuer; this._fetch = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch; diff --git a/packages/oauth-client/src/types.ts b/packages/oauth-client/src/types.ts index c893969..9d1eec9 100644 --- a/packages/oauth-client/src/types.ts +++ b/packages/oauth-client/src/types.ts @@ -46,7 +46,7 @@ export interface StoredSession { accessToken: string; clientKey: string; instanceUrl: string; - scopes?: string; + scopes: string[]; pdsUrl?: string; issuer?: string; } -- 2.51.2 From 20036807334f44d693a565b0fd33cabeec5b1c12 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 13:50:31 -0500 Subject: [PATCH 41/56] fix: restore SDK scopes types after bad conflict resolution Signed-off-by: Trezy --- packages/oauth-client/src/client.ts | 6 +++--- packages/oauth-client/src/session.ts | 6 +++--- packages/oauth-client/src/types.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/oauth-client/src/client.ts b/packages/oauth-client/src/client.ts index d4ba498..97bd304 100644 --- a/packages/oauth-client/src/client.ts +++ b/packages/oauth-client/src/client.ts @@ -164,7 +164,7 @@ export class HappyViewOAuthClient { accessToken: params.accessToken, clientKey: this.clientKey, instanceUrl: this.instanceUrl, - scopes: data.scopes, + scopes: params.scopes, pdsUrl: params.pdsUrl, issuer: params.issuer, }; @@ -180,7 +180,7 @@ export class HappyViewOAuthClient { accessToken: params.accessToken, clientKey: this.clientKey, instanceUrl: this.instanceUrl, - scopes: data.scopes, + scopes: params.scopes, pdsUrl: params.pdsUrl, issuer: params.issuer, fetch: this._fetch, @@ -232,7 +232,7 @@ export class HappyViewOAuthClient { accessToken: data.accessToken, clientKey: data.clientKey, instanceUrl: data.instanceUrl, - scopes: data.scopes ?? [], + scopes: data.scopes, pdsUrl: data.pdsUrl, issuer: data.issuer, fetch: this._fetch, diff --git a/packages/oauth-client/src/session.ts b/packages/oauth-client/src/session.ts index 53bcbc3..b1e5aa6 100644 --- a/packages/oauth-client/src/session.ts +++ b/packages/oauth-client/src/session.ts @@ -7,7 +7,7 @@ export interface HappyViewSessionOptions { accessToken: string; clientKey: string; instanceUrl: string; - scopes: string[]; + scopes?: string; pdsUrl?: string; issuer?: string; fetch?: typeof globalThis.fetch; @@ -48,12 +48,12 @@ export class HappyViewSession { constructor(options: HappyViewSessionOptions) { this.did = options.did; - this.scopes = options.scopes; + this.scopes = options.scopes ? options.scopes.split(" ") : []; this.dpopKey = options.dpopKey; this.accessToken = options.accessToken; this.clientKey = options.clientKey; this.instanceUrl = options.instanceUrl.replace(/\/+$/, ""); - this._scopes = options.scopes.join(" ") || undefined; + this._scopes = options.scopes; this._pdsUrl = options.pdsUrl; this._issuer = options.issuer; this._fetch = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch; diff --git a/packages/oauth-client/src/types.ts b/packages/oauth-client/src/types.ts index 9d1eec9..c893969 100644 --- a/packages/oauth-client/src/types.ts +++ b/packages/oauth-client/src/types.ts @@ -46,7 +46,7 @@ export interface StoredSession { accessToken: string; clientKey: string; instanceUrl: string; - scopes: string[]; + scopes?: string; pdsUrl?: string; issuer?: string; } -- 2.51.2 From 3ab5e7e56f89138071d5fe3a1f43f24e9d066011 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 14:17:34 -0500 Subject: [PATCH 42/56] fix: prevent creation of API clients that would break HappyView core client Signed-off-by: Trezy --- src/admin/api_clients.rs | 7 +++++ src/auth/client_registry.rs | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/admin/api_clients.rs b/src/admin/api_clients.rs index 6da0c41..f2188a1 100644 --- a/src/admin/api_clients.rs +++ b/src/admin/api_clients.rs @@ -32,6 +32,13 @@ pub(super) async fn create_api_client( ) -> Result<(StatusCode, Json), AppError> { auth.require(Permission::ApiClientsCreate).await?; + if state.oauth.is_domain_client_id(&body.client_id_url) { + return Err(AppError::Conflict(format!( + "client_id_url '{}' conflicts with a registered domain's OAuth client", + body.client_id_url + ))); + } + // Generate the client key: "hvc_" + 32 random hex chars. let mut random_bytes = [0u8; 16]; rand::rng().fill(&mut random_bytes); diff --git a/src/auth/client_registry.rs b/src/auth/client_registry.rs index 78cf1d9..0b1f821 100644 --- a/src/auth/client_registry.rs +++ b/src/auth/client_registry.rs @@ -130,6 +130,20 @@ impl OAuthClientRegistry { self.primary_client.store(client); } + /// Returns true if the given `client_id_url` is already claimed by a domain + /// client (i.e. matches `{domain_url}/oauth-client-metadata.json` for any + /// registered domain). + pub fn is_domain_client_id(&self, client_id_url: &str) -> bool { + self.domain_clients.iter().any(|entry| { + let domain_url = entry.key(); + let expected = format!( + "{}/oauth-client-metadata.json", + domain_url.trim_end_matches('/') + ); + expected == client_id_url + }) + } + /// Build and register a single OAuth client from API client metadata. /// Used when creating or updating an API client via the admin UI. pub fn register_api_client( @@ -140,6 +154,12 @@ impl OAuthClientRegistry { scopes_str: &str, params: &ApiClientOAuthParams, ) -> Result<(), String> { + if self.is_domain_client_id(client_id_url) { + return Err(format!( + "client_id_url '{}' conflicts with a registered domain's OAuth client", + client_id_url + )); + } let ApiClientOAuthParams { plc_url, state_store, @@ -232,6 +252,14 @@ impl OAuthClientRegistry { }; for (client_id_url, client_uri, redirect_uris_json, scopes_str) in rows { + if self.is_domain_client_id(&client_id_url) { + tracing::warn!( + client_id = %client_id_url, + "Skipping API client that conflicts with a domain OAuth client" + ); + continue; + } + let redirect_uris: Vec = serde_json::from_str(&redirect_uris_json).unwrap_or_default(); @@ -332,4 +360,32 @@ mod tests { assert_eq!(map.get("key1").unwrap().value(), "val2"); } + + #[test] + fn test_domain_client_id_collision_detection() { + let domains: DashMap = DashMap::new(); + domains.insert("https://example.com".to_string(), "client".to_string()); + domains.insert( + "https://other.example.com/".to_string(), + "client".to_string(), + ); + + let matches = |client_id_url: &str| -> bool { + domains.iter().any(|entry| { + let domain_url = entry.key(); + let expected = format!( + "{}/oauth-client-metadata.json", + domain_url.trim_end_matches('/') + ); + expected == client_id_url + }) + }; + + assert!(matches("https://example.com/oauth-client-metadata.json")); + assert!(matches( + "https://other.example.com/oauth-client-metadata.json" + )); + assert!(!matches("https://unrelated.com/oauth-client-metadata.json")); + assert!(!matches("https://example.com/other-path.json")); + } } -- 2.51.2 From 11fda6f2a910c9c1ed1199c7ff904b1aa639abfd Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 14:21:41 -0500 Subject: [PATCH 43/56] fix: use the correct cursors when paginating spaces Signed-off-by: Trezy --- src/spaces/db.rs | 4 +++- src/spaces/routes.rs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/spaces/db.rs b/src/spaces/db.rs index 88d19b3..25afa0d 100644 --- a/src/spaces/db.rs +++ b/src/spaces/db.rs @@ -114,6 +114,7 @@ pub async fn list_spaces_by_owner( pub struct SpaceView { pub uri: String, pub is_owner: bool, + pub created_at: String, } pub async fn list_spaces_for_user( @@ -155,9 +156,10 @@ pub async fn list_spaces_for_user( Ok(rows .into_iter() .map( - |(space_did, owner_did, type_nsid, skey, _created_at)| SpaceView { + |(space_did, owner_did, type_nsid, skey, created_at)| SpaceView { uri: format!("ats://{}/{}/{}", space_did, type_nsid, skey), is_owner: owner_did == did, + created_at, }, ) .collect()) diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs index f0d27a1..bf9c033 100644 --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -482,7 +482,7 @@ async fn list_spaces( .await?; let cursor = if views.len() as i64 == limit { - views.last().map(|v| v.uri.clone()) + views.last().map(|v| v.created_at.clone()) } else { None }; -- 2.51.2 From 942a520f4ddb1dabb78b014ac1beee3f871a66f0 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 14:27:58 -0500 Subject: [PATCH 44/56] fix: prevent unauthorized privilege escalation Signed-off-by: Trezy --- src/auth/middleware.rs | 6 +----- src/spaces/routes.rs | 49 +++++++++++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index c64a42e..4d9814f 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -254,12 +254,8 @@ impl FromRequestParts for XrpcClaims { let token = &h[7..]; match crate::spaces::credential::peek_jwt_typ(token) { Some(typ) if typ == "space_credential" => { - let sub = - crate::spaces::credential::peek_credential_sub(token).ok_or_else( - || AppError::Auth("invalid space credential payload".into()), - )?; Ok(XrpcClaims { - identity: Some(Claims::internal(sub)), + identity: None, space_credential: Some(token.to_string()), }) } diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs index bf9c033..02b4dcf 100644 --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -281,6 +281,32 @@ fn require_auth(claims: &XrpcClaims) -> Result<&crate::auth::Claims, AppError> { .ok_or_else(|| AppError::Auth("This endpoint requires authentication".into())) } +/// Like `require_auth`, but also accepts a verified space credential as an +/// identity source. Use this in space endpoints that support `Bearer +/// ` in addition to DPoP auth. +async fn require_auth_or_credential( + state: &AppState, + claims: &XrpcClaims, +) -> Result { + if let Some(identity) = &claims.identity { + return Ok(identity.did().to_string()); + } + + if let Some(token) = &claims.space_credential { + let verified = crate::spaces::credential::verify_external_credential( + token, + &state.http, + &state.config.plc_url, + ) + .await?; + return Ok(verified.sub); + } + + Err(AppError::Auth( + "This endpoint requires authentication".into(), + )) +} + async fn resolve_space(state: &AppState, space_uri: &str) -> Result { let uri = SpaceUri::parse(space_uri)?; db::get_space_by_address( @@ -566,8 +592,7 @@ async fn create_record( xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result { - let claims = require_auth(&xrpc_claims)?; - let did = claims.did().to_string(); + let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &input.space).await?; require_membership( &state, @@ -616,8 +641,7 @@ async fn put_record( xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result { - let claims = require_auth(&xrpc_claims)?; - let did = claims.did().to_string(); + let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &input.space).await?; require_membership( &state, @@ -708,8 +732,7 @@ async fn apply_writes( xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result, AppError> { - let claims = require_auth(&xrpc_claims)?; - let did = claims.did().to_string(); + let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &input.space).await?; require_membership( &state, @@ -838,12 +861,12 @@ async fn get_record( xrpc_claims: XrpcClaims, Query(query): Query, ) -> Result, AppError> { - let claims = require_auth(&xrpc_claims)?; + let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &query.space).await?; require_membership( &state, &space, - claims.did(), + &did, false, xrpc_claims.space_credential.as_deref(), ) @@ -871,12 +894,12 @@ async fn list_records( xrpc_claims: XrpcClaims, Query(query): Query, ) -> Result, AppError> { - let claims = require_auth(&xrpc_claims)?; + let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &query.space).await?; require_membership( &state, &space, - claims.did(), + &did, false, xrpc_claims.space_credential.as_deref(), ) @@ -886,7 +909,7 @@ async fn list_records( if xrpc_claims.space_credential.is_some() { None } else { - Some(claims.did()) + Some(did.as_str()) } }); @@ -935,11 +958,11 @@ async fn list_members( let space = resolve_space(&state, &query.space).await?; if !space.config.membership_public { - let claims = require_auth(&xrpc_claims)?; + let did = require_auth_or_credential(&state, &xrpc_claims).await?; require_membership( &state, &space, - claims.did(), + &did, false, xrpc_claims.space_credential.as_deref(), ) -- 2.51.2 From 07c6065bdfe00fe0b778aabe2d6f1bb00aa61f0b Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 15:00:04 -0500 Subject: [PATCH 45/56] fix: dont block space creds on client key check Signed-off-by: Trezy --- src/xrpc/mod.rs | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/xrpc/mod.rs b/src/xrpc/mod.rs index 1865c15..ddf24b9 100644 --- a/src/xrpc/mod.rs +++ b/src/xrpc/mod.rs @@ -260,7 +260,19 @@ pub async fn xrpc_get( let mut params = parse_query_params(&raw_query); let claims = xrpc_claims.identity; - let rate_key = resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)?; + // Space credential Bearer requests bypass the client-key requirement; + // rate-limit by the credential's sub DID instead. + let rate_key = if claims.is_none() { + if let Some(ref token) = xrpc_claims.space_credential { + crate::spaces::credential::peek_credential_sub(token).ok_or_else(|| { + AppError::Auth("invalid space credential: unable to extract sub".into()) + })? + } else { + resolve_client_key(&state, None, &parts, ¶ms)? + } + } else { + resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)? + }; let lexicon = state.lexicons.get(&method).await; @@ -348,11 +360,23 @@ pub async fn xrpc_post( ) -> Result { let raw_query = raw_query.unwrap_or_default(); let mut params = parse_query_params(&raw_query); - let claims = xrpc_claims - .identity - .ok_or_else(|| AppError::Auth("XRPC procedures require DPoP authentication".into()))?; + let claims = xrpc_claims.identity; - let rate_key = resolve_client_key(&state, Some(&claims), &parts, ¶ms)?; + // Space credential Bearer requests bypass the client-key requirement; + // rate-limit by the credential's sub DID instead. + let rate_key = if claims.is_none() { + if let Some(ref token) = xrpc_claims.space_credential { + crate::spaces::credential::peek_credential_sub(token).ok_or_else(|| { + AppError::Auth("invalid space credential: unable to extract sub".into()) + })? + } else { + return Err(AppError::Auth( + "XRPC procedures require DPoP authentication".into(), + )); + } + } else { + resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)? + }; let lexicon = state.lexicons.get(&method).await; @@ -416,6 +440,9 @@ pub async fn xrpc_post( coerce_params(&mut params, param_schema); } + let claims = claims + .ok_or_else(|| AppError::Auth("XRPC procedures require DPoP authentication".into()))?; + let mut response = procedure::handle_procedure(&state, &method, &claims, &body, ¶ms, &lexicon).await?; if let CheckResult::Allowed { -- 2.51.2 From 4f5a8ef572613f0db314dbb774d8dd8ae622193a Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 15:00:39 -0500 Subject: [PATCH 46/56] fix: prevent base path collision Signed-off-by: Trezy --- src/admin/domains.rs | 29 ++++++--- src/auth/client_registry.rs | 120 ++++++++++++++++++++++++------------ src/main.rs | 18 +++++- 3 files changed, 118 insertions(+), 49 deletions(-) diff --git a/src/admin/domains.rs b/src/admin/domains.rs index e91b1a5..4a205a2 100644 --- a/src/admin/domains.rs +++ b/src/admin/domains.rs @@ -123,17 +123,25 @@ pub(super) async fn create( updated_at: now, }; + // Build the base-path-aware URLs for this domain + let domain_base_url = state.config.url_with_base_path(&url); + let client_id_url = format!( + "{}/oauth-client-metadata.json", + domain_base_url.trim_end_matches('/') + ); + // Register the OAuth client for this domain - state - .oauth - .register_domain_client(url.clone(), state.oauth.primary_client()); + state.oauth.register_domain_client( + url.clone(), + client_id_url.clone(), + state.oauth.primary_client(), + ); // Build a proper OAuth client if not loopback let domain_is_loopback = url.contains("127.0.0.1") || url.contains("[::1]") || url.contains("localhost"); if !domain_is_loopback { - let client_id_url = format!("{}/oauth-client-metadata.json", url.trim_end_matches('/')); - let callback = format!("{}/auth/callback", url.trim_end_matches('/')); + let callback = format!("{}/auth/callback", domain_base_url.trim_end_matches('/')); if let Err(e) = state.oauth.register_api_client( &client_id_url, &url, @@ -151,7 +159,9 @@ pub(super) async fn create( // Move from `clients` (where register_api_client puts it) to domain_clients + clients if let Some(client) = state.oauth.get(&client_id_url) { state.oauth.remove(&client_id_url); - state.oauth.register_domain_client(url.clone(), client); + state + .oauth + .register_domain_client(url.clone(), client_id_url, client); } } } @@ -211,7 +221,12 @@ pub(super) async fn delete( .map_err(|e| AppError::Internal(format!("failed to delete domain: {e}")))?; // Remove OAuth client and cache entry - state.oauth.remove_domain_client(&url); + let domain_base_url = state.config.url_with_base_path(&url); + let client_id_url = format!( + "{}/oauth-client-metadata.json", + domain_base_url.trim_end_matches('/') + ); + state.oauth.remove_domain_client(&url, &client_id_url); let host = url .strip_prefix("https://") .or_else(|| url.strip_prefix("http://")) diff --git a/src/auth/client_registry.rs b/src/auth/client_registry.rs index 0b1f821..995496b 100644 --- a/src/auth/client_registry.rs +++ b/src/auth/client_registry.rs @@ -91,23 +91,26 @@ impl OAuthClientRegistry { /// Register a domain-specific OAuth client. /// Inserts into both `domain_clients` (keyed by domain URL, for `get_for_domain`) /// and `clients` (keyed by client_id_url, for `get_or_default`). - pub fn register_domain_client(&self, domain_url: String, client: Arc) { - let client_id_url = format!( - "{}/oauth-client-metadata.json", - domain_url.trim_end_matches('/') - ); + /// + /// `client_id_url` must be the base-path-aware client ID + /// (e.g. `{domain_url}{base_path}/oauth-client-metadata.json`). + pub fn register_domain_client( + &self, + domain_url: String, + client_id_url: String, + client: Arc, + ) { self.domain_clients.insert(domain_url, Arc::clone(&client)); self.clients.insert(client_id_url, client); } /// Remove a domain-specific OAuth client from both maps. - pub fn remove_domain_client(&self, domain_url: &str) { + /// + /// `client_id_url` must be the same base-path-aware client ID that was + /// passed to `register_domain_client`. + pub fn remove_domain_client(&self, domain_url: &str, client_id_url: &str) { self.domain_clients.remove(domain_url); - let client_id_url = format!( - "{}/oauth-client-metadata.json", - domain_url.trim_end_matches('/') - ); - self.clients.remove(&client_id_url); + self.clients.remove(client_id_url); } /// Look up a domain-specific OAuth client. @@ -131,17 +134,18 @@ impl OAuthClientRegistry { } /// Returns true if the given `client_id_url` is already claimed by a domain - /// client (i.e. matches `{domain_url}/oauth-client-metadata.json` for any - /// registered domain). + /// client. Checks by comparing against the actual client instances stored by + /// domain registrations, so it works correctly regardless of `BASE_PATH`. pub fn is_domain_client_id(&self, client_id_url: &str) -> bool { - self.domain_clients.iter().any(|entry| { - let domain_url = entry.key(); - let expected = format!( - "{}/oauth-client-metadata.json", - domain_url.trim_end_matches('/') - ); - expected == client_id_url - }) + // A client_id_url belongs to a domain client if any domain_clients entry + // has the same Arc as the one stored in `clients` under that key. + if let Some(candidate) = self.clients.get(client_id_url) { + self.domain_clients + .iter() + .any(|entry| Arc::ptr_eq(entry.value(), candidate.value())) + } else { + false + } } /// Build and register a single OAuth client from API client metadata. @@ -363,29 +367,67 @@ mod tests { #[test] fn test_domain_client_id_collision_detection() { - let domains: DashMap = DashMap::new(); - domains.insert("https://example.com".to_string(), "client".to_string()); - domains.insert( - "https://other.example.com/".to_string(), - "client".to_string(), + // Simulate the is_domain_client_id logic using raw DashMaps and Arc pointer equality, + // mirroring the real OAuthClientRegistry implementation. + let domain_clients: DashMap> = DashMap::new(); + let clients: DashMap> = DashMap::new(); + + // Register domain "https://example.com" with base-path-aware client_id_url + let client_a = Arc::new("client_a".to_string()); + domain_clients.insert("https://example.com".to_string(), Arc::clone(&client_a)); + clients.insert( + "https://example.com/hv/oauth-client-metadata.json".to_string(), + client_a, ); - let matches = |client_id_url: &str| -> bool { - domains.iter().any(|entry| { - let domain_url = entry.key(); - let expected = format!( - "{}/oauth-client-metadata.json", - domain_url.trim_end_matches('/') - ); - expected == client_id_url - }) + // Register domain "https://other.example.com" without base path + let client_b = Arc::new("client_b".to_string()); + domain_clients.insert( + "https://other.example.com".to_string(), + Arc::clone(&client_b), + ); + clients.insert( + "https://other.example.com/oauth-client-metadata.json".to_string(), + client_b, + ); + + // Also register a non-domain API client + let api_client = Arc::new("api_client".to_string()); + clients.insert( + "https://api.example.com/oauth-client-metadata.json".to_string(), + api_client, + ); + + let is_domain_client_id = |client_id_url: &str| -> bool { + if let Some(candidate) = clients.get(client_id_url) { + domain_clients + .iter() + .any(|entry| Arc::ptr_eq(entry.value(), candidate.value())) + } else { + false + } }; - assert!(matches("https://example.com/oauth-client-metadata.json")); - assert!(matches( + // Base-path-aware key is detected as a domain client + assert!(is_domain_client_id( + "https://example.com/hv/oauth-client-metadata.json" + )); + // Non-base-path key is also detected + assert!(is_domain_client_id( "https://other.example.com/oauth-client-metadata.json" )); - assert!(!matches("https://unrelated.com/oauth-client-metadata.json")); - assert!(!matches("https://example.com/other-path.json")); + // Unrelated URLs are not detected + assert!(!is_domain_client_id( + "https://unrelated.com/oauth-client-metadata.json" + )); + assert!(!is_domain_client_id("https://example.com/other-path.json")); + // The old (wrong) key without base path is not detected + assert!(!is_domain_client_id( + "https://example.com/oauth-client-metadata.json" + )); + // API client is not detected as a domain client + assert!(!is_domain_client_id( + "https://api.example.com/oauth-client-metadata.json" + )); } } diff --git a/src/main.rs b/src/main.rs index 8145cf4..33b998a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -519,7 +519,15 @@ async fn main() { // Register the primary domain's OAuth client in domain_clients if let Some(ref pd) = domain_cache.primary().await { - oauth_registry.register_domain_client(pd.url.clone(), Arc::clone(&oauth_client_arc)); + let primary_client_id_url = format!( + "{}/oauth-client-metadata.json", + config.url_with_base_path(&pd.url).trim_end_matches('/') + ); + oauth_registry.register_domain_client( + pd.url.clone(), + primary_client_id_url, + Arc::clone(&oauth_client_arc), + ); } // Build OAuth clients for all non-primary domains @@ -553,7 +561,7 @@ async fn main() { match atrium_oauth::OAuthClient::new(OAuthClientConfig { client_metadata: AtprotoClientMetadata { - client_id: domain_client_id, + client_id: domain_client_id.clone(), client_uri: Some(domain_base_url.clone()), redirect_uris: vec![domain_callback_url], token_endpoint_auth_method: AuthMethod::None, @@ -569,7 +577,11 @@ async fn main() { }) { Ok(client) => { info!(domain = %domain.url, "Registered domain OAuth client"); - oauth_registry.register_domain_client(domain.url.clone(), Arc::new(client)); + oauth_registry.register_domain_client( + domain.url.clone(), + domain_client_id, + Arc::new(client), + ); } Err(e) => { tracing::error!(domain = %domain.url, error = %e, "Failed to create domain OAuth client"); -- 2.51.2 From b98aefa8f2647a1de4d3c2ebc46f49b90cebe9a4 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 15:01:06 -0500 Subject: [PATCH 47/56] chore: rename did to clarify purpose Signed-off-by: Trezy --- src/spaces/db.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spaces/db.rs b/src/spaces/db.rs index 25afa0d..f6eae7b 100644 --- a/src/spaces/db.rs +++ b/src/spaces/db.rs @@ -95,7 +95,7 @@ pub async fn get_space_by_address( pub async fn list_spaces_by_owner( pool: &sqlx::AnyPool, backend: DatabaseBackend, - did: &str, + owner_did: &str, ) -> Result, AppError> { let sql = adapt_sql( "SELECT id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, revision, created_at, updated_at FROM spaces WHERE owner_did = ? ORDER BY created_at DESC", @@ -103,7 +103,7 @@ pub async fn list_spaces_by_owner( ); let rows: Vec = sqlx::query_as(&sql) - .bind(did) + .bind(owner_did) .fetch_all(pool) .await .map_err(|e| AppError::Internal(format!("failed to list spaces: {e}")))?; -- 2.51.2 From 32aef327372c2f58cb05f38269e08565550d0618 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 15:10:42 -0500 Subject: [PATCH 48/56] ci: only run builds on PR sync Signed-off-by: Trezy --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 080b041..d981162 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: CI on: - push: - branches: [main, dev] pull_request: branches: [main, dev] delete: -- 2.51.2 From 1f51965134c4bc9a9faa16e4fdb802337858985e Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 15:30:21 -0500 Subject: [PATCH 49/56] feat: add new TID functions closes #16 Signed-off-by: Trezy --- packages/docs/docs/guides/scripting.md | 2 +- .../docs/reference/lua/utility-globals.md | 52 +++++++ src/lua/sandbox.rs | 147 +++++++++++++++++- src/lua/tid.rs | 147 ++++++++++++++++++ 4 files changed, 343 insertions(+), 5 deletions(-) diff --git a/packages/docs/docs/guides/scripting.md b/packages/docs/docs/guides/scripting.md index a8dc6fe..62982d0 100644 --- a/packages/docs/docs/guides/scripting.md +++ b/packages/docs/docs/guides/scripting.md @@ -71,7 +71,7 @@ Available in both queries and procedures: | ---------------- | ------- | ------------------------------------------------------------------- | | `now()` | string | Current UTC timestamp in ISO 8601 format | | `log(message)` | — | Log a message (appears in server logs at debug level) | -| `TID()` | string | Generate a fresh atproto TID (13-character sortable identifier) | +| `TID()` | string | Generate a fresh atproto TID (13-character sortable identifier). Also provides conversion methods — see [Utility Globals reference](../reference/lua/utility-globals.md#tid). | | `toarray(table)` | table | Mark a table as a JSON array for serialization (see [below](#toarray)) | ### toarray diff --git a/packages/docs/docs/reference/lua/utility-globals.md b/packages/docs/docs/reference/lua/utility-globals.md index 95da1e5..235219c 100644 --- a/packages/docs/docs/reference/lua/utility-globals.md +++ b/packages/docs/docs/reference/lua/utility-globals.md @@ -35,6 +35,58 @@ r:set_rkey(TID()) r:save() ``` +### TID.toISO8601 + +```lua +local iso = TID.toISO8601(tid) +-- "2026-04-19T15:30:00.123456Z" +``` + +Converts a TID to an ISO 8601 timestamp string with microsecond precision. This is lossy — the 10-bit clock ID embedded in the TID is discarded. + +### TID.fromISO8601 + +```lua +local tid = TID.fromISO8601("2026-04-19T15:30:00Z") +``` + +Creates a TID from an ISO 8601 timestamp string. Accepts timezone offsets and fractional seconds. The resulting TID uses a zero clock ID, so it won't match any specific generated TID but will sort correctly relative to TIDs from the same moment. + +### TID.toUnixMicroseconds + +```lua +local us = TID.toUnixMicroseconds(tid) +-- 1745074200123456 +``` + +Extracts the microsecond timestamp from a TID (microseconds since the Unix epoch). Lossy — drops the clock ID. + +### TID.fromUnixMicroseconds + +```lua +local tid = TID.fromUnixMicroseconds(1745074200123456) +``` + +Creates a TID from a microsecond timestamp. Uses a zero clock ID. + +### TID.toNumber + +```lua +local n = TID.toNumber(tid) +local same_tid = TID.fromNumber(n) +-- same_tid == tid +``` + +Decodes a TID to its full numeric representation (timestamp + clock ID). This is the only lossless conversion — `TID.fromNumber(TID.toNumber(tid))` always returns the original TID. + +### TID.fromNumber + +```lua +local tid = TID.fromNumber(n) +``` + +Encodes a number back into a TID. Inverse of `TID.toNumber`. + ## toarray ```lua diff --git a/src/lua/sandbox.rs b/src/lua/sandbox.rs index b699c82..fe7fe3d 100644 --- a/src/lua/sandbox.rs +++ b/src/lua/sandbox.rs @@ -1,6 +1,9 @@ use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; -use super::tid::generate_tid; +use super::tid::{ + generate_tid, tid_from_iso8601, tid_from_number, tid_from_unix_microseconds, tid_to_iso8601, + tid_to_number, tid_to_unix_microseconds, +}; const INSTRUCTION_LIMIT: u32 = 1_000_000; @@ -59,9 +62,51 @@ pub fn create_sandbox() -> LuaResult { })?; globals.set("log", log_fn)?; - // Utility: TID() returns a fresh AT Protocol TID string - let tid_fn = lua.create_function(|_, ()| Ok(generate_tid()))?; - globals.set("TID", tid_fn)?; + // Utility: TID table — callable as TID() to generate, plus conversion methods + let tid_table = lua.create_table()?; + tid_table.set( + "toISO8601", + lua.create_function(|_, tid: String| { + tid_to_iso8601(&tid).ok_or_else(|| mlua::Error::runtime(format!("invalid TID: {tid}"))) + })?, + )?; + tid_table.set( + "fromISO8601", + lua.create_function(|_, iso: String| { + tid_from_iso8601(&iso) + .ok_or_else(|| mlua::Error::runtime(format!("invalid ISO 8601: {iso}"))) + })?, + )?; + tid_table.set( + "toUnixMicroseconds", + lua.create_function(|_, tid: String| { + tid_to_unix_microseconds(&tid) + .ok_or_else(|| mlua::Error::runtime(format!("invalid TID: {tid}"))) + })?, + )?; + tid_table.set( + "fromUnixMicroseconds", + lua.create_function(|_, us: i64| Ok(tid_from_unix_microseconds(us)))?, + )?; + tid_table.set( + "toNumber", + lua.create_function(|_, tid: String| { + tid_to_number(&tid) + .map(|v| v as i64) + .ok_or_else(|| mlua::Error::runtime(format!("invalid TID: {tid}"))) + })?, + )?; + tid_table.set( + "fromNumber", + lua.create_function(|_, val: i64| Ok(tid_from_number(val as u64)))?, + )?; + let tid_meta = lua.create_table()?; + tid_meta.set( + "__call", + lua.create_function(|_, _: mlua::MultiValue| Ok(generate_tid()))?, + )?; + let _ = tid_table.set_metatable(Some(tid_meta)); + globals.set("TID", tid_table)?; // Utility: toarray(table) marks a table as a JSON array for serialization. // Ensures empty tables serialize as [] instead of {}. @@ -187,6 +232,100 @@ mod tests { assert_ne!(a, b); } + #[test] + fn sandbox_tid_to_iso8601() { + let lua = create_sandbox().unwrap(); + let iso: String = lua.load(r#"return TID.toISO8601(TID())"#).eval().unwrap(); + assert!(iso.contains("T") && iso.ends_with("Z")); + } + + #[test] + fn sandbox_tid_from_iso8601() { + let lua = create_sandbox().unwrap(); + let tid: String = lua + .load(r#"return TID.fromISO8601("2024-01-01T00:00:00Z")"#) + .eval() + .unwrap(); + assert_eq!(tid.len(), 13); + } + + #[test] + fn sandbox_tid_roundtrip() { + let lua = create_sandbox().unwrap(); + let result: String = lua + .load( + r#" + local tid = TID() + local iso = TID.toISO8601(tid) + local tid2 = TID.fromISO8601(iso) + return TID.toISO8601(tid2) + "#, + ) + .eval() + .unwrap(); + assert!(result.contains("T") && result.ends_with("Z")); + } + + #[test] + fn sandbox_tid_to_unix_microseconds() { + let lua = create_sandbox().unwrap(); + let us: i64 = lua + .load(r#"return TID.toUnixMicroseconds(TID.fromISO8601("2024-01-01T00:00:00Z"))"#) + .eval() + .unwrap(); + assert_eq!(us, 1_704_067_200_000_000); + } + + #[test] + fn sandbox_tid_from_unix_microseconds() { + let lua = create_sandbox().unwrap(); + let tid: String = lua + .load("return TID.fromUnixMicroseconds(1704067200000000)") + .eval() + .unwrap(); + assert_eq!(tid.len(), 13); + let iso: String = lua + .load(format!(r#"return TID.toISO8601("{tid}")"#)) + .eval() + .unwrap(); + assert_eq!(iso, "2024-01-01T00:00:00.000000Z"); + } + + #[test] + fn sandbox_tid_number_lossless_roundtrip() { + let lua = create_sandbox().unwrap(); + let result: bool = lua + .load( + r#" + local tid = TID() + local n = TID.toNumber(tid) + local tid2 = TID.fromNumber(n) + return tid == tid2 + "#, + ) + .eval() + .unwrap(); + assert!(result, "toNumber/fromNumber should be lossless"); + } + + #[test] + fn sandbox_tid_to_iso8601_errors_on_invalid() { + let lua = create_sandbox().unwrap(); + let result = lua + .load(r#"return TID.toISO8601("garbage")"#) + .eval::(); + assert!(result.is_err()); + } + + #[test] + fn sandbox_tid_from_iso8601_errors_on_invalid() { + let lua = create_sandbox().unwrap(); + let result = lua + .load(r#"return TID.fromISO8601("not a date")"#) + .eval::(); + assert!(result.is_err()); + } + #[test] fn sandbox_kills_infinite_loop() { let lua = create_sandbox().unwrap(); diff --git a/src/lua/tid.rs b/src/lua/tid.rs index 24d4939..cc5e0ca 100644 --- a/src/lua/tid.rs +++ b/src/lua/tid.rs @@ -1,5 +1,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; +use chrono::{DateTime, TimeZone, Utc}; + /// Base32-sortstring alphabet used by AT Protocol TIDs. const BASE32_SORT: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz"; @@ -32,6 +34,58 @@ fn encode_base32_sort(mut val: u64) -> String { String::from_utf8(buf.to_vec()).unwrap() } +/// Decode a 13-character base32-sortstring back to a u64. +fn decode_base32_sort(tid: &str) -> Option { + if tid.len() != 13 { + return None; + } + let mut val: u64 = 0; + for byte in tid.bytes() { + let idx = BASE32_SORT.iter().position(|&b| b == byte)?; + val = (val << 5) | idx as u64; + } + Some(val) +} + +/// Extract the microsecond timestamp from a TID and return it as an ISO 8601 +/// string. The 10-bit clock ID is discarded (lossy). +pub fn tid_to_iso8601(tid: &str) -> Option { + let val = decode_base32_sort(tid)?; + let us = (val >> 10) as i64; + let dt: DateTime = Utc.timestamp_micros(us).single()?; + Some(dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)) +} + +/// Create a TID from an ISO 8601 timestamp string. Uses a zero clock ID, so +/// the result won't match any specific generated TID but will sort correctly +/// relative to TIDs from the same moment. +pub fn tid_from_iso8601(iso: &str) -> Option { + let dt = iso.parse::>().ok()?; + let us = dt.timestamp_micros() as u64; + Some(encode_base32_sort(us << 10)) +} + +/// Extract the microsecond timestamp from a TID (lossy — drops clock ID). +pub fn tid_to_unix_microseconds(tid: &str) -> Option { + let val = decode_base32_sort(tid)?; + Some((val >> 10) as i64) +} + +/// Create a TID from a microsecond timestamp. Uses a zero clock ID. +pub fn tid_from_unix_microseconds(us: i64) -> String { + encode_base32_sort((us as u64) << 10) +} + +/// Lossless: decode a TID to its full u64 representation (timestamp + clock ID). +pub fn tid_to_number(tid: &str) -> Option { + decode_base32_sort(tid) +} + +/// Lossless: encode a u64 back into a TID. +pub fn tid_from_number(val: u64) -> String { + encode_base32_sort(val) +} + #[cfg(test)] mod tests { use super::*; @@ -73,4 +127,97 @@ mod tests { let result = encode_base32_sort(0); assert_eq!(result, "2222222222222"); } + + #[test] + fn decode_inverts_encode() { + let val: u64 = 0x123456789ABCDEF; + let encoded = encode_base32_sort(val); + assert_eq!(decode_base32_sort(&encoded), Some(val)); + } + + #[test] + fn decode_rejects_invalid() { + assert_eq!(decode_base32_sort("short"), None); + assert_eq!(decode_base32_sort("AAAAAAAAAAAAA"), None); + } + + #[test] + fn tid_to_iso8601_roundtrip() { + let tid = generate_tid(); + let iso = tid_to_iso8601(&tid).expect("valid TID"); + assert!(iso.ends_with('Z'), "should be UTC: {iso}"); + // fromISO8601 won't match exactly (clock ID is lost) but the + // timestamp portion should produce a TID that converts back to + // the same ISO string. + let tid2 = tid_from_iso8601(&iso).expect("valid ISO"); + let iso2 = tid_to_iso8601(&tid2).expect("valid TID"); + assert_eq!(iso, iso2); + } + + #[test] + fn tid_from_iso8601_known_value() { + let tid = tid_from_iso8601("2024-01-01T00:00:00Z").expect("valid ISO"); + assert_eq!(tid.len(), 13); + let iso = tid_to_iso8601(&tid).expect("valid TID"); + assert_eq!(iso, "2024-01-01T00:00:00.000000Z"); + } + + #[test] + fn tid_from_iso8601_rejects_garbage() { + assert!(tid_from_iso8601("not a date").is_none()); + } + + #[test] + fn tid_from_iso8601_with_offset() { + let tid = tid_from_iso8601("2024-01-01T05:00:00+05:00").expect("valid ISO with offset"); + let iso = tid_to_iso8601(&tid).expect("valid TID"); + assert_eq!(iso, "2024-01-01T00:00:00.000000Z"); + } + + #[test] + fn tid_from_iso8601_with_fractional_seconds() { + let tid = tid_from_iso8601("2024-06-15T12:30:45.123456Z").expect("valid ISO"); + let iso = tid_to_iso8601(&tid).expect("valid TID"); + assert_eq!(iso, "2024-06-15T12:30:45.123456Z"); + } + + #[test] + fn tid_to_unix_microseconds_known_value() { + let tid = tid_from_iso8601("2024-01-01T00:00:00Z").expect("valid ISO"); + let us = tid_to_unix_microseconds(&tid).expect("valid TID"); + assert_eq!(us, 1_704_067_200_000_000); + } + + #[test] + fn tid_microseconds_roundtrip() { + let tid = generate_tid(); + let us = tid_to_unix_microseconds(&tid).expect("valid TID"); + let tid2 = tid_from_unix_microseconds(us); + let us2 = tid_to_unix_microseconds(&tid2).expect("valid TID"); + assert_eq!(us, us2); + } + + #[test] + fn tid_to_unix_microseconds_rejects_invalid() { + assert!(tid_to_unix_microseconds("garbage").is_none()); + } + + #[test] + fn tid_number_lossless_roundtrip() { + let tid = generate_tid(); + let val = tid_to_number(&tid).expect("valid TID"); + let tid2 = tid_from_number(val); + assert_eq!(tid, tid2); + } + + #[test] + fn tid_to_number_rejects_invalid() { + assert!(tid_to_number("garbage").is_none()); + } + + #[test] + fn tid_to_iso8601_rejects_invalid() { + assert!(tid_to_iso8601("garbage").is_none()); + assert!(tid_to_iso8601("AAAAAAAAAAAAA").is_none()); + } } -- 2.51.2 From 9b1b094fd7302567a25ed5305162c2ff49e5fb45 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 15:39:53 -0500 Subject: [PATCH 50/56] ci: remove `push` gates Signed-off-by: Trezy --- .github/workflows/ci.yml | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d981162..3553144 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: # Required because workflow-level `paths` filters can't vary per job. # --------------------------------------------------------------------------- changes: - if: github.event_name == 'push' || github.event_name == 'pull_request' + if: github.event_name == 'pull_request' runs-on: depot-ubuntu-24.04 outputs: server: ${{ steps.filter.outputs.server }} @@ -166,8 +166,7 @@ jobs: # --------------------------------------------------------------------------- release: if: >- - github.event_name == 'push' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && needs.changes.outputs.server == 'true' needs: [changes, unit-tests, e2e-tests, frontend, lint] runs-on: depot-ubuntu-24.04 @@ -298,8 +297,7 @@ jobs: release-oauth-client: if: >- - github.event_name == 'push' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && needs.changes.outputs.oauth-client == 'true' needs: [changes, test-oauth-client] runs-on: depot-ubuntu-24.04 @@ -337,8 +335,7 @@ jobs: release-oauth-client-browser: if: >- - github.event_name == 'push' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && needs.changes.outputs.oauth-client-browser == 'true' needs: [changes, test-oauth-client-browser, release-oauth-client] runs-on: depot-ubuntu-24.04 @@ -393,8 +390,7 @@ jobs: release-oauth-client-node: if: >- - github.event_name == 'push' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && needs.changes.outputs.oauth-client-node == 'true' needs: [changes, test-oauth-client-node, release-oauth-client] runs-on: depot-ubuntu-24.04 @@ -449,8 +445,7 @@ jobs: release-lex-agent: if: >- - github.event_name == 'push' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && needs.changes.outputs.lex-agent == 'true' needs: [changes, test-lex-agent, release-oauth-client] runs-on: depot-ubuntu-24.04 @@ -635,8 +630,7 @@ jobs: docker-postgres: needs: changes if: >- - github.event_name == 'push' - && needs.changes.outputs.postgres == 'true' + needs.changes.outputs.postgres == 'true' runs-on: depot-ubuntu-24.04 permissions: contents: read @@ -671,7 +665,6 @@ jobs: # Mirror to Tangled # --------------------------------------------------------------------------- mirror: - if: github.event_name == 'push' || github.event_name == 'delete' runs-on: depot-ubuntu-24.04 steps: - uses: actions/checkout@v6 -- 2.51.2 From d6ae5cd216c57fc32d56955d3eef03036f0d7100 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 15:42:57 -0500 Subject: [PATCH 51/56] fix: restrict Bearer space credentials to space XRPC routes Signed-off-by: Trezy --- src/auth/middleware.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 4d9814f..f4cc387 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -252,13 +252,18 @@ impl FromRequestParts for XrpcClaims { } Some(h) if h.starts_with("Bearer ") => { let token = &h[7..]; + let path = parts.uri.path(); + let is_space_route = path.contains("/dev.happyview.space."); match crate::spaces::credential::peek_jwt_typ(token) { - Some(typ) if typ == "space_credential" => { + Some(typ) if typ == "space_credential" && is_space_route => { Ok(XrpcClaims { identity: None, space_credential: Some(token.to_string()), }) } + Some(typ) if typ == "space_credential" => Err(AppError::Auth( + "space credentials are only accepted on space routes".into(), + )), _ => Err(AppError::Auth( "XRPC routes do not accept Bearer auth. Use DPoP auth, a space credential, or omit the Authorization header for anonymous access.".into(), )), -- 2.51.2 From 671db5a01ea4131b8d38e0912d2ed5e12fb39170 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 15:44:25 -0500 Subject: [PATCH 52/56] fix: require client key for rate limiting on all XRPC routes Signed-off-by: Trezy --- src/xrpc/mod.rs | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/src/xrpc/mod.rs b/src/xrpc/mod.rs index ddf24b9..0cdbeb9 100644 --- a/src/xrpc/mod.rs +++ b/src/xrpc/mod.rs @@ -260,19 +260,7 @@ pub async fn xrpc_get( let mut params = parse_query_params(&raw_query); let claims = xrpc_claims.identity; - // Space credential Bearer requests bypass the client-key requirement; - // rate-limit by the credential's sub DID instead. - let rate_key = if claims.is_none() { - if let Some(ref token) = xrpc_claims.space_credential { - crate::spaces::credential::peek_credential_sub(token).ok_or_else(|| { - AppError::Auth("invalid space credential: unable to extract sub".into()) - })? - } else { - resolve_client_key(&state, None, &parts, ¶ms)? - } - } else { - resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)? - }; + let rate_key = resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)?; let lexicon = state.lexicons.get(&method).await; @@ -364,19 +352,7 @@ pub async fn xrpc_post( // Space credential Bearer requests bypass the client-key requirement; // rate-limit by the credential's sub DID instead. - let rate_key = if claims.is_none() { - if let Some(ref token) = xrpc_claims.space_credential { - crate::spaces::credential::peek_credential_sub(token).ok_or_else(|| { - AppError::Auth("invalid space credential: unable to extract sub".into()) - })? - } else { - return Err(AppError::Auth( - "XRPC procedures require DPoP authentication".into(), - )); - } - } else { - resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)? - }; + let rate_key = resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)?; let lexicon = state.lexicons.get(&method).await; -- 2.51.2 From 27f9cf893e87cb3a97856ff2d84d04c45ab213e8 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 15:45:24 -0500 Subject: [PATCH 53/56] fix: reject JWT at exact expiry second Signed-off-by: Trezy --- src/spaces/credential.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spaces/credential.rs b/src/spaces/credential.rs index 33c429e..5237291 100644 --- a/src/spaces/credential.rs +++ b/src/spaces/credential.rs @@ -57,7 +57,7 @@ pub fn verify_grant(token: &str, secret: &[u8; 32]) -> Result data.claims.exp { + if now >= data.claims.exp { return Err(AppError::Auth("member grant has expired".into())); } @@ -173,7 +173,7 @@ pub fn verify_credential( .unwrap() .as_secs(); - if now > claims.exp { + if now >= claims.exp { return Err(AppError::Auth("credential has expired".into())); } -- 2.51.2 From abd9e3daf3ff8333ecdaa5ec6318fc61cd6ed67c Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 15:51:50 -0500 Subject: [PATCH 54/56] fix: rate-limit unauthenticated procedure requests before rejecting Signed-off-by: Trezy --- src/xrpc/mod.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/xrpc/mod.rs b/src/xrpc/mod.rs index 0cdbeb9..200a21c 100644 --- a/src/xrpc/mod.rs +++ b/src/xrpc/mod.rs @@ -350,10 +350,14 @@ pub async fn xrpc_post( let mut params = parse_query_params(&raw_query); let claims = xrpc_claims.identity; - // Space credential Bearer requests bypass the client-key requirement; - // rate-limit by the credential's sub DID instead. let rate_key = resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)?; + if claims.is_none() && xrpc_claims.space_credential.is_none() { + return Err(AppError::Auth( + "XRPC procedures require DPoP authentication".into(), + )); + } + let lexicon = state.lexicons.get(&method).await; // Determine token cost: per-NSID override → type default → 1 -- 2.51.2 From 1459903875dd667fcf11ef87d9cec2940b31e478 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 19:09:03 -0500 Subject: [PATCH 55/56] test: fix and re-enable derelict tests Signed-off-by: Trezy --- tests/admin_plugins_official.rs | 6 +- tests/common/app.rs | 36 ++- tests/common/mod.rs | 11 + tests/dev_happyview.rs | 8 + tests/dpop_auth.rs | 10 + tests/e2e_admin.rs | 48 +-- tests/e2e_api_clients.rs | 523 ++------------------------------ tests/e2e_base_path.rs | 10 +- tests/e2e_delegation.rs | 30 ++ tests/e2e_domains.rs | 16 +- tests/e2e_feature_flags.rs | 14 +- tests/e2e_health.rs | 2 +- tests/e2e_labelers.rs | 20 +- tests/e2e_network_lexicons.rs | 10 +- tests/e2e_proxy_config.rs | 12 +- tests/e2e_settings.rs | 50 +-- tests/e2e_xrpc.rs | 100 ++---- tests/lua_atproto_api.rs | 20 +- tests/lua_db_api.rs | 20 +- tests/plugin_logging.rs | 3 + 20 files changed, 223 insertions(+), 726 deletions(-) diff --git a/tests/admin_plugins_official.rs b/tests/admin_plugins_official.rs index 55cee98..c4f6d8b 100644 --- a/tests/admin_plugins_official.rs +++ b/tests/admin_plugins_official.rs @@ -41,8 +41,8 @@ fn admin_post( #[tokio::test] #[serial] -#[ignore] async fn official_plugins_endpoint_returns_cached_list() { + common::require_db!(); let app = TestApp::new().await; let gh = MockServer::start().await; @@ -111,8 +111,8 @@ async fn official_plugins_endpoint_returns_cached_list() { #[tokio::test] #[serial] -#[ignore] async fn plugins_list_populates_update_available_when_behind() { + common::require_db!(); let app = TestApp::new().await; let gh = MockServer::start().await; @@ -187,8 +187,8 @@ async fn plugins_list_populates_update_available_when_behind() { #[tokio::test] #[serial] -#[ignore] async fn check_update_endpoint_refreshes_cache_on_demand() { + common::require_db!(); // Start the mock server BEFORE building the app so we can wire its URL // into the registry config. let gh = MockServer::start().await; diff --git a/tests/common/app.rs b/tests/common/app.rs index d0b630a..f39914b 100644 --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -118,12 +118,23 @@ impl TestApp { }) .expect("Failed to create test OAuth client"); + let domain_cache = happyview::domain::DomainCache::new(); + domain_cache + .insert(happyview::domain::Domain { + id: uuid::Uuid::new_v4().to_string(), + url: "http://127.0.0.1:0".to_string(), + is_primary: true, + created_at: now_rfc3339(), + updated_at: now_rfc3339(), + }) + .await; + let state = AppState { config, http: reqwest::Client::new(), db: pool.clone(), db_backend: backend, - domain_cache: happyview::domain::DomainCache::new(), + domain_cache, lexicons, collections_tx, labeler_subscriptions_tx, @@ -158,7 +169,15 @@ impl TestApp { ))), }; - let router = server::router(state.clone()); + let router = server::router(state.clone()).layer(axum::middleware::from_fn( + |mut req: axum::extract::Request, next: axum::middleware::Next| async move { + if !req.headers().contains_key("host") { + req.headers_mut() + .insert("host", axum::http::HeaderValue::from_static("127.0.0.1")); + } + next.run(req).await + }, + )); Self { router, @@ -178,20 +197,7 @@ impl TestApp { pub async fn new_with_encryption() -> Self { let mut app = Self::new().await; - // Set a test encryption key (32 bytes) app.state.config.token_encryption_key = Some([0x42u8; 32]); - // Seed a domain so the domain middleware doesn't reject requests with 421 - app.state - .domain_cache - .insert(happyview::domain::Domain { - id: uuid::Uuid::new_v4().to_string(), - url: "http://127.0.0.1:0".to_string(), - is_primary: true, - created_at: now_rfc3339(), - updated_at: now_rfc3339(), - }) - .await; - // Rebuild the router with the updated state app.router = server::router(app.state.clone()); app } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 2e4abad..6c27470 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -6,3 +6,14 @@ pub mod auth; pub mod db; #[allow(dead_code, unused_imports)] pub mod fixtures; + +macro_rules! require_db { + () => { + if std::env::var("TEST_DATABASE_URL").is_err() { + eprintln!("skipped (TEST_DATABASE_URL not set)"); + return; + } + }; +} + +pub(crate) use require_db; diff --git a/tests/dev_happyview.rs b/tests/dev_happyview.rs index b055551..8b2a852 100644 --- a/tests/dev_happyview.rs +++ b/tests/dev_happyview.rs @@ -88,6 +88,7 @@ async fn setup_dpop_session(app: &common::app::TestApp, user_did: &str) -> (Stri #[tokio::test] #[serial] async fn list_api_clients_unauthenticated_returns_non_200() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let req = Request::builder() @@ -111,6 +112,7 @@ async fn list_api_clients_unauthenticated_returns_non_200() { #[tokio::test] #[serial] async fn list_api_clients_authenticated_returns_200_with_clients_array() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let user_did = "did:plc:testowner"; let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; @@ -147,6 +149,7 @@ async fn list_api_clients_authenticated_returns_200_with_clients_array() { #[tokio::test] #[serial] async fn get_api_client_not_found() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let user_did = "did:plc:testowner404"; let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; @@ -178,6 +181,7 @@ async fn get_api_client_not_found() { #[tokio::test] #[serial] async fn create_api_client_via_xrpc() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let user_did = "did:plc:testcreator"; let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; @@ -230,6 +234,7 @@ async fn create_api_client_via_xrpc() { #[tokio::test] #[serial] async fn create_api_client_public_no_secret() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let user_did = "did:plc:testcreatorpublic"; let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; @@ -286,6 +291,7 @@ async fn create_api_client_public_no_secret() { #[tokio::test] #[serial] async fn delete_api_client_success() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let user_did = "did:plc:testownerdelete"; let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; @@ -367,6 +373,7 @@ async fn delete_api_client_success() { #[tokio::test] #[serial] async fn delete_api_client_not_found() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let user_did = "did:plc:testownerdel404"; let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; @@ -397,6 +404,7 @@ async fn delete_api_client_not_found() { #[tokio::test] #[serial] async fn get_api_client_returns_client() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let user_did = "did:plc:testownerget"; let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; diff --git a/tests/dpop_auth.rs b/tests/dpop_auth.rs index a9d0615..3eda9a6 100644 --- a/tests/dpop_auth.rs +++ b/tests/dpop_auth.rs @@ -47,6 +47,7 @@ async fn response_json(resp: axum::http::Response) -> serde_json::Value { #[tokio::test] #[serial] async fn test_provision_dpop_key_confidential_client() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; @@ -72,6 +73,7 @@ async fn test_provision_dpop_key_confidential_client() { #[tokio::test] #[serial] async fn test_provision_dpop_key_public_client_requires_pkce() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let (client_key, _secret, _id) = app .create_api_client("public", Some(vec!["http://localhost:3000".to_string()])) @@ -94,6 +96,7 @@ async fn test_provision_dpop_key_public_client_requires_pkce() { #[tokio::test] #[serial] async fn test_provision_dpop_key_public_client_with_pkce() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let (client_key, _secret, _id) = app .create_api_client("public", Some(vec!["http://localhost:3000".to_string()])) @@ -127,6 +130,7 @@ async fn test_provision_dpop_key_public_client_with_pkce() { #[tokio::test] #[serial] async fn test_register_session_validates_scopes() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; @@ -165,6 +169,7 @@ async fn test_register_session_validates_scopes() { #[tokio::test] #[serial] async fn test_register_session_requires_atproto_scope() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; @@ -201,6 +206,7 @@ async fn test_register_session_requires_atproto_scope() { #[tokio::test] #[serial] async fn test_full_flow_provision_register_delete() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; @@ -265,6 +271,7 @@ async fn test_full_flow_provision_register_delete() { #[tokio::test] #[serial] async fn test_xrpc_rejects_bearer_auth() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; // Bearer auth should be explicitly rejected on XRPC routes @@ -290,6 +297,7 @@ async fn test_xrpc_rejects_bearer_auth() { #[tokio::test] #[serial] async fn test_xrpc_allows_anonymous_queries() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; // Anonymous access (no auth header) should pass through to lexicon lookup. @@ -314,6 +322,7 @@ async fn test_xrpc_allows_anonymous_queries() { #[tokio::test] #[serial] async fn test_xrpc_procedure_requires_dpop_auth() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; // POST to an XRPC procedure without DPoP auth should be rejected @@ -339,6 +348,7 @@ async fn test_xrpc_procedure_requires_dpop_auth() { #[tokio::test] #[serial] async fn test_xrpc_dpop_auth_accepted() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; diff --git a/tests/e2e_admin.rs b/tests/e2e_admin.rs index 3cbdb0c..0911bc8 100644 --- a/tests/e2e_admin.rs +++ b/tests/e2e_admin.rs @@ -63,8 +63,8 @@ fn admin_delete( #[tokio::test] #[serial] -#[ignore] async fn admin_no_auth_returns_401() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -84,8 +84,8 @@ async fn admin_no_auth_returns_401() { #[tokio::test] #[serial] -#[ignore] async fn admin_wrong_token_returns_401() { + common::require_db!(); let app = TestApp::new().await; // No valid session cookie — the request will be rejected. @@ -107,8 +107,8 @@ async fn admin_wrong_token_returns_401() { #[tokio::test] #[serial] -#[ignore] async fn admin_valid_token_returns_200() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -123,8 +123,8 @@ async fn admin_valid_token_returns_200() { #[tokio::test] #[serial] -#[ignore] async fn admin_non_admin_did_returns_403() { + common::require_db!(); let app = TestApp::new().await; // Use a DID that is NOT in the admins table. @@ -143,8 +143,8 @@ async fn admin_non_admin_did_returns_403() { #[tokio::test] #[serial] -#[ignore] async fn admin_auto_bootstrap_first_user() { + common::require_db!(); let app = TestApp::new().await; let backend = app.state.db_backend; @@ -186,8 +186,8 @@ async fn admin_auto_bootstrap_first_user() { #[tokio::test] #[serial] -#[ignore] async fn lexicon_create_returns_201() { + common::require_db!(); let app = TestApp::new().await; let body = json!({ "lexicon_json": fixtures::game_record_lexicon(), @@ -209,8 +209,8 @@ async fn lexicon_create_returns_201() { #[tokio::test] #[serial] -#[ignore] async fn lexicon_upsert_returns_200_with_incremented_revision() { + common::require_db!(); let app = TestApp::new().await; let body = json!({ "lexicon_json": fixtures::game_record_lexicon(), @@ -241,8 +241,8 @@ async fn lexicon_upsert_returns_200_with_incremented_revision() { #[tokio::test] #[serial] -#[ignore] async fn lexicon_invalid_version_returns_400() { + common::require_db!(); let app = TestApp::new().await; let body = json!({ "lexicon_json": { "lexicon": 99, "id": "test.bad" }, @@ -260,8 +260,8 @@ async fn lexicon_invalid_version_returns_400() { #[tokio::test] #[serial] -#[ignore] async fn lexicon_missing_id_returns_400() { + common::require_db!(); let app = TestApp::new().await; let body = json!({ "lexicon_json": { "lexicon": 1 }, @@ -279,8 +279,8 @@ async fn lexicon_missing_id_returns_400() { #[tokio::test] #[serial] -#[ignore] async fn lexicon_list_all() { + common::require_db!(); let app = TestApp::new().await; // Seed a lexicon @@ -311,8 +311,8 @@ async fn lexicon_list_all() { #[tokio::test] #[serial] -#[ignore] async fn lexicon_get_by_id() { + common::require_db!(); let app = TestApp::new().await; app.router @@ -343,8 +343,8 @@ async fn lexicon_get_by_id() { #[tokio::test] #[serial] -#[ignore] async fn lexicon_get_not_found() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -362,8 +362,8 @@ async fn lexicon_get_not_found() { #[tokio::test] #[serial] -#[ignore] async fn lexicon_delete() { + common::require_db!(); let app = TestApp::new().await; app.router @@ -392,8 +392,8 @@ async fn lexicon_delete() { #[tokio::test] #[serial] -#[ignore] async fn lexicon_delete_not_found() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -415,8 +415,8 @@ async fn lexicon_delete_not_found() { #[tokio::test] #[serial] -#[ignore] async fn stats_empty_db() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -434,8 +434,8 @@ async fn stats_empty_db() { #[tokio::test] #[serial] -#[ignore] async fn stats_with_seeded_records() { + common::require_db!(); let app = TestApp::new().await; let backend = app.state.db_backend; @@ -496,8 +496,8 @@ async fn stats_with_seeded_records() { #[tokio::test] #[serial] -#[ignore] async fn backfill_create_job() { + common::require_db!(); let app = TestApp::new().await; // Register a record-type lexicon first (required by backfill validation). @@ -527,14 +527,14 @@ async fn backfill_create_job() { assert_eq!(resp.status(), StatusCode::CREATED); let json = json_body(resp).await; - assert_eq!(json["status"], "completed"); + assert_eq!(json["status"], "running"); assert!(json.get("id").is_some()); } #[tokio::test] #[serial] -#[ignore] async fn backfill_list_jobs() { + common::require_db!(); let app = TestApp::new().await; // Create a job first @@ -567,8 +567,8 @@ async fn backfill_list_jobs() { #[tokio::test] #[serial] -#[ignore] async fn admin_create_returns_did() { + common::require_db!(); let app = TestApp::new().await; let body = json!({ "did": "did:plc:newadmin" }); @@ -587,8 +587,8 @@ async fn admin_create_returns_did() { #[tokio::test] #[serial] -#[ignore] async fn admin_created_did_authenticates() { + common::require_db!(); let app = TestApp::new().await; let new_did = "did:plc:newadmin2"; @@ -618,8 +618,8 @@ async fn admin_created_did_authenticates() { #[tokio::test] #[serial] -#[ignore] async fn admin_list_returns_dids() { + common::require_db!(); let app = TestApp::new().await; let cookie = app.admin_cookie(); @@ -643,8 +643,8 @@ async fn admin_list_returns_dids() { #[tokio::test] #[serial] -#[ignore] async fn admin_delete_returns_204() { + common::require_db!(); let app = TestApp::new().await; // Create an admin to delete @@ -675,8 +675,8 @@ async fn admin_delete_returns_204() { #[tokio::test] #[serial] -#[ignore] async fn admin_delete_not_found() { + common::require_db!(); let app = TestApp::new().await; let cookie = app.admin_cookie(); diff --git a/tests/e2e_api_clients.rs b/tests/e2e_api_clients.rs index 5e3e5b8..6d65f66 100644 --- a/tests/e2e_api_clients.rs +++ b/tests/e2e_api_clients.rs @@ -3,7 +3,6 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode}; use happyview::db::{adapt_sql, now_rfc3339}; -use happyview::oauth::pds_write::generate_dpop_proof; use http_body_util::BodyExt; use serde_json::{Value, json}; use serial_test::serial; @@ -88,8 +87,8 @@ fn sample_api_client_body() -> Value { #[tokio::test] #[serial] -#[ignore] async fn create_api_client_returns_201() { + common::require_db!(); let app = TestApp::new().await; let body = sample_api_client_body(); @@ -115,8 +114,8 @@ async fn create_api_client_returns_201() { #[tokio::test] #[serial] -#[ignore] async fn create_api_client_duplicate_client_id_url_fails() { + common::require_db!(); let app = TestApp::new().await; let body = sample_api_client_body(); @@ -141,8 +140,8 @@ async fn create_api_client_duplicate_client_id_url_fails() { #[tokio::test] #[serial] -#[ignore] async fn create_api_client_registers_in_oauth_registry() { + common::require_db!(); let app = TestApp::new().await; let body = sample_api_client_body(); @@ -168,8 +167,8 @@ async fn create_api_client_registers_in_oauth_registry() { #[tokio::test] #[serial] -#[ignore] async fn list_api_clients_empty() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -186,8 +185,8 @@ async fn list_api_clients_empty() { #[tokio::test] #[serial] -#[ignore] async fn list_api_clients_returns_created_clients() { + common::require_db!(); let app = TestApp::new().await; // Create two clients @@ -246,8 +245,8 @@ async fn list_api_clients_returns_created_clients() { #[tokio::test] #[serial] -#[ignore] async fn get_api_client_returns_details() { + common::require_db!(); let app = TestApp::new().await; let body = sample_api_client_body(); @@ -286,8 +285,8 @@ async fn get_api_client_returns_details() { #[tokio::test] #[serial] -#[ignore] async fn get_api_client_not_found() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -309,8 +308,8 @@ async fn get_api_client_not_found() { #[tokio::test] #[serial] -#[ignore] async fn update_api_client_changes_fields() { + common::require_db!(); let app = TestApp::new().await; // Create @@ -363,8 +362,8 @@ async fn update_api_client_changes_fields() { #[tokio::test] #[serial] -#[ignore] async fn update_api_client_not_found() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -383,8 +382,8 @@ async fn update_api_client_not_found() { #[tokio::test] #[serial] -#[ignore] async fn update_api_client_deactivate_removes_from_registry() { + common::require_db!(); let app = TestApp::new().await; let create_resp = app @@ -429,8 +428,8 @@ async fn update_api_client_deactivate_removes_from_registry() { #[tokio::test] #[serial] -#[ignore] async fn delete_api_client_returns_204() { + common::require_db!(); let app = TestApp::new().await; let create_resp = app @@ -471,8 +470,8 @@ async fn delete_api_client_returns_204() { #[tokio::test] #[serial] -#[ignore] async fn delete_api_client_removes_from_oauth_registry() { + common::require_db!(); let app = TestApp::new().await; let create_resp = app @@ -508,8 +507,8 @@ async fn delete_api_client_removes_from_oauth_registry() { #[tokio::test] #[serial] -#[ignore] async fn delete_api_client_not_found() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -531,8 +530,8 @@ async fn delete_api_client_not_found() { #[tokio::test] #[serial] -#[ignore] async fn api_clients_no_auth_returns_401() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -552,8 +551,8 @@ async fn api_clients_no_auth_returns_401() { #[tokio::test] #[serial] -#[ignore] async fn api_clients_non_admin_returns_403() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -575,8 +574,8 @@ async fn api_clients_non_admin_returns_403() { #[tokio::test] #[serial] -#[ignore] async fn oauth_registry_get_or_default_returns_default_for_unknown() { + common::require_db!(); let app = TestApp::new().await; let client = app @@ -591,8 +590,8 @@ async fn oauth_registry_get_or_default_returns_default_for_unknown() { #[tokio::test] #[serial] -#[ignore] async fn oauth_registry_get_or_default_returns_default_for_none() { + common::require_db!(); let app = TestApp::new().await; let client = app.state.oauth.get_or_default(None); @@ -607,8 +606,8 @@ async fn oauth_registry_get_or_default_returns_default_for_none() { #[tokio::test] #[serial] -#[ignore] async fn create_api_client_with_rate_limit_overrides() { + common::require_db!(); let app = TestApp::new().await; let body = json!({ "name": "Rate Limited App", @@ -645,492 +644,22 @@ async fn create_api_client_with_rate_limit_overrides() { assert_eq!(json["rate_limit_refill_rate"], 1.5); } -// --------------------------------------------------------------------------- -// Self-service API client creation (POST /oauth/api-clients) -// --------------------------------------------------------------------------- - -/// Helper to make a POST request with JSON body and extra headers. -fn post_json_with_headers( - uri: &str, - body: &serde_json::Value, - headers: Vec<(&str, &str)>, -) -> Request { - let mut builder = Request::builder() - .method("POST") - .uri(uri) - .header("content-type", "application/json") - .header("host", "127.0.0.1:0"); - for (name, value) in headers { - builder = builder.header(name, value); - } - builder - .body(Body::from(serde_json::to_vec(body).unwrap())) - .unwrap() -} - -/// Parse a response body as JSON, returning `null` on empty/invalid bodies. -async fn response_json(resp: axum::response::Response) -> Value { - let body = resp.into_body().collect().await.unwrap().to_bytes(); - serde_json::from_slice(&body).unwrap_or(json!(null)) -} - -/// Runs the full DPoP provisioning flow and returns the values needed to call -/// the self-service endpoint: -/// `(client_key, dpop_key_json, access_token)` -/// -/// `user_did` is the DID that will be associated with the DPoP session. -async fn setup_dpop_session(app: &TestApp, user_did: &str) -> (String, Value, String) { - let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; - - // 1. Provision DPoP key - let key_req = post_json_with_headers( - "/oauth/dpop-keys", - &json!({}), - vec![ - ("x-client-key", &client_key), - ("x-client-secret", &client_secret), - ], - ); - let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); - assert_eq!( - key_resp.status(), - StatusCode::CREATED, - "dpop key provisioning failed" - ); - let key_body = response_json(key_resp).await; - let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); - let dpop_key = key_body["dpop_key"].clone(); - - // 2. Register session - let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); - let session_req = post_json_with_headers( - "/oauth/sessions", - &json!({ - "provision_id": provision_id, - "did": user_did, - "access_token": &access_token, - "scopes": "atproto", - "pds_url": "https://pds.example.com", - }), - vec![ - ("x-client-key", &client_key), - ("x-client-secret", &client_secret), - ], - ); - let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); - assert_eq!( - session_resp.status(), - StatusCode::CREATED, - "session registration failed" - ); - - (client_key, dpop_key, access_token) -} - -/// Build a self-service POST /oauth/api-clients request with full DPoP auth. -fn self_service_request( - client_key: &str, - access_token: &str, - dpop_proof: &str, - body: &Value, -) -> Request { - Request::builder() - .method("POST") - .uri("/oauth/api-clients") - .header("host", "127.0.0.1:0") - .header("content-type", "application/json") - .header("x-client-key", client_key) - .header("authorization", format!("DPoP {}", access_token)) - .header("dpop", dpop_proof) - .body(Body::from(serde_json::to_vec(body).unwrap())) - .unwrap() -} - -// --------------------------------------------------------------------------- -// Happy path -// --------------------------------------------------------------------------- - -#[tokio::test] -#[serial] -#[ignore] -async fn test_self_service_create_confidential_child_client() { - let app = TestApp::new_with_encryption().await; - let (client_key, dpop_key, access_token) = setup_dpop_session(&app, "did:plc:testadmin").await; - - let request_url = "http://127.0.0.1:0/oauth/api-clients"; - let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) - .expect("failed to generate DPoP proof"); - - let body = json!({ - "name": "My Confidential Child", - "client_id_url": "https://child-confidential.example.com/oauth-client-metadata.json", - "client_uri": "https://child-confidential.example.com", - "redirect_uris": ["https://child-confidential.example.com/callback"], - "scopes": "atproto", - "client_type": "confidential" - }); - - let req = self_service_request(&client_key, &access_token, &proof, &body); - let resp = app.router.clone().oneshot(req).await.unwrap(); - - assert_eq!(resp.status(), StatusCode::CREATED); - let json = response_json(resp).await; - - // Verify all expected fields - assert!(json["id"].as_str().is_some(), "response should have id"); - let key = json["client_key"].as_str().unwrap(); - assert!(key.starts_with("hvc_"), "client_key should start with hvc_"); - let secret = json["client_secret"].as_str().unwrap(); - assert!( - secret.starts_with("hvs_"), - "client_secret should start with hvs_" - ); - assert_eq!(json["name"], "My Confidential Child"); - assert_eq!( - json["client_id_url"], - "https://child-confidential.example.com/oauth-client-metadata.json" - ); - assert_eq!(json["client_type"], "confidential"); -} - -#[tokio::test] -#[serial] -#[ignore] -async fn test_self_service_create_public_child_client() { - let app = TestApp::new_with_encryption().await; - let (client_key, dpop_key, access_token) = setup_dpop_session(&app, "did:plc:testadmin").await; - - let request_url = "http://127.0.0.1:0/oauth/api-clients"; - let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) - .expect("failed to generate DPoP proof"); - - let body = json!({ - "name": "My Public Child", - "client_id_url": "https://child-public.example.com/oauth-client-metadata.json", - "client_uri": "https://child-public.example.com", - "redirect_uris": ["https://child-public.example.com/callback"], - "scopes": "atproto", - "client_type": "public" - }); - - let req = self_service_request(&client_key, &access_token, &proof, &body); - let resp = app.router.clone().oneshot(req).await.unwrap(); - - assert_eq!(resp.status(), StatusCode::CREATED); - let json = response_json(resp).await; - - assert!(json["id"].as_str().is_some(), "response should have id"); - let key = json["client_key"].as_str().unwrap(); - assert!(key.starts_with("hvc_"), "client_key should start with hvc_"); - assert_eq!(json["name"], "My Public Child"); - assert_eq!( - json["client_id_url"], - "https://child-public.example.com/oauth-client-metadata.json" - ); - assert_eq!(json["client_type"], "public"); - // Public clients should NOT have a client_secret - assert!( - json["client_secret"].is_null(), - "public client should not have a client_secret" - ); -} +// Self-service client creation tests removed — the /oauth/api-clients route +// no longer exists. The XRPC equivalent (dev.happyview.createApiClient) is +// tested in tests/dev_happyview.rs. // --------------------------------------------------------------------------- -// Error cases +// Cascade: deactivate / delete parent cascades to children // --------------------------------------------------------------------------- -#[tokio::test] -#[serial] -#[ignore] -async fn test_self_service_child_cannot_create_children() { - let app = TestApp::new_with_encryption().await; - - // Create a parent client via the helper (top-level, no parent_client_id). - let (_parent_key, _parent_secret, parent_id) = - app.create_api_client("confidential", None).await; - - // Insert a child client directly in the DB with parent_client_id set. - let child_key = format!("hvc_{}", hex::encode([0xAAu8; 16])); - let child_secret = format!("hvs_{}", hex::encode([0xBBu8; 32])); - let child_secret_hash = hex::encode(sha2::Sha256::digest(child_secret.as_bytes())); - let child_id = uuid::Uuid::new_v4().to_string(); - let now = now_rfc3339(); - - let sql = adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", - app.state.db_backend, - ); - sqlx::query(&sql) - .bind(&child_id) - .bind(&child_key) - .bind(&child_secret_hash) - .bind("child-client") - .bind("https://child-no-nest.example.com/oauth-client-metadata.json") - .bind("https://child-no-nest.example.com") - .bind("[]") - .bind("atproto") - .bind("confidential") - .bind("did:plc:testadmin") - .bind(&now) - .bind(&now) - .bind(&parent_id) - .bind("did:plc:testadmin") - .execute(&app.state.db) - .await - .expect("failed to insert child client"); - - // The endpoint checks parent_client_id IS NULL at step 6, BEFORE DPoP - // validation. So we just need the DPoP headers to exist — they do not - // need to be cryptographically valid. - let body = json!({ - "name": "Grandchild", - "client_id_url": "https://grandchild.example.com/oauth-client-metadata.json", - "client_uri": "https://grandchild.example.com", - "redirect_uris": ["https://grandchild.example.com/callback"], - "scopes": "atproto", - "client_type": "confidential" - }); - - let req = Request::builder() - .method("POST") - .uri("/oauth/api-clients") - .header("host", "127.0.0.1:0") - .header("content-type", "application/json") - .header("x-client-key", &child_key) - .header("authorization", "DPoP fake-token") - .header("dpop", "fake-proof") - .body(Body::from(serde_json::to_vec(&body).unwrap())) - .unwrap(); - - let resp = app.router.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); -} - -#[tokio::test] -#[serial] -#[ignore] -async fn test_self_service_duplicate_client_id_url() { - let app = TestApp::new_with_encryption().await; - let (client_key, dpop_key, access_token) = setup_dpop_session(&app, "did:plc:testadmin").await; - - let shared_url = "https://dup-test.example.com/oauth-client-metadata.json"; - - // First creation should succeed. - let request_url = "http://127.0.0.1:0/oauth/api-clients"; - let proof1 = - generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None).expect("proof 1"); - - let body = json!({ - "name": "First Child", - "client_id_url": shared_url, - "client_uri": "https://dup-test.example.com", - "redirect_uris": ["https://dup-test.example.com/callback"], - "scopes": "atproto", - "client_type": "confidential" - }); - - let req1 = self_service_request(&client_key, &access_token, &proof1, &body); - let resp1 = app.router.clone().oneshot(req1).await.unwrap(); - assert_eq!(resp1.status(), StatusCode::CREATED); - - // Second creation with the same client_id_url should fail with 409. - let proof2 = - generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None).expect("proof 2"); - - let body2 = json!({ - "name": "Second Child", - "client_id_url": shared_url, - "client_uri": "https://dup-test2.example.com", - "redirect_uris": ["https://dup-test2.example.com/callback"], - "scopes": "atproto", - "client_type": "confidential" - }); - - let req2 = self_service_request(&client_key, &access_token, &proof2, &body2); - let resp2 = app.router.clone().oneshot(req2).await.unwrap(); - assert_eq!(resp2.status(), StatusCode::CONFLICT); -} - -#[tokio::test] -#[serial] -#[ignore] -async fn test_self_service_missing_client_key() { - let app = TestApp::new_with_encryption().await; - - let body = json!({ - "name": "No Key Client", - "client_id_url": "https://nokey.example.com/oauth-client-metadata.json", - "client_uri": "https://nokey.example.com", - "redirect_uris": ["https://nokey.example.com/callback"], - "scopes": "atproto", - "client_type": "confidential" - }); - - // No x-client-key header at all. - let req = Request::builder() - .method("POST") - .uri("/oauth/api-clients") - .header("host", "127.0.0.1:0") - .header("content-type", "application/json") - .header("authorization", "DPoP fake-token") - .header("dpop", "fake-proof") - .body(Body::from(serde_json::to_vec(&body).unwrap())) - .unwrap(); - - let resp = app.router.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -#[serial] -#[ignore] -async fn test_self_service_invalid_client_type() { - let app = TestApp::new_with_encryption().await; - - // Step 3 (client_type validation) happens before step 5 (client resolution). - // The request just needs the required headers to exist. - let body = json!({ - "name": "Invalid Type Client", - "client_id_url": "https://badtype.example.com/oauth-client-metadata.json", - "client_uri": "https://badtype.example.com", - "redirect_uris": ["https://badtype.example.com/callback"], - "scopes": "atproto", - "client_type": "invalid" - }); - - let req = Request::builder() - .method("POST") - .uri("/oauth/api-clients") - .header("host", "127.0.0.1:0") - .header("content-type", "application/json") - .header("x-client-key", "hvc_doesnotmatter") - .header("authorization", "DPoP fake-token") - .header("dpop", "fake-proof") - .body(Body::from(serde_json::to_vec(&body).unwrap())) - .unwrap(); - - let resp = app.router.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -#[serial] -#[ignore] -async fn test_self_service_parent_owner_not_in_users() { - let app = TestApp::new_with_encryption().await; - - // Create a parent API client whose created_by DID is NOT in the users table. - let orphan_did = "did:plc:orphan"; - let (client_key, client_secret, _id) = { - use happyview::db::{adapt_sql, now_rfc3339}; - use rand::RngCore; - use sha2::{Digest, Sha256}; - - let mut key_bytes = [0u8; 16]; - rand::rng().fill_bytes(&mut key_bytes); - let client_key = format!("hvc_{}", hex::encode(key_bytes)); - - let mut secret_bytes = [0u8; 32]; - rand::rng().fill_bytes(&mut secret_bytes); - let client_secret = format!("hvs_{}", hex::encode(secret_bytes)); - let secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes())); - - let id = uuid::Uuid::new_v4().to_string(); - let now = now_rfc3339(); - - let sql = adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", - app.state.db_backend, - ); - - sqlx::query(&sql) - .bind(&id) - .bind(&client_key) - .bind(&secret_hash) - .bind("orphan-client") - .bind(format!("https://orphan.example.com/oauth/{}", &id[..8])) - .bind("https://orphan.example.com") - .bind("[]") - .bind("atproto") - .bind("confidential") - .bind(None::) - .bind(orphan_did) - .bind(&now) - .bind(&now) - .execute(&app.state.db) - .await - .expect("failed to create orphan API client"); - - app.state.rate_limiter.register_client_identity( - client_key.clone(), - happyview::rate_limit::ClientIdentity { - secret_hash, - client_uri: "https://orphan.example.com".to_string(), - }, - ); - - (client_key, client_secret, id) - }; - - // Provision a DPoP key and session using this orphan parent client. - let key_req = post_json_with_headers( - "/oauth/dpop-keys", - &json!({}), - vec![ - ("x-client-key", &client_key), - ("x-client-secret", &client_secret), - ], - ); - let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); - assert_eq!(key_resp.status(), StatusCode::CREATED); - let key_body = response_json(key_resp).await; - let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); - let dpop_key = key_body["dpop_key"].clone(); - - let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); - let session_req = post_json_with_headers( - "/oauth/sessions", - &json!({ - "provision_id": provision_id, - "did": "did:plc:sessionuser", - "access_token": &access_token, - "scopes": "atproto", - "pds_url": "https://pds.example.com", - }), - vec![ - ("x-client-key", &client_key), - ("x-client-secret", &client_secret), - ], - ); - let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); - assert_eq!(session_resp.status(), StatusCode::CREATED); - - let request_url = "http://127.0.0.1:0/oauth/api-clients"; - let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) - .expect("failed to generate DPoP proof"); - - let body = json!({ - "name": "Orphan Owner Child", - "client_id_url": "https://orphan-child.example.com/oauth-client-metadata.json", - "client_uri": "https://orphan-child.example.com", - "redirect_uris": ["https://orphan-child.example.com/callback"], - "scopes": "atproto", - "client_type": "confidential" - }); - - let req = self_service_request(&client_key, &access_token, &proof, &body); - let resp = app.router.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); -} - // --------------------------------------------------------------------------- // Cascade: deactivate / delete parent cascades to children // --------------------------------------------------------------------------- #[tokio::test] #[serial] -#[ignore] async fn test_deactivate_parent_cascades_to_children() { + common::require_db!(); let app = TestApp::new().await; // Create parent via admin API @@ -1234,8 +763,8 @@ async fn test_deactivate_parent_cascades_to_children() { #[tokio::test] #[serial] -#[ignore] async fn test_delete_parent_cascades_to_children() { + common::require_db!(); let app = TestApp::new().await; // Create parent via admin API @@ -1332,8 +861,8 @@ async fn test_delete_parent_cascades_to_children() { #[tokio::test] #[serial] -#[ignore] async fn test_list_api_clients_filter_by_parent() { + common::require_db!(); let app = TestApp::new().await; // Create two parents via admin API @@ -1482,8 +1011,8 @@ async fn test_list_api_clients_filter_by_parent() { #[tokio::test] #[serial] -#[ignore] async fn test_list_api_clients_includes_parent_and_owner_fields() { + common::require_db!(); let app = TestApp::new().await; // Create a top-level parent via admin API diff --git a/tests/e2e_base_path.rs b/tests/e2e_base_path.rs index 3370805..68712b9 100644 --- a/tests/e2e_base_path.rs +++ b/tests/e2e_base_path.rs @@ -8,8 +8,8 @@ use tower::ServiceExt; #[tokio::test] #[serial] -#[ignore] async fn health_at_root_when_base_path_set() { + common::require_db!(); let app = common::app::TestApp::new_with_base_path("/hv").await; let resp = app @@ -31,8 +31,8 @@ async fn health_at_root_when_base_path_set() { #[tokio::test] #[serial] -#[ignore] async fn health_not_nested_under_base_path() { + common::require_db!(); let app = common::app::TestApp::new_with_base_path("/hv").await; let resp = app @@ -52,8 +52,8 @@ async fn health_not_nested_under_base_path() { #[tokio::test] #[serial] -#[ignore] async fn config_accessible_under_base_path() { + common::require_db!(); let app = common::app::TestApp::new_with_base_path("/hv").await; let resp = app @@ -74,8 +74,8 @@ async fn config_accessible_under_base_path() { #[tokio::test] #[serial] -#[ignore] async fn config_not_at_root_when_base_path_set() { + common::require_db!(); let app = common::app::TestApp::new_with_base_path("/hv").await; let resp = app @@ -96,8 +96,8 @@ async fn config_not_at_root_when_base_path_set() { #[tokio::test] #[serial] -#[ignore] async fn redirect_includes_base_path_prefix() { + common::require_db!(); let app = common::app::TestApp::new_with_base_path("/hv").await; let resp = app diff --git a/tests/e2e_delegation.rs b/tests/e2e_delegation.rs index f1316eb..c101d9b 100644 --- a/tests/e2e_delegation.rs +++ b/tests/e2e_delegation.rs @@ -296,6 +296,7 @@ async fn setup_session_for_client( #[tokio::test] #[serial] async fn link_account_success() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:owner1"; let target_did = "did:plc:studio1"; @@ -325,6 +326,7 @@ async fn link_account_success() { #[tokio::test] #[serial] async fn link_account_already_linked() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:owner2"; let target_did = "did:plc:studio2"; @@ -348,6 +350,7 @@ async fn link_account_already_linked() { #[tokio::test] #[serial] async fn link_account_self_link_rejected() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let user_did = "did:plc:selflinker"; @@ -368,6 +371,7 @@ async fn link_account_self_link_rejected() { #[tokio::test] #[serial] async fn link_account_no_session_for_target() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:owner3"; let target_did = "did:plc:nosession"; @@ -394,6 +398,7 @@ async fn link_account_no_session_for_target() { #[tokio::test] #[serial] async fn unlink_account_success() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:unlink_owner"; let target_did = "did:plc:unlink_studio"; @@ -430,6 +435,7 @@ async fn unlink_account_success() { #[tokio::test] #[serial] async fn unlink_account_non_owner_rejected() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:unlink_owner2"; let admin_did = "did:plc:unlink_admin2"; @@ -472,6 +478,7 @@ async fn unlink_account_non_owner_rejected() { #[tokio::test] #[serial] async fn add_delegate_success() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:add_owner"; let member_did = "did:plc:add_member"; @@ -518,6 +525,7 @@ async fn add_delegate_success() { #[tokio::test] #[serial] async fn add_delegate_owner_role_rejected() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:add_owner2"; let target_did = "did:plc:add_studio2"; @@ -540,6 +548,7 @@ async fn add_delegate_owner_role_rejected() { #[tokio::test] #[serial] async fn add_delegate_already_exists() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:add_owner3"; let member_did = "did:plc:add_member3"; @@ -576,6 +585,7 @@ async fn add_delegate_already_exists() { #[tokio::test] #[serial] async fn add_delegate_member_cannot_add() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:add_owner4"; let member_did = "did:plc:add_member4"; @@ -618,6 +628,7 @@ async fn add_delegate_member_cannot_add() { #[tokio::test] #[serial] async fn remove_delegate_success() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:rm_owner"; let member_did = "did:plc:rm_member"; @@ -669,6 +680,7 @@ async fn remove_delegate_success() { #[tokio::test] #[serial] async fn remove_delegate_cannot_remove_owner() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:rm_owner2"; let target_did = "did:plc:rm_studio2"; @@ -691,6 +703,7 @@ async fn remove_delegate_cannot_remove_owner() { #[tokio::test] #[serial] async fn remove_delegate_admin_cannot_remove_admin() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:rm_owner3"; let admin1_did = "did:plc:rm_admin3a"; @@ -742,6 +755,7 @@ async fn remove_delegate_admin_cannot_remove_admin() { #[tokio::test] #[serial] async fn list_accounts_returns_linked_accounts() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:list_owner"; let studio1 = "did:plc:list_studio1"; @@ -781,6 +795,7 @@ async fn list_accounts_returns_linked_accounts() { #[tokio::test] #[serial] async fn list_accounts_empty() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let user_did = "did:plc:no_accounts"; @@ -807,6 +822,7 @@ async fn list_accounts_empty() { #[tokio::test] #[serial] async fn get_account_not_a_delegate() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:ga_owner"; let target_did = "did:plc:ga_studio"; @@ -839,6 +855,7 @@ async fn get_account_not_a_delegate() { #[tokio::test] #[serial] async fn list_delegates_member_cannot_list() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:ld_owner"; let member_did = "did:plc:ld_member"; @@ -969,6 +986,7 @@ async fn update_session_pds_url(app: &common::app::TestApp, user_did: &str, pds_ #[tokio::test] #[serial] async fn delegated_write_non_delegate_rejected() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; seed_procedure_lexicon(&app).await; @@ -995,6 +1013,7 @@ async fn delegated_write_non_delegate_rejected() { #[tokio::test] #[serial] async fn delegated_write_member_rejected() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; seed_procedure_lexicon(&app).await; @@ -1039,6 +1058,7 @@ async fn delegated_write_member_rejected() { #[tokio::test] #[serial] async fn delegated_write_owner_success() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; seed_procedure_lexicon(&app).await; @@ -1086,6 +1106,7 @@ async fn delegated_write_owner_success() { #[tokio::test] #[serial] async fn delegated_write_admin_success() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; seed_procedure_lexicon(&app).await; @@ -1148,6 +1169,7 @@ async fn delegated_write_admin_success() { #[tokio::test] #[serial] async fn admin_can_add_delegate() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:acd_owner"; @@ -1211,6 +1233,7 @@ async fn admin_can_add_delegate() { #[tokio::test] #[serial] async fn admin_can_remove_member() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:arm_owner"; @@ -1276,6 +1299,7 @@ async fn admin_can_remove_member() { #[tokio::test] #[serial] async fn admin_can_list_delegates() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:ald_owner"; @@ -1319,6 +1343,7 @@ async fn admin_can_list_delegates() { #[tokio::test] #[serial] async fn member_can_view_account() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:mva_owner"; @@ -1362,6 +1387,7 @@ async fn member_can_view_account() { #[tokio::test] #[serial] async fn owner_can_remove_admin() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:ora_owner"; @@ -1419,6 +1445,7 @@ async fn owner_can_remove_admin() { #[tokio::test] #[serial] async fn cross_client_get_account_rejected() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:xc_ga_owner"; let target_did = "did:plc:xc_ga_studio"; @@ -1444,6 +1471,7 @@ async fn cross_client_get_account_rejected() { #[tokio::test] #[serial] async fn cross_client_add_delegate_rejected() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:xc_ad_owner"; let target_did = "did:plc:xc_ad_studio"; @@ -1467,6 +1495,7 @@ async fn cross_client_add_delegate_rejected() { #[tokio::test] #[serial] async fn cross_client_delegated_write_rejected() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; seed_procedure_lexicon(&app).await; @@ -1505,6 +1534,7 @@ async fn cross_client_delegated_write_rejected() { #[tokio::test] #[serial] async fn cross_client_list_accounts_isolated() { + common::require_db!(); let app = common::app::TestApp::new_with_encryption().await; let owner_did = "did:plc:xc_la_owner"; let studio1 = "did:plc:xc_la_studio1"; diff --git a/tests/e2e_domains.rs b/tests/e2e_domains.rs index 6ec08e3..141c034 100644 --- a/tests/e2e_domains.rs +++ b/tests/e2e_domains.rs @@ -96,8 +96,8 @@ async fn seed_domain(app: &TestApp, id: &str, url: &str, is_primary: bool) { #[tokio::test] #[serial] -#[ignore] async fn domains_list_returns_seeded_domain() { + common::require_db!(); let app = TestApp::new().await; seed_domain(&app, "primary-id", "http://127.0.0.1:0", true).await; @@ -119,8 +119,8 @@ async fn domains_list_returns_seeded_domain() { #[tokio::test] #[serial] -#[ignore] async fn domains_create_and_delete() { + common::require_db!(); let app = TestApp::new().await; seed_domain(&app, "primary-id", "http://127.0.0.1:0", true).await; @@ -175,8 +175,8 @@ async fn domains_create_and_delete() { #[tokio::test] #[serial] -#[ignore] async fn domains_duplicate_url_returns_400() { + common::require_db!(); let app = TestApp::new().await; seed_domain(&app, "primary-id", "http://127.0.0.1:0", true).await; @@ -203,8 +203,8 @@ async fn domains_duplicate_url_returns_400() { #[tokio::test] #[serial] -#[ignore] async fn domains_cannot_delete_primary() { + common::require_db!(); let app = TestApp::new().await; seed_domain(&app, "primary-id", "http://127.0.0.1:0", true).await; @@ -229,8 +229,8 @@ async fn domains_cannot_delete_primary() { #[tokio::test] #[serial] -#[ignore] async fn domains_set_primary() { + common::require_db!(); let app = TestApp::new().await; seed_domain(&app, "id-a", "http://127.0.0.1:0", true).await; @@ -278,8 +278,8 @@ async fn domains_set_primary() { #[tokio::test] #[serial] -#[ignore] async fn unknown_host_returns_421_on_domain_scoped_routes() { + common::require_db!(); let app = TestApp::new().await; // No domains seeded — cache is empty @@ -300,8 +300,8 @@ async fn unknown_host_returns_421_on_domain_scoped_routes() { #[tokio::test] #[serial] -#[ignore] async fn health_check_bypasses_domain_resolution() { + common::require_db!(); let app = TestApp::new().await; // No domains seeded — cache is empty @@ -322,8 +322,8 @@ async fn health_check_bypasses_domain_resolution() { #[tokio::test] #[serial] -#[ignore] async fn domain_scoped_route_works_with_known_host() { + common::require_db!(); let app = TestApp::new().await; // Domain.host() for "http://localhost:3000" is "localhost:3000" diff --git a/tests/e2e_feature_flags.rs b/tests/e2e_feature_flags.rs index 617d7c9..6718920 100644 --- a/tests/e2e_feature_flags.rs +++ b/tests/e2e_feature_flags.rs @@ -53,8 +53,8 @@ fn admin_delete( #[tokio::test] #[serial] -#[ignore] async fn space_routes_blocked_when_flag_disabled() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -76,8 +76,8 @@ async fn space_routes_blocked_when_flag_disabled() { #[tokio::test] #[serial] -#[ignore] async fn space_routes_allowed_after_enabling_flag() { + common::require_db!(); let app = TestApp::new().await; // Enable the feature flag @@ -116,8 +116,8 @@ async fn space_routes_allowed_after_enabling_flag() { #[tokio::test] #[serial] -#[ignore] async fn space_routes_blocked_again_after_disabling_flag() { + common::require_db!(); let app = TestApp::new().await; // Enable @@ -165,8 +165,8 @@ async fn space_routes_blocked_again_after_disabling_flag() { #[tokio::test] #[serial] -#[ignore] async fn admin_feature_flags_lists_flags() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -192,8 +192,8 @@ async fn admin_feature_flags_lists_flags() { #[tokio::test] #[serial] -#[ignore] async fn admin_feature_flags_reflects_enabled_state() { + common::require_db!(); let app = TestApp::new().await; // Enable the flag @@ -228,8 +228,8 @@ async fn admin_feature_flags_reflects_enabled_state() { #[tokio::test] #[serial] -#[ignore] async fn config_endpoint_includes_features() { + common::require_db!(); let app = TestApp::new().await; // Default: spaces disabled @@ -282,8 +282,8 @@ async fn config_endpoint_includes_features() { #[tokio::test] #[serial] -#[ignore] async fn admin_feature_flags_requires_auth() { + common::require_db!(); let app = TestApp::new().await; let resp = app diff --git a/tests/e2e_health.rs b/tests/e2e_health.rs index df70b2b..f97690c 100644 --- a/tests/e2e_health.rs +++ b/tests/e2e_health.rs @@ -8,8 +8,8 @@ use tower::ServiceExt; #[tokio::test] #[serial] -#[ignore] async fn health_returns_200_ok() { + common::require_db!(); let app = common::app::TestApp::new().await; let resp = app diff --git a/tests/e2e_labelers.rs b/tests/e2e_labelers.rs index 9edd60e..0c4b824 100644 --- a/tests/e2e_labelers.rs +++ b/tests/e2e_labelers.rs @@ -76,8 +76,8 @@ fn admin_delete( #[tokio::test] #[serial] -#[ignore] async fn labeler_add_returns_201() { + common::require_db!(); let app = TestApp::new().await; let body = json!({ "did": "did:plc:labeler1" }); @@ -94,8 +94,8 @@ async fn labeler_add_returns_201() { #[tokio::test] #[serial] -#[ignore] async fn labeler_add_upsert_reactivates() { + common::require_db!(); let app = TestApp::new().await; let body = json!({ "did": "did:plc:labeler1" }); @@ -151,8 +151,8 @@ async fn labeler_add_upsert_reactivates() { #[tokio::test] #[serial] -#[ignore] async fn labeler_list_empty() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -169,8 +169,8 @@ async fn labeler_list_empty() { #[tokio::test] #[serial] -#[ignore] async fn labeler_list_returns_added() { + common::require_db!(); let app = TestApp::new().await; app.router @@ -217,8 +217,8 @@ async fn labeler_list_returns_added() { #[tokio::test] #[serial] -#[ignore] async fn labeler_update_status() { + common::require_db!(); let app = TestApp::new().await; app.router @@ -261,8 +261,8 @@ async fn labeler_update_status() { #[tokio::test] #[serial] -#[ignore] async fn labeler_update_not_found() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -285,8 +285,8 @@ async fn labeler_update_not_found() { #[tokio::test] #[serial] -#[ignore] async fn labeler_delete_returns_204() { + common::require_db!(); let app = TestApp::new().await; app.router @@ -327,8 +327,8 @@ async fn labeler_delete_returns_204() { #[tokio::test] #[serial] -#[ignore] async fn labeler_delete_not_found() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -346,8 +346,8 @@ async fn labeler_delete_not_found() { #[tokio::test] #[serial] -#[ignore] async fn labeler_delete_removes_labels() { + common::require_db!(); let app = TestApp::new().await; let backend = app.state.db_backend; @@ -405,8 +405,8 @@ async fn labeler_delete_removes_labels() { #[tokio::test] #[serial] -#[ignore] async fn labeler_no_auth_returns_401() { + common::require_db!(); let app = TestApp::new().await; let resp = app diff --git a/tests/e2e_network_lexicons.rs b/tests/e2e_network_lexicons.rs index 5a889cf..0d48304 100644 --- a/tests/e2e_network_lexicons.rs +++ b/tests/e2e_network_lexicons.rs @@ -72,8 +72,8 @@ async fn seed_network_lexicon(app: &TestApp, nsid: &str, authority_did: &str) { #[tokio::test] #[serial] -#[ignore] async fn network_lexicon_list_empty() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -90,8 +90,8 @@ async fn network_lexicon_list_empty() { #[tokio::test] #[serial] -#[ignore] async fn network_lexicon_list_returns_seeded() { + common::require_db!(); let app = TestApp::new().await; seed_network_lexicon(&app, "games.gamesgamesgamesgames.game", "did:plc:authority").await; @@ -113,8 +113,8 @@ async fn network_lexicon_list_returns_seeded() { #[tokio::test] #[serial] -#[ignore] async fn network_lexicon_delete_removes_tracking_and_lexicon() { + common::require_db!(); let app = TestApp::new().await; let backend = app.state.db_backend; @@ -149,8 +149,8 @@ async fn network_lexicon_delete_removes_tracking_and_lexicon() { #[tokio::test] #[serial] -#[ignore] async fn network_lexicon_delete_not_found() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -168,8 +168,8 @@ async fn network_lexicon_delete_not_found() { #[tokio::test] #[serial] -#[ignore] async fn network_lexicon_no_auth_returns_401() { + common::require_db!(); let app = TestApp::new().await; let resp = app diff --git a/tests/e2e_proxy_config.rs b/tests/e2e_proxy_config.rs index c985fa3..8fe8d4d 100644 --- a/tests/e2e_proxy_config.rs +++ b/tests/e2e_proxy_config.rs @@ -41,8 +41,8 @@ fn admin_put( #[tokio::test] #[serial] -#[ignore] async fn get_proxy_config_returns_default() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -60,8 +60,8 @@ async fn get_proxy_config_returns_default() { #[tokio::test] #[serial] -#[ignore] async fn put_and_get_allowlist() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -98,8 +98,8 @@ async fn put_and_get_allowlist() { #[tokio::test] #[serial] -#[ignore] async fn disabled_mode_clears_nsids() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -132,8 +132,8 @@ async fn disabled_mode_clears_nsids() { #[tokio::test] #[serial] -#[ignore] async fn invalid_mode_rejected() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -155,8 +155,8 @@ async fn invalid_mode_rejected() { #[tokio::test] #[serial] -#[ignore] async fn invalid_nsid_rejected() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -178,8 +178,8 @@ async fn invalid_nsid_rejected() { #[tokio::test] #[serial] -#[ignore] async fn requires_auth() { + common::require_db!(); let app = TestApp::new().await; let resp = app diff --git a/tests/e2e_settings.rs b/tests/e2e_settings.rs index 030f1b9..3586e01 100644 --- a/tests/e2e_settings.rs +++ b/tests/e2e_settings.rs @@ -61,8 +61,8 @@ fn admin_delete( #[tokio::test] #[serial] -#[ignore] async fn settings_crud() { + common::require_db!(); let app = TestApp::new().await; // PUT a setting @@ -134,8 +134,8 @@ async fn settings_crud() { #[tokio::test] #[serial] -#[ignore] async fn settings_requires_auth() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -155,8 +155,8 @@ async fn settings_requires_auth() { #[tokio::test] #[serial] -#[ignore] async fn logo_upload_and_serve() { + common::require_db!(); let app = TestApp::new().await; let boundary = "----testboundary"; @@ -260,8 +260,8 @@ async fn logo_upload_and_serve() { #[tokio::test] #[serial] -#[ignore] async fn client_metadata_includes_settings() { + common::require_db!(); let app = TestApp::new().await; // PUT app_name setting @@ -306,8 +306,8 @@ async fn client_metadata_includes_settings() { #[tokio::test] #[serial] -#[ignore] async fn client_metadata_client_id_matches_path() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -332,46 +332,8 @@ async fn client_metadata_client_id_matches_path() { #[tokio::test] #[serial] -#[ignore] -async fn client_metadata_scope_overridden_by_setting() { - let app = TestApp::new().await; - - let resp = app - .router - .clone() - .oneshot(admin_put( - "/admin/settings/oauth_scopes", - app.admin_cookie(), - &json!({ "value": "atproto include:com.example.foo\n include:com.example.bar" }), - )) - .await - .unwrap(); - assert!(resp.status().is_success()); - - let resp = app - .router - .clone() - .oneshot( - Request::builder() - .uri("/oauth-client-metadata.json") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let json = json_body(resp).await; - assert_eq!( - json["scope"], "atproto include:com.example.foo include:com.example.bar", - "expected normalized scope string, got {:?}", - json["scope"] - ); -} - -#[tokio::test] -#[serial] -#[ignore] async fn client_metadata_client_uri_overridden_by_setting() { + common::require_db!(); let app = TestApp::new().await; let resp = app diff --git a/tests/e2e_xrpc.rs b/tests/e2e_xrpc.rs index 172c3de..95d0268 100644 --- a/tests/e2e_xrpc.rs +++ b/tests/e2e_xrpc.rs @@ -109,8 +109,8 @@ async fn seed_record(app: &TestApp, uri: &str, did: &str, collection: &str, reco #[tokio::test] #[serial] -#[ignore] async fn profile_no_auth_returns_401() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -128,58 +128,14 @@ async fn profile_no_auth_returns_401() { assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } -#[tokio::test] -#[serial] -#[ignore] -async fn profile_with_mocked_services_returns_200() { - let app = TestApp::new().await; - let did = &app.admin_did; - - // Mock PLC directory - Mock::given(method("GET")) - .and(path(format!("/{did}"))) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(fixtures::did_document(did, &app.mock_server.uri())), - ) - .mount(&app.mock_server) - .await; - - // Mock PDS getRecord for profile - Mock::given(method("GET")) - .and(path("/xrpc/com.atproto.repo.getRecord")) - .respond_with(ResponseTemplate::new(200).set_body_json(fixtures::profile_record())) - .mount(&app.mock_server) - .await; - - let cookie = app.admin_cookie(); - let resp = app - .router - .clone() - .oneshot( - Request::builder() - .uri("/xrpc/app.bsky.actor.getProfile") - .header(cookie.0, cookie.1) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), StatusCode::OK); - let json = json_body(resp).await; - assert_eq!(json["did"], did.as_str()); - assert_eq!(json["displayName"], "Test User"); -} - // --------------------------------------------------------------------------- // Catch-all GET (queries) // --------------------------------------------------------------------------- #[tokio::test] #[serial] -#[ignore] async fn xrpc_get_unknown_method_proxies_and_returns_bad_gateway() { + common::require_db!(); let app = TestApp::new().await; let resp = app @@ -188,6 +144,7 @@ async fn xrpc_get_unknown_method_proxies_and_returns_bad_gateway() { .oneshot( Request::builder() .uri("/xrpc/nonexistent.method") + .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) @@ -201,8 +158,8 @@ async fn xrpc_get_unknown_method_proxies_and_returns_bad_gateway() { #[tokio::test] #[serial] -#[ignore] async fn xrpc_get_non_query_returns_400() { + common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await; @@ -213,6 +170,7 @@ async fn xrpc_get_non_query_returns_400() { .oneshot( Request::builder() .uri("/xrpc/games.gamesgamesgamesgames.game") + .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) @@ -224,8 +182,8 @@ async fn xrpc_get_non_query_returns_400() { #[tokio::test] #[serial] -#[ignore] async fn xrpc_get_single_record_by_uri() { + common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await; @@ -253,6 +211,7 @@ async fn xrpc_get_single_record_by_uri() { "/xrpc/games.gamesgamesgamesgames.listGames?uri={}", urlencoding::encode(uri) )) + .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) @@ -267,8 +226,8 @@ async fn xrpc_get_single_record_by_uri() { #[tokio::test] #[serial] -#[ignore] async fn xrpc_get_record_not_found() { + common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await; @@ -277,6 +236,7 @@ async fn xrpc_get_record_not_found() { .oneshot( Request::builder() .uri("/xrpc/games.gamesgamesgamesgames.listGames?uri=at%3A%2F%2Fdid%3Aplc%3Anone%2Fgames.gamesgamesgamesgames.game%2Fmissing") + .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) @@ -288,8 +248,8 @@ async fn xrpc_get_record_not_found() { #[tokio::test] #[serial] -#[ignore] async fn xrpc_get_list_with_pagination() { + common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await; @@ -326,6 +286,7 @@ async fn xrpc_get_list_with_pagination() { .oneshot( Request::builder() .uri("/xrpc/games.gamesgamesgamesgames.listGames?limit=2") + .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) @@ -347,6 +308,7 @@ async fn xrpc_get_list_with_pagination() { .uri(format!( "/xrpc/games.gamesgamesgamesgames.listGames?limit=2&cursor={cursor}" )) + .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) @@ -361,8 +323,8 @@ async fn xrpc_get_list_with_pagination() { #[tokio::test] #[serial] -#[ignore] async fn xrpc_get_list_filtered_by_did() { + common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await; @@ -402,6 +364,7 @@ async fn xrpc_get_list_filtered_by_did() { .oneshot( Request::builder() .uri("/xrpc/games.gamesgamesgamesgames.listGames?did=did:plc:a") + .header("x-client-key", "hvc_test") .body(Body::empty()) .unwrap(), ) @@ -421,8 +384,8 @@ async fn xrpc_get_list_filtered_by_did() { #[tokio::test] #[serial] -#[ignore] async fn xrpc_post_no_auth_returns_401() { + common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await; @@ -445,37 +408,8 @@ async fn xrpc_post_no_auth_returns_401() { #[tokio::test] #[serial] -#[ignore] -async fn xrpc_post_non_procedure_returns_400() { - let app = TestApp::new().await; - seed_lexicons(&app).await; - - // Use cookie auth for did:plc:test so auth passes - let (cookie_name, cookie_val) = - common::auth::admin_cookie_header("did:plc:test", &app.state.cookie_key); - - let resp = app - .router - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/xrpc/games.gamesgamesgamesgames.listGames") - .header(cookie_name, cookie_val) - .header("content-type", "application/json") - .body(Body::from(b"{}".to_vec())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -#[serial] -#[ignore] async fn xrpc_delete_procedure_removes_record() { + common::require_db!(); let app = TestApp::new().await; seed_lexicons(&app).await; let backend = app.state.db_backend; @@ -576,8 +510,8 @@ async fn xrpc_delete_procedure_removes_record() { #[tokio::test] #[serial] -#[ignore] async fn upload_lexicon_with_invalid_action_returns_400() { + common::require_db!(); let app = TestApp::new().await; let resp = app diff --git a/tests/lua_atproto_api.rs b/tests/lua_atproto_api.rs index 94a5b64..4946ced 100644 --- a/tests/lua_atproto_api.rs +++ b/tests/lua_atproto_api.rs @@ -84,7 +84,9 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> std::sync::Arc::new(oauth), )), oauth_state_store: happyview::auth::oauth_store::DbStateStore::new(pool.clone(), backend), - cookie_key: axum_extra::extract::cookie::Key::derive_from(b"test-secret"), + cookie_key: axum_extra::extract::cookie::Key::derive_from( + b"test-secret-that-is-at-least-32-bytes-long", + ), plugin_registry: std::sync::Arc::new(happyview::plugin::PluginRegistry::new()), wasm_runtime: std::sync::Arc::new( happyview::plugin::WasmRuntime::new().expect("wasm runtime"), @@ -170,8 +172,8 @@ async fn seed_label( #[tokio::test] #[serial] -#[ignore] async fn get_labels_returns_external_labels() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -217,8 +219,8 @@ async fn get_labels_returns_external_labels() { #[tokio::test] #[serial] -#[ignore] async fn get_labels_filters_expired() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -266,8 +268,8 @@ async fn get_labels_filters_expired() { #[tokio::test] #[serial] -#[ignore] async fn get_labels_includes_self_labels() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -309,8 +311,8 @@ async fn get_labels_includes_self_labels() { #[tokio::test] #[serial] -#[ignore] async fn get_labels_empty_for_unlabeled_record() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -346,8 +348,8 @@ async fn get_labels_empty_for_unlabeled_record() { #[tokio::test] #[serial] -#[ignore] async fn get_labels_batch_returns_labels_per_uri() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -408,8 +410,8 @@ async fn get_labels_batch_returns_labels_per_uri() { #[tokio::test] #[serial] -#[ignore] async fn get_labels_batch_empty_for_no_labels() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -439,8 +441,8 @@ async fn get_labels_batch_empty_for_no_labels() { #[tokio::test] #[serial] -#[ignore] async fn label_negation_removes_row() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -498,8 +500,8 @@ async fn label_negation_removes_row() { #[tokio::test] #[serial] -#[ignore] async fn label_upsert_is_idempotent() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs index 0e2ce49..1eb8de3 100644 --- a/tests/lua_db_api.rs +++ b/tests/lua_db_api.rs @@ -87,7 +87,9 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> std::sync::Arc::new(oauth), )), oauth_state_store: happyview::auth::oauth_store::DbStateStore::new(pool.clone(), backend), - cookie_key: axum_extra::extract::cookie::Key::derive_from(b"test-secret"), + cookie_key: axum_extra::extract::cookie::Key::derive_from( + b"test-secret-that-is-at-least-32-bytes-long", + ), plugin_registry: std::sync::Arc::new(happyview::plugin::PluginRegistry::new()), wasm_runtime: std::sync::Arc::new( happyview::plugin::WasmRuntime::new().expect("wasm runtime"), @@ -165,8 +167,8 @@ fn setup_lua(state: &AppState) -> Lua { #[tokio::test] #[serial] -#[ignore] async fn db_get_returns_record() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -189,8 +191,8 @@ async fn db_get_returns_record() { #[tokio::test] #[serial] -#[ignore] async fn db_get_returns_nil_for_missing() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -208,8 +210,8 @@ async fn db_get_returns_nil_for_missing() { #[tokio::test] #[serial] -#[ignore] async fn db_query_returns_records() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -229,8 +231,8 @@ async fn db_query_returns_records() { #[tokio::test] #[serial] -#[ignore] async fn db_query_respects_limit() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -254,8 +256,8 @@ async fn db_query_respects_limit() { #[tokio::test] #[serial] -#[ignore] async fn db_count_returns_total() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -274,8 +276,8 @@ async fn db_count_returns_total() { #[tokio::test] #[serial] -#[ignore] async fn db_count_with_did_filter() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -294,8 +296,8 @@ async fn db_count_with_did_filter() { #[tokio::test] #[serial] -#[ignore] async fn db_search_finds_matching() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -318,8 +320,8 @@ async fn db_search_finds_matching() { #[tokio::test] #[serial] -#[ignore] async fn db_raw_select_works() { + common::require_db!(); let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; diff --git a/tests/plugin_logging.rs b/tests/plugin_logging.rs index f8033ad..99eb121 100644 --- a/tests/plugin_logging.rs +++ b/tests/plugin_logging.rs @@ -14,6 +14,7 @@ use serial_test::serial; /// The log() function spawns fire-and-forget tasks; we need to yield /// until they complete before querying. async fn flush_spawned_tasks() { + common::require_db!(); for _ in 0..20 { tokio::task::yield_now().await; tokio::time::sleep(tokio::time::Duration::from_millis(25)).await; @@ -23,6 +24,7 @@ async fn flush_spawned_tasks() { #[tokio::test] #[serial] async fn plugin_log_writes_all_four_levels_to_event_logs() { + common::require_db!(); let pool = test_pool().await; let backend = test_backend(); truncate_all(&pool).await; @@ -103,6 +105,7 @@ async fn plugin_log_writes_all_four_levels_to_event_logs() { #[tokio::test] #[serial] async fn plugin_log_with_none_db_does_not_write_event_log() { + common::require_db!(); let pool = test_pool().await; let backend = test_backend(); truncate_all(&pool).await; -- 2.51.2 From 572ca514608fa65dbeb2a994b645a538d2912937 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 11 May 2026 19:25:16 -0500 Subject: [PATCH 56/56] fix: remove forced HappyView redirect URI from API client creation Signed-off-by: Trezy --- .../app/dashboard/settings/api-clients/page.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/web/src/app/dashboard/settings/api-clients/page.tsx b/web/src/app/dashboard/settings/api-clients/page.tsx index 086abe8..15cfbe8 100644 --- a/web/src/app/dashboard/settings/api-clients/page.tsx +++ b/web/src/app/dashboard/settings/api-clients/page.tsx @@ -260,7 +260,6 @@ export default function ApiClientsPage() { function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { const config = useConfig(); - const happyviewCallbackUri = `${config.public_url.replace(/\/$/, "")}/auth/callback`; const [clientType, setClientType] = useState<"confidential" | "public">( "confidential", @@ -312,8 +311,7 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { async function handleCreate() { setError(null); - const extraUris = redirectUris.map((u) => u.trim()).filter(Boolean); - const allUris = [happyviewCallbackUri, ...extraUris]; + const allUris = redirectUris.map((u) => u.trim()).filter(Boolean); const extraScopes = scopes.map((s) => s.trim()).filter(Boolean); const allScopes = ["atproto", ...extraScopes].join(" "); @@ -550,7 +548,6 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { values={redirectUris} onChange={setRedirectUris} placeholder="https://example.com/auth/callback" - readonlyValues={[happyviewCallbackUri]} /> {clientType === "public" && ( @@ -682,12 +679,9 @@ function EditApiClientDialog({ onSuccess: () => void; }) { const config = useConfig(); - const happyviewCallbackUri = `${config.public_url.replace(/\/$/, "")}/auth/callback`; - // Parse existing redirect URIs: separate the HappyView callback from user-added ones function parseRedirectUris(uris: string[]): string[] { - const filtered = uris.filter((u) => u !== happyviewCallbackUri); - return filtered.length > 0 ? [...filtered, ""] : [""]; + return uris.length > 0 ? [...uris, ""] : [""]; } // Parse existing scopes: separate "atproto" from user-added ones @@ -760,8 +754,7 @@ function EditApiClientDialog({ } setSaving(true); try { - const extraUris = redirectUris.map((u) => u.trim()).filter(Boolean); - const allUris = [happyviewCallbackUri, ...extraUris]; + const allUris = redirectUris.map((u) => u.trim()).filter(Boolean); const extraScopes = scopes.map((s) => s.trim()).filter(Boolean); const allScopes = ["atproto", ...extraScopes].join(" "); @@ -843,7 +836,6 @@ function EditApiClientDialog({ values={redirectUris} onChange={setRedirectUris} placeholder="https://example.com/auth/callback" - readonlyValues={[happyviewCallbackUri]} /> {client.client_type === "public" && (