diff --git a/src/webapp/app/api/auth/sync/route.ts b/src/webapp/app/api/auth/sync/route.ts new file mode 100644 index 00000000..1f6d3e2d --- /dev/null +++ b/src/webapp/app/api/auth/sync/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const { accessToken, refreshToken } = await request.json(); + + if (!accessToken || !refreshToken) { + return NextResponse.json( + { error: 'Missing tokens' }, + { status: 400 } + ); + } + + // Create response with cookies + const response = NextResponse.json({ synced: true }); + + // Set httpOnly cookies + response.cookies.set('accessToken', accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 900, // 15 minutes + path: '/', + }); + + response.cookies.set('refreshToken', refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 604800, // 7 days + path: '/', + }); + + return response; + } catch (error) { + console.error('Token sync error:', error); + return NextResponse.json( + { error: 'Failed to sync tokens' }, + { status: 500 } + ); + } +} diff --git a/src/webapp/app/auth/complete/page.tsx b/src/webapp/app/auth/complete/page.tsx index 3136740a..fdf62fd7 100644 --- a/src/webapp/app/auth/complete/page.tsx +++ b/src/webapp/app/auth/complete/page.tsx @@ -58,7 +58,7 @@ function AuthCompleteContent() { if (accessToken && refreshToken) { // Store tokens using the auth context function - setTokens(accessToken, refreshToken); + await setTokens(accessToken, refreshToken); // Check if extension tokens were requested if (ExtensionService.isExtensionTokensRequested()) { diff --git a/src/webapp/features/auth/components/loginForm/LoginForm.tsx b/src/webapp/features/auth/components/loginForm/LoginForm.tsx index 13c8ba86..193c8de8 100644 --- a/src/webapp/features/auth/components/loginForm/LoginForm.tsx +++ b/src/webapp/features/auth/components/loginForm/LoginForm.tsx @@ -116,7 +116,7 @@ export default function LoginForm() { appPassword: form.values.appPassword, }); - setTokens(accessToken, refreshToken); + await setTokens(accessToken, refreshToken); if (isExtensionLogin) { await handleExtensionTokenGeneration(); diff --git a/src/webapp/hooks/useAuth.tsx b/src/webapp/hooks/useAuth.tsx index 4d4ebff4..9e7dde0f 100644 --- a/src/webapp/hooks/useAuth.tsx +++ b/src/webapp/hooks/useAuth.tsx @@ -21,7 +21,7 @@ interface AuthContextType { login: (handle: string) => Promise<{ authUrl: string }>; logout: () => Promise; refreshTokens: () => Promise; - setTokens: (accessToken: string, refreshToken: string) => void; + setTokens: (accessToken: string, refreshToken: string) => Promise; } const AuthContext = createContext(undefined); @@ -82,7 +82,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const { accessToken: newAccessToken, refreshToken: newRefreshToken } = await apiClient.refreshAccessToken({ refreshToken }); - setTokens(newAccessToken, newRefreshToken); + await setTokens(newAccessToken, newRefreshToken); return true; } catch (error) { console.error('Token refresh failed:', error); @@ -177,8 +177,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { [createApiClient], ); - const setTokens = useCallback((accessToken: string, refreshToken: string) => { - // Store tokens + const setTokens = useCallback(async (accessToken: string, refreshToken: string) => { + // Store tokens in localStorage localStorage.setItem('accessToken', accessToken); localStorage.setItem('refreshToken', refreshToken); @@ -186,6 +186,22 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { setAccessToken(accessToken); setRefreshToken(refreshToken); setIsAuthenticated(true); + + // Sync tokens with server-side cookies + try { + await fetch('/api/auth/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + accessToken, + refreshToken + }), + credentials: 'include' // Important for cookie handling + }); + } catch (error) { + console.error('Failed to sync tokens with server:', error); + // Don't throw error - localStorage tokens are still valid + } }, []); return ( -- 2.51.2 From 009cf758632450e198385997c5e8f8d7aa6bf30a Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 19 Sep 2025 18:28:20 -0700 Subject: [PATCH 2/8] fix: resolve async operation in useEffect for auth complete page Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- src/webapp/app/auth/complete/page.tsx | 104 +++++++++++++------------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/src/webapp/app/auth/complete/page.tsx b/src/webapp/app/auth/complete/page.tsx index fdf62fd7..042c6e4e 100644 --- a/src/webapp/app/auth/complete/page.tsx +++ b/src/webapp/app/auth/complete/page.tsx @@ -15,61 +15,65 @@ function AuthCompleteContent() { const { setTokens } = useAuth(); useEffect(() => { - // Create API client instance - const apiClient = new ApiClient( - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', - () => getAccessToken(), - ); - - const accessToken = searchParams.get('accessToken'); - const refreshToken = searchParams.get('refreshToken'); - const error = searchParams.get('error'); - - // Clear the URL parameters for security - const cleanUrl = '/'; - window.history.replaceState({}, document.title, cleanUrl); - - if (error) { - console.error('Authentication error:', error); - router.push(`/login?error=${encodeURIComponent(error)}`); - return; - } - - const handleExtensionTokenGeneration = async () => { - try { - setMessage('Generating extension tokens...'); - - const tokens = await apiClient.generateExtensionTokens(); - await ExtensionService.sendTokensToExtension(tokens); - ExtensionService.clearExtensionTokensRequested(); - - setMessage('Extension tokens generated successfully!'); - - // Redirect to extension success page after successful extension token generation - setTimeout(() => router.push('/extension/auth/complete'), 1000); - } catch (extensionError: any) { - console.error('Failed to generate extension tokens:', extensionError); - ExtensionService.clearExtensionTokensRequested(); - - // Redirect to extension error page - router.push('/extension/auth/error'); + const handleAuth = async () => { + // Create API client instance + const apiClient = new ApiClient( + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', + () => getAccessToken(), + ); + + const accessToken = searchParams.get('accessToken'); + const refreshToken = searchParams.get('refreshToken'); + const error = searchParams.get('error'); + + // Clear the URL parameters for security + const cleanUrl = '/'; + window.history.replaceState({}, document.title, cleanUrl); + + if (error) { + console.error('Authentication error:', error); + router.push(`/login?error=${encodeURIComponent(error)}`); + return; } - }; - if (accessToken && refreshToken) { - // Store tokens using the auth context function - await setTokens(accessToken, refreshToken); + const handleExtensionTokenGeneration = async () => { + try { + setMessage('Generating extension tokens...'); + + const tokens = await apiClient.generateExtensionTokens(); + await ExtensionService.sendTokensToExtension(tokens); + ExtensionService.clearExtensionTokensRequested(); + + setMessage('Extension tokens generated successfully!'); + + // Redirect to extension success page after successful extension token generation + setTimeout(() => router.push('/extension/auth/complete'), 1000); + } catch (extensionError: any) { + console.error('Failed to generate extension tokens:', extensionError); + ExtensionService.clearExtensionTokensRequested(); - // Check if extension tokens were requested - if (ExtensionService.isExtensionTokensRequested()) { - handleExtensionTokenGeneration(); + // Redirect to extension error page + router.push('/extension/auth/error'); + } + }; + + if (accessToken && refreshToken) { + // Store tokens using the auth context function + await setTokens(accessToken, refreshToken); + + // Check if extension tokens were requested + if (ExtensionService.isExtensionTokensRequested()) { + handleExtensionTokenGeneration(); + } else { + // Redirect to library + router.push('/library'); + } } else { - // Redirect to library - router.push('/library'); + router.push('/login?error=Authentication failed'); } - } else { - router.push('/login?error=Authentication failed'); - } + }; + + handleAuth(); }, [router, searchParams, setTokens]); return ( -- 2.51.2 From 5344fcf2ee2f68ffc7630c34e22800876e6cbbd3 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Fri, 19 Sep 2025 19:18:58 -0700 Subject: [PATCH 3/8] set up oauthClient as a shared service --- .../http/factories/ServiceFactory.ts | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/shared/infrastructure/http/factories/ServiceFactory.ts b/src/shared/infrastructure/http/factories/ServiceFactory.ts index 56659330..c3eb4077 100644 --- a/src/shared/infrastructure/http/factories/ServiceFactory.ts +++ b/src/shared/infrastructure/http/factories/ServiceFactory.ts @@ -57,11 +57,11 @@ export interface SharedServices { metadataService: IMetadataService; profileService: IProfileService; feedService: FeedService; + nodeOauthClient: NodeOAuthClient; } // Web app specific services (includes publishers, auth middleware) export interface WebAppServices extends SharedServices { - nodeOauthClient: NodeOAuthClient; oauthProcessor: IOAuthProcessor; appPasswordProcessor: IAppPasswordProcessor; collectionPublisher: ICollectionPublisher; @@ -102,13 +102,6 @@ export class ServiceFactory { const useMockAuth = process.env.USE_MOCK_AUTH === 'true'; - // OAuth Client (always create for real, but may not be used if mocking) - const nodeOauthClient = OAuthClientFactory.createClient( - repositories.oauthStateStore, - repositories.oauthSessionStore, - oauthConfig.baseUrl, - ); - // App Password Session Service const appPasswordSessionService = useMockAuth ? new FakeAppPasswordSessionService() @@ -124,7 +117,7 @@ export class ServiceFactory { // OAuth Processor const oauthProcessor = useMockAuth ? new FakeAtProtoOAuthProcessor(sharedServices.tokenService) - : new AtProtoOAuthProcessor(nodeOauthClient); + : new AtProtoOAuthProcessor(sharedServices.nodeOauthClient); const useFakePublishers = process.env.USE_FAKE_PUBLISHERS === 'true'; @@ -163,7 +156,6 @@ export class ServiceFactory { return { ...sharedServices, - nodeOauthClient, oauthProcessor, appPasswordProcessor, collectionPublisher, @@ -233,6 +225,12 @@ export class ServiceFactory { ): SharedServices { const useMockAuth = process.env.USE_MOCK_AUTH === 'true'; + const nodeOauthClient = OAuthClientFactory.createClient( + repositories.oauthStateStore, + repositories.oauthSessionStore, + oauthConfig.baseUrl, + ); + // Token Service const tokenService = useMockAuth ? new FakeJwtTokenService(repositories.tokenRepository) @@ -258,11 +256,7 @@ export class ServiceFactory { // ATProto Agent Service const atProtoAgentService = useMockAuth ? new FakeAgentService() - : new ATProtoAgentService( - // For workers, we don't need OAuth client, just pass null - null as any, - appPasswordSessionService, - ); + : new ATProtoAgentService(nodeOauthClient, appPasswordSessionService); const metadataService = new IFramelyMetadataService( configService.getIFramelyApiKey(), @@ -283,6 +277,7 @@ export class ServiceFactory { metadataService, profileService, feedService, + nodeOauthClient, }; } } -- 2.51.2 From 5271e9668be36dcad3510d8fb7e00164985c48b0 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 22 Sep 2025 11:37:27 -0700 Subject: [PATCH 4/8] use ipv4 for server side components --- src/webapp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webapp/package.json b/src/webapp/package.json index faf5981b..59c0f279 100644 --- a/src/webapp/package.json +++ b/src/webapp/package.json @@ -11,7 +11,7 @@ "key": "$CRX_PUBLIC_KEY" }, "scripts": { - "dev": "PORT=4000 next dev --turbopack", + "dev": "PORT=4000 NODE_OPTIONS='--dns-result-order=ipv4first' next dev --turbopack", "dev:prod": "NODE_ENV=production PORT=4000 next dev", "dev:extension": "plasmo dev", "build": "next build", -- 2.51.2 From e834f6c24f30a3d66e9b4bb5075e27c66b37f937 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 22 Sep 2025 11:46:29 -0700 Subject: [PATCH 5/8] include separate function for accessing tokens via cookies for server side components --- src/webapp/services/auth.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/webapp/services/auth.ts b/src/webapp/services/auth.ts index 2e1ac46c..5006bc8b 100644 --- a/src/webapp/services/auth.ts +++ b/src/webapp/services/auth.ts @@ -21,6 +21,16 @@ export const getAccessToken = (): string | null => { return localStorage.getItem('accessToken'); }; +export const getAccessTokenInServerComponent = async (): Promise< + string | null +> => { + // Get access token on server side + const { cookies } = await import('next/headers'); + const cookiesStore = await cookies(); + const accessToken = cookiesStore.get('accessToken')?.value || null; + return accessToken; +}; + // Get the refresh token export const getRefreshToken = (): string | null => { if (typeof window === 'undefined') { -- 2.51.2 From cc25617762d6b67f73399a2d116a9eb7593d9301 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 22 Sep 2025 11:47:46 -0700 Subject: [PATCH 6/8] deleted example doc for creating annotations (outdated) --- docs/examples/creating-annotations.md | 459 -------------------------- 1 file changed, 459 deletions(-) delete mode 100644 docs/examples/creating-annotations.md diff --git a/docs/examples/creating-annotations.md b/docs/examples/creating-annotations.md deleted file mode 100644 index b7b6b2ca..00000000 --- a/docs/examples/creating-annotations.md +++ /dev/null @@ -1,459 +0,0 @@ -# Creating Annotations using Templates - -This guide demonstrates how to create annotation field records, group them into a template, and then use that template to create annotations on a Personal Data Server (PDS) using the AT Protocol and the `app.annos.*` lexicons. - -## Prerequisites - -- An AT Protocol account with a DID and handle -- Access to a PDS that supports the `app.annos.*` lexicons -- A client library for AT Protocol (we'll use JavaScript with the `@atproto/api` package) - -## Installation - -```bash -npm install @atproto/api -``` - -## Example: Annotating a Podcast Episode using a Template - -This example involves three main steps: - -1. Creating individual `app.annos.annotationField` records for each type of annotation (dyad, rating, multi-select, single-select, triad). -2. Creating an `app.annos.annotationTemplate` record that groups strong references to the field records created in step 1. -3. Creating `app.annos.annotation` records for a specific podcast episode, referencing the template and the appropriate field record, and providing the specific annotation value. - -### 1. Create Annotation Field Records - -First, we define and create the individual field records. Each field record defines the structure and constraints for a specific type of annotation. - -```javascript -import { BskyAgent } from '@atproto/api'; - -// Helper function to create a field record -async function createFieldRecord(agent, name, description, definition) { - const record = { - $type: 'app.annos.annotationField', - name: name, - description: description, - definition: definition, - createdAt: new Date().toISOString(), - }; - - console.log(`Creating field record: ${name}...`); - const response = await agent.api.com.atproto.repo.createRecord({ - repo: agent.session.did, - collection: 'app.annos.annotationField', - record: record, - }); - console.log(`Field record '${name}' created:`, response.uri); - // Return a strong ref object - return { uri: response.uri, cid: response.cid }; -} - -// --- Main Execution --- -async function runPodcastAnnotationExample() { - const agent = new BskyAgent({ - service: 'https://your-pds.example.com', // Replace with your PDS URL - }); - await agent.login({ - identifier: 'your-handle.bsky.social', // Replace with your handle - password: 'your-password', // Replace with your app password - }); - - // --- Define and Create Fields --- - - // a) Dyad Field (Audience Accessibility) - const audienceFieldRef = await createFieldRecord( - agent, - 'Audience Accessibility', - 'How accessible is the content to different audiences?', - { - $type: 'app.annos.annotationField#dyadFieldDef', - sideA: 'Technical', - sideB: 'General', - }, - ); - - // b) Rating Field (Audio Quality) - const qualityFieldRef = await createFieldRecord( - agent, - 'Audio Quality', - 'Rate the audio production quality (1-5 stars)', - { - $type: 'app.annos.annotationField#ratingFieldDef', - numberOfStars: 5, // Lexicon defines this must be 5 - }, - ); - - // c) Multi-Select Field (Intended Use) - const useOptions = [ - 'learn something new', - 'be entertained', - 'relax or unwind', - 'stay informed', - 'laugh', - 'get inspired', - 'background listening', - ]; - const useFieldRef = await createFieldRecord( - agent, - 'Intended Use', - 'Why would someone listen?', - { - $type: 'app.annos.annotationField#multiSelectFieldDef', - options: useOptions, - }, - ); - - // d) Single-Select Field (Primary Language) - const languageOptions = ['English', 'Spanish', 'French', 'German', 'Other']; - const languageFieldRef = await createFieldRecord( - agent, - 'Primary Language', - 'The main language spoken in the episode', - { - $type: 'app.annos.annotationField#singleSelectFieldDef', - options: languageOptions, - }, - ); - - // e) Triad Field (Content Focus) - const focusFieldRef = await createFieldRecord( - agent, - 'Content Focus', - 'Relative focus on Host vs Guest vs Topic (sums to 1000)', - { - $type: 'app.annos.annotationField#triadFieldDef', - vertexA: 'Host Focus', - vertexB: 'Guest Focus', - vertexC: 'Topic Focus', - }, - ); - - // Store refs for later use - const fieldRefs = { - audience: audienceFieldRef, - quality: qualityFieldRef, - use: useFieldRef, - language: languageFieldRef, - focus: focusFieldRef, - }; - - // 2. Create the Template Record - const templateRef = await createPodcastTemplate(agent, fieldRefs); - - // 3. Define the target resource (podcast episode) - const podcastUrl = 'https://example.com/podcast/episode/123'; - const podcastDoi = '10.9876/podcast.ep123'; - - // 4. Create annotations using the template and field refs - await createPodcastAnnotations( - agent, - templateRef, - fieldRefs, - podcastUrl, - podcastDoi, - ); -} - -runPodcastAnnotationExample().catch(console.error); -``` - -### 2. Create the Template Record - -Now, create the `app.annos.annotationTemplate` record, including strong references (`ref`) to the field records created above. We can also mark fields as required within the template context. - -```javascript -async function createPodcastTemplate(agent, fieldRefs) { - // Create the template record, referencing the field records - const templateRecord = { - $type: 'app.annos.annotationTemplate', - name: 'Podcast Template', - description: 'Standard annotations for podcast episodes', - annotationFields: [ - // Array of annotationFieldRef objects - { ref: fieldRefs.audience, required: true }, - { ref: fieldRefs.quality, required: true }, - { ref: fieldRefs.use, required: false }, - { ref: fieldRefs.language, required: true }, // Mark language as required - { ref: fieldRefs.focus, required: false }, // Mark focus as optional - ], - createdAt: new Date().toISOString(), - }; - - console.log('Creating template record...'); - const templateResponse = await agent.api.com.atproto.repo.createRecord({ - repo: agent.session.did, - collection: 'app.annos.annotationTemplate', // Use the correct collection - record: templateRecord, - }); - - console.log('Template record created:', templateResponse.uri); - // Return a strong ref to the template - return { uri: templateResponse.uri, cid: templateResponse.cid }; -} -``` - -### 3. Create Annotations Referencing the Template and Fields - -Finally, create the actual `app.annos.annotation` records for the podcast episode. Each annotation record references both the template and the specific field record it corresponds to, and includes the annotation value conforming to the structure defined in `lexicons/annotation.json`. - -```javascript -async function createPodcastAnnotations( - agent, - templateRef, - fieldRefs, - podcastUrl, - podcastDoi, -) { - const now = new Date().toISOString(); - - // Common properties for all annotations created from this template for this URL - const baseAnnotation = { - $type: 'app.annos.annotation', // All annotations use this $type - url: podcastUrl, - additionalIdentifiers: [{ type: 'doi', value: podcastDoi }], - fromTemplates: [templateRef], // Array of strong refs to template records - createdAt: now, - }; - - // a) Create Dyad Annotation (Audience Accessibility) - // Value should be integer 0-100 as per lexicon - const audienceAnnotation = { - ...baseAnnotation, - field: fieldRefs.audience, // Strong ref to the Audience field record - value: { - // Structure matches #dyadValue in annotation.json - value: 70, // Example value (0=Technical, 100=General) - }, - note: 'Leans towards a general audience but has some technical jargon.', // Optional note - }; - console.log('Creating audience annotation...'); - const audienceResponse = await agent.api.com.atproto.repo.createRecord({ - repo: agent.session.did, - collection: 'app.annos.annotation', // ALL annotations go in this collection - record: audienceAnnotation, - }); - console.log('Audience annotation created:', audienceResponse.uri); - - // b) Create Rating Annotation (Audio Quality) - // Value should be integer 1-5 as per lexicon (via field def) - const qualityAnnotation = { - ...baseAnnotation, - field: fieldRefs.quality, // Strong ref to the Quality field record - value: { - // Structure matches #ratingValue in annotation.json - rating: 5, - // 'mustbeBetween' is not part of the value record itself, it's a constraint defined by the field - }, - }; - console.log('Creating quality annotation...'); - const qualityResponse = await agent.api.com.atproto.repo.createRecord({ - repo: agent.session.did, - collection: 'app.annos.annotation', - record: qualityAnnotation, - }); - console.log('Quality annotation created:', qualityResponse.uri); - - // c) Create Multi-Select Annotation (Intended Use) - const useAnnotation = { - ...baseAnnotation, - field: fieldRefs.use, // Strong ref to the Use field record - value: { - // Structure matches #multiSelectValue in annotation.json - option: ['learn something new', 'be entertained'], // Selected options - // 'mustbeSomeOf' is not part of the value record, it's a constraint defined by the field - }, - }; - console.log('Creating intended use annotation...'); - const useResponse = await agent.api.com.atproto.repo.createRecord({ - repo: agent.session.did, - collection: 'app.annos.annotation', - record: useAnnotation, - }); - console.log('Intended use annotation created:', useResponse.uri); - - // d) Create Single-Select Annotation (Primary Language) - const languageAnnotation = { - ...baseAnnotation, - field: fieldRefs.language, // Strong ref to the Language field record - value: { - // Structure matches #singleSelectValue in annotation.json - option: 'English', // Selected option - }, - }; - console.log('Creating language annotation...'); - const languageResponse = await agent.api.com.atproto.repo.createRecord({ - repo: agent.session.did, - collection: 'app.annos.annotation', - record: languageAnnotation, - }); - console.log('Language annotation created:', languageResponse.uri); - - // e) Create Triad Annotation (Content Focus) - // Values must sum to 1000 as per lexicon - const focusAnnotation = { - ...baseAnnotation, - field: fieldRefs.focus, // Strong ref to the Focus field record - value: { - // Structure matches #triadValue in annotation.json - vertexA: 300, // Host Focus - vertexB: 200, // Guest Focus - vertexC: 500, // Topic Focus - // 'sum' is implicitly 1000 due to constraints, not explicitly set here - }, - note: 'Mostly topic-driven, with moderate host presence and less guest focus.', - }; - console.log('Creating focus annotation...'); - const focusResponse = await agent.api.com.atproto.repo.createRecord({ - repo: agent.session.did, - collection: 'app.annos.annotation', - record: focusAnnotation, - }); - console.log('Focus annotation created:', focusResponse.uri); -} -``` - -## Request and Response Examples (Conceptual) - -Below are conceptual JSON structures for the requests and responses involved in the podcast template scenario. Replace placeholders like DIDs, CIDs, and timestamps with actual values. - -### Request JSONs - -**1. Create Field Record Request (Example: Dyad Field)** - -```json -{ - "repo": "did:plc:abcdefghijklmnopqrstuvwxyz", - "collection": "app.annos.annotationField", - "record": { - "$type": "app.annos.annotationField", - "name": "Audience Accessibility", - "description": "How accessible is the content to different audiences?", - "definition": { - "sideA": "Technical", - "sideB": "General" - }, - "createdAt": "2025-04-16T17:00:00.000Z" - } -} -``` - -_(Similar requests are made for the Rating, Multi-Select, Single-Select, and Triad fields)_ - -**2. Create Template Record Request:** - -```json -{ - "repo": "did:plc:abcdefghijklmnopqrstuvwxyz", - "collection": "app.annos.annotationTemplate", - "record": { - "$type": "app.annos.annotationTemplate", - "name": "Podcast Template", - "description": "Standard annotations for podcast episodes", - "annotationFields": [ - { - "ref": { - "uri": "at://did:plc:abcdefghijklmnopqrstuvwxyz/app.annos.annotationField/3kfieldDyad...", - "cid": "bafyreia..." - }, - "required": true - }, - { - "ref": { - "uri": "at://did:plc:abcdefghijklmnopqrstuvwxyz/app.annos.annotationField/3kfieldRating...", - "cid": "bafyreib..." - }, - "required": true - }, - { - "ref": { - "uri": "at://did:plc:abcdefghijklmnopqrstuvwxyz/app.annos.annotationField/3kfieldSelect...", - "cid": "bafyreic..." - }, - "required": false - }, - { - "ref": { - "uri": "at://did:plc:abcdefghijklmnopqrstuvwxyz/app.annos.annotationField/3kfieldLang...", - "cid": "bafyreid..." - }, - "required": true - }, - { - "ref": { - "uri": "at://did:plc:abcdefghijklmnopqrstuvwxyz/app.annos.annotationField/3kfieldFocus...", - "cid": "bafyreie..." - }, - "required": false - } - ], - "createdAt": "2025-04-16T17:01:00.000Z" - } -} -``` - -**3. Create Annotation Record Request (Example: Dyad Annotation)** - -```json -{ - "repo": "did:plc:abcdefghijklmnopqrstuvwxyz", - "collection": "app.annos.annotation", // Note: Collection is always app.annos.annotation - "record": { - "$type": "app.annos.annotation", // Note: $type is always app.annos.annotation - "url": "https://example.com/podcast/episode/123", - "additionalIdentifiers": [ - { "type": "doi", "value": "10.9876/podcast.ep123" } - ], - "fromTemplates": [ - // Now an array - { - "uri": "at://did:plc:abcdefghijklmnopqrstuvwxyz/app.annos.annotationTemplate/3ktemplate...", - "cid": "bafyreif..." // Assuming template CID might change if fields added - } - ], - "field": { - // Strong ref to the specific FIELD record (Dyad example) - "uri": "at://did:plc:abcdefghijklmnopqrstuvwxyz/app.annos.annotationField/3kfieldDyad...", - "cid": "bafyreia..." - }, - "value": { - // Value structure matches #dyadValue in annotation.json - "value": 70 - }, - "note": "Leans towards a general audience but has some technical jargon.", - "createdAt": "2025-04-16T17:02:00.000Z" - } -} -``` - -_(Similar requests are made for the Rating, Multi-Select, Single-Select, and Triad annotations, changing the `field` ref and `value` structure accordingly)_ - -### Response JSONs (Conceptual) - -**1. Create Field Record Response (Example: Dyad Field)** - -```json -{ - "uri": "at://did:plc:abcdefghijklmnopqrstuvwxyz/app.annos.annotationField/3kfieldDyad...", - "cid": "bafyreia..." -} -``` - -**2. Create Template Record Response:** - -```json -{ - "uri": "at://did:plc:abcdefghijklmnopqrstuvwxyz/app.annos.annotationTemplate/3ktemplate...", - "cid": "bafyreif..." // Assuming template CID might change -} -``` - -**3. Create Annotation Record Response (Example: Dyad Annotation)** - -```json -{ - "uri": "at://did:plc:abcdefghijklmnopqrstuvwxyz/app.annos.annotation/3kannoDyad...", - "cid": "bafyreig..." // Assuming annotation CID changes -} -``` -- 2.51.2 From b904890ae66a303a7749ed35eaf0cef8b99adafb Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 22 Sep 2025 11:50:25 -0700 Subject: [PATCH 7/8] include example for using tokens in ssr --- docs/examples/next-js-ssr-component.md | 107 +++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/examples/next-js-ssr-component.md diff --git a/docs/examples/next-js-ssr-component.md b/docs/examples/next-js-ssr-component.md new file mode 100644 index 00000000..703fd271 --- /dev/null +++ b/docs/examples/next-js-ssr-component.md @@ -0,0 +1,107 @@ +- use the `getAccessTokenInServerComponent()` to access the token +- pass the token in to the `ApiClient` as normal + +```tsx +const accessToken = await getAccessTokenInServerComponent(); +const apiClient = new ApiClient( + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', + () => accessToken, +); +``` + +- Full example + +```tsx +import { ApiClient } from '@/api-client/ApiClient'; +import { getAccessTokenInServerComponent } from '@/services/auth'; +import { + Card, + Container, + Stack, + Title, + Text, + Avatar, + Group, +} from '@mantine/core'; +import { redirect } from 'next/navigation'; + +export default async function SSRProfilePage() { + const accessToken = await getAccessTokenInServerComponent(); + if (!accessToken) { + redirect('/auth/signin'); + } + + // Create API client for server-side usage + const apiClient = new ApiClient( + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', + () => accessToken, + ); + + let profile; + let error; + + try { + profile = await apiClient.getMyProfile(); + console.log('Fetched profile on server side:', profile); + } catch (err: any) { + console.error('Error fetching profile on server side:', err); + error = err.message || 'Failed to load profile'; + } + + return ( + + + Server-Side Rendered Profile + + {error ? ( + + + Error: {error} + + + ) : profile ? ( + + + + + + {profile.name} + + @{profile.handle} + + + {profile.description && ( + + {profile.description} + + )} + + + + + ID: {profile.id} + + + + + ) : ( + + + No profile data available + + + )} + + + This page was rendered on the server at {new Date().toISOString()} + + + + ); +} +``` -- 2.51.2 From 0462485416da2448ee083f403c89967921735c9f Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Mon, 22 Sep 2025 11:51:06 -0700 Subject: [PATCH 8/8] formatting --- src/webapp/app/api/auth/sync/route.ts | 7 +--- src/webapp/hooks/useAuth.tsx | 53 ++++++++++++++------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/webapp/app/api/auth/sync/route.ts b/src/webapp/app/api/auth/sync/route.ts index 1f6d3e2d..0c9d2d7d 100644 --- a/src/webapp/app/api/auth/sync/route.ts +++ b/src/webapp/app/api/auth/sync/route.ts @@ -5,10 +5,7 @@ export async function POST(request: NextRequest) { const { accessToken, refreshToken } = await request.json(); if (!accessToken || !refreshToken) { - return NextResponse.json( - { error: 'Missing tokens' }, - { status: 400 } - ); + return NextResponse.json({ error: 'Missing tokens' }, { status: 400 }); } // Create response with cookies @@ -36,7 +33,7 @@ export async function POST(request: NextRequest) { console.error('Token sync error:', error); return NextResponse.json( { error: 'Failed to sync tokens' }, - { status: 500 } + { status: 500 }, ); } } diff --git a/src/webapp/hooks/useAuth.tsx b/src/webapp/hooks/useAuth.tsx index 9e7dde0f..640b66af 100644 --- a/src/webapp/hooks/useAuth.tsx +++ b/src/webapp/hooks/useAuth.tsx @@ -177,32 +177,35 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { [createApiClient], ); - const setTokens = useCallback(async (accessToken: string, refreshToken: string) => { - // Store tokens in localStorage - localStorage.setItem('accessToken', accessToken); - localStorage.setItem('refreshToken', refreshToken); - - // Update state - setAccessToken(accessToken); - setRefreshToken(refreshToken); - setIsAuthenticated(true); + const setTokens = useCallback( + async (accessToken: string, refreshToken: string) => { + // Store tokens in localStorage + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + + // Update state + setAccessToken(accessToken); + setRefreshToken(refreshToken); + setIsAuthenticated(true); - // Sync tokens with server-side cookies - try { - await fetch('/api/auth/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - accessToken, - refreshToken - }), - credentials: 'include' // Important for cookie handling - }); - } catch (error) { - console.error('Failed to sync tokens with server:', error); - // Don't throw error - localStorage tokens are still valid - } - }, []); + // Sync tokens with server-side cookies + try { + await fetch('/api/auth/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + accessToken, + refreshToken, + }), + credentials: 'include', // Important for cookie handling + }); + } catch (error) { + console.error('Failed to sync tokens with server:', error); + // Don't throw error - localStorage tokens are still valid + } + }, + [], + ); return (