Change requests #2

merged
opened by adamspiers.org targeting main from adamspiers.org/eii-frontend: change-requests
Changed files
+629 -7
app
api
change-request
login
oauth
callback
client-metadata.json
change-request
[uri]
lexicons
lib
auth
+161
CHAT_API_ATTEMPTS_LOG.md
···
+
# Chat API Implementation Attempts Log
+
+
## Overview
+
We're trying to implement Bluesky chat DM functionality to notify project owners when someone creates a change request for their project. The OAuth scope `'atproto transition:generic transition:chat.bsky'` IS being granted correctly (confirmed via OAuth consent screen showing chat permissions).
+
+
## Confirmed Working Parts
+
- ✅ OAuth flow with chat scope (user sees chat permissions in consent screen)
+
- ✅ Change request record creation
+
- ✅ ATProto Agent authentication with OAuth session
+
- ✅ Session restoration and basic API calls work
+
+
## Failed Attempts (In Chronological Order)
+
+
### Attempt 1: Basic agent.chat.bsky approach
+
```typescript
+
const convoResponse = await agent.chat.bsky.convo.getConvoForMembers({
+
members: [targetDid]
+
})
+
```
+
**Result:** `Error: XRPCNotSupported, status: 404`
+
+
### Attempt 2: agent.api.chat.bsky with service routing
+
```typescript
+
const convoResponse = await agent.api.chat.bsky.convo.getConvoForMembers(
+
{ members: [targetDid] },
+
{
+
service: 'did:web:api.bsky.chat',
+
headers: {
+
'atproto-proxy': 'did:web:api.bsky.chat#atproto_labeler'
+
}
+
}
+
)
+
```
+
**Result:** `Error: could not resolve proxy did service url, status: 400`
+
+
### Attempt 3: Simplified service routing
+
```typescript
+
const convoResponse = await agent.api.chat.bsky.convo.getConvoForMembers(
+
{ members: [targetDid] },
+
{ service: 'did:web:api.bsky.chat' }
+
)
+
```
+
**Result:** `Error: could not resolve proxy did service url, status: 400`
+
+
### Attempt 4: No service parameter
+
```typescript
+
const convoResponse = await agent.api.chat.bsky.convo.getConvoForMembers({
+
members: [targetDid]
+
})
+
```
+
**Result:** `Error: XRPCNotSupported, status: 404`
+
+
### Attempt 5: BskyAgent approach with session transfer
+
```typescript
+
const bskyAgent = new BskyAgent({ service: 'https://bsky.social' })
+
if (agent.session) {
+
bskyAgent.session = agent.session // Copy session
+
}
+
const convoResponse = await bskyAgent.api.chat.bsky.convo.getConvoForMembers({
+
members: [targetDid]
+
})
+
```
+
**Result:** `Error: Authentication Required, status: 401` (agent.session was undefined)
+
+
### Attempt 6: BskyAgent with manual session construction
+
```typescript
+
const bskyAgent = new BskyAgent({ service: 'https://bsky.social' })
+
bskyAgent.session = {
+
did: oauthSession.sub,
+
handle: oauthSession.sub,
+
accessJwt: tokenSet?.access_token,
+
refreshJwt: tokenSet?.refresh_token,
+
active: true
+
}
+
```
+
**Result:** `TypeError: Cannot set property session of #<AtpAgent> which has only a getter`
+
+
### Attempt 7: BskyAgent with resumeSession
+
```typescript
+
await bskyAgent.resumeSession({
+
did: oauthSession.sub,
+
handle: oauthSession.sub,
+
accessJwt: tokenSet?.access_token,
+
refreshJwt: tokenSet?.refresh_token,
+
active: true
+
})
+
```
+
**Result:** `Error: Token could not be verified, error: 'InvalidToken', status: 400`
+
+
### Attempt 8: Back to agent.api.chat.bsky with different headers
+
```typescript
+
const convoResponse = await agent.api.chat.bsky.convo.getConvoForMembers(
+
{ members: [targetDid] },
+
{
+
service: 'did:web:api.bsky.chat',
+
headers: {
+
'atproto-accept-labelers': 'did:plc:ar7c4by46qjdydhdevvrndac;redact'
+
}
+
}
+
)
+
```
+
**Result:** [Testing now - likely same as previous service routing attempts]
+
+
### Attempt 9: Back to basic agent.chat.bsky (current)
+
```typescript
+
const convoResponse = await agent.chat.bsky.convo.getConvoForMembers({
+
members: [targetDid]
+
})
+
```
+
**Result:** `Error: XRPCNotSupported, status: 404`
+
+
### Attempt 10: SOLUTION FOUND - Using withProxy method ✅
+
```typescript
+
console.log('Creating chat proxy with withProxy method')
+
const chatAgent = agent.withProxy('bsky_chat', 'did:web:api.bsky.chat')
+
+
const convoResponse = await chatAgent.chat.bsky.convo.getConvoForMembers({
+
members: [targetDid]
+
})
+
+
await chatAgent.chat.bsky.convo.sendMessage({
+
convoId: convoResponse.data.convo.id,
+
message: {
+
text: message
+
}
+
})
+
```
+
**Result:** ✅ **SUCCESS!** Chat API implementation working correctly. Got business logic error: `"recipient requires incoming messages to come from someone they follow"` - this means the technical implementation is correct, the target user just has privacy settings that require them to follow the sender first. This is expected Bluesky behavior, not a technical bug.
+
+
## Key Debugging Info
+
- **OAuth Session Data:** `scope: undefined, aud: undefined, sub: 'did:plc:ucuwh64u4r5pycnlvrqvty3j'`
+
- **OAuth Consent Screen:** DOES show chat permissions (confirmed by user)
+
- **Request Scope:** `'atproto transition:generic transition:chat.bsky'`
+
- **Agent Type:** ATProto `Agent` created from OAuth session
+
+
## Theories for Why It's Not Working
+
+
### Theory 1: Scope Format Issue
+
- OAuth scope in token response is `undefined` even though consent screen shows chat permissions
+
- ATProto might handle scopes differently than standard OAuth2
+
+
### Theory 2: Service Routing Issue
+
- Chat APIs require specific service routing that we haven't figured out
+
- The `did:web:api.bsky.chat` service routing isn't working correctly
+
+
### Theory 3: Token Format Incompatibility
+
- OAuth tokens from ATProto client aren't compatible with BskyAgent
+
- Need different authentication method for chat APIs
+
+
### Theory 4: Chat API Availability
+
- Chat APIs might not be fully available via OAuth (only App Passwords?)
+
- Chat functionality might be restricted/beta
+
+
## Next Steps to Try
+
1. **Check ATProto SDK documentation** for official chat API examples
+
2. **Test with a different target DID** (maybe chat with yourself first)
+
3. **Look for working chat API examples** in ATProto community/GitHub
+
4. **Consider alternative notification methods** (mentions, follows, etc.)
+
+
## Current Status
+
Going in circles between the same 4-5 approaches. Need fresh perspective or to accept chat DMs might not be viable via OAuth.
+132
app/api/change-request/route.ts
···
+
import { NextRequest, NextResponse } from 'next/server'
+
import { Agent } from '@atproto/api'
+
import { getPdsEndpoint } from '@atproto/common-web'
+
import { IdResolver } from '@atproto/identity'
+
+
// GET - Fetch change request and related data
+
export async function GET(request: NextRequest) {
+
try {
+
const { searchParams } = new URL(request.url)
+
const did = searchParams.get('did')
+
const rkey = searchParams.get('rkey')
+
+
if (!did || !rkey) {
+
return NextResponse.json({ error: 'DID and rkey parameters required' }, { status: 400 })
+
}
+
+
// Resolve DID to get PDS endpoint
+
const resolver = new IdResolver()
+
const didDoc = await resolver.did.resolve(did)
+
+
if (!didDoc) {
+
return NextResponse.json({ error: 'Could not resolve DID' }, { status: 404 })
+
}
+
+
const pdsEndpoint = getPdsEndpoint(didDoc)
+
if (!pdsEndpoint) {
+
return NextResponse.json({ error: 'No PDS endpoint found for DID' }, { status: 404 })
+
}
+
+
// Create Agent pointing to the specific PDS for public access
+
const agent = new Agent({ service: pdsEndpoint })
+
+
try {
+
// Fetch the change request record
+
const changeRequestResponse = await agent.com.atproto.repo.getRecord({
+
repo: did,
+
collection: 'org.impactindexer.changeRequest',
+
rkey: rkey
+
})
+
+
if (!changeRequestResponse.success) {
+
return NextResponse.json({ error: 'Change request not found' }, { status: 404 })
+
}
+
+
const changeRequestData = changeRequestResponse.data.value as any
+
+
// Fetch requester's profile
+
let requesterProfile = null
+
try {
+
const profileResponse = await agent.getProfile({ actor: did })
+
if (profileResponse.success) {
+
requesterProfile = {
+
handle: profileResponse.data.handle,
+
displayName: profileResponse.data.displayName,
+
avatar: profileResponse.data.avatar
+
}
+
}
+
} catch (error) {
+
console.log('Could not fetch requester profile')
+
}
+
+
// Fetch original data if originalRecord URI exists
+
let originalData = null
+
if (changeRequestData.originalRecord) {
+
try {
+
// Parse the original record URI
+
const originalUri = changeRequestData.originalRecord
+
const originalUriParts = originalUri.replace('at://', '').split('/')
+
const originalDid = originalUriParts[0]
+
const originalRkey = originalUriParts[2]
+
+
// Resolve original DID and fetch the record
+
const originalDidDoc = await resolver.did.resolve(originalDid)
+
if (originalDidDoc) {
+
const originalPdsEndpoint = getPdsEndpoint(originalDidDoc)
+
if (originalPdsEndpoint) {
+
const originalAgent = new Agent({ service: originalPdsEndpoint })
+
const originalResponse = await originalAgent.com.atproto.repo.getRecord({
+
repo: originalDid,
+
collection: 'org.impactindexer.status',
+
rkey: originalRkey
+
})
+
+
if (originalResponse.success) {
+
originalData = originalResponse.data.value
+
}
+
}
+
}
+
} catch (error) {
+
console.log('Could not fetch original record:', error)
+
}
+
}
+
+
// Fetch proposed data
+
let proposedData = null
+
try {
+
// Parse the proposed record URI
+
const proposedUri = changeRequestData.proposedRecord
+
const proposedUriParts = proposedUri.replace('at://', '').split('/')
+
const proposedDid = proposedUriParts[0]
+
const proposedRkey = proposedUriParts[2]
+
+
// The proposed record should be in the same repository as the change request
+
const proposedResponse = await agent.com.atproto.repo.getRecord({
+
repo: proposedDid,
+
collection: 'org.impactindexer.status',
+
rkey: proposedRkey
+
})
+
+
if (proposedResponse.success) {
+
proposedData = proposedResponse.data.value
+
}
+
} catch (error) {
+
console.log('Could not fetch proposed record:', error)
+
}
+
+
return NextResponse.json({
+
...changeRequestData,
+
requesterProfile,
+
originalData,
+
proposedData
+
})
+
+
} catch (error: any) {
+
console.error('Failed to fetch change request:', error)
+
return NextResponse.json({ error: 'Change request not found' }, { status: 404 })
+
}
+
} catch (error) {
+
console.error('Change request fetch failed:', error)
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+
}
+
}
+1 -1
app/api/login/route.ts
···
}
const url = await client.authorize(handle, {
-
scope: 'atproto transition:generic',
+
scope: 'atproto transition:generic transition:chat.bsky',
})
return NextResponse.json({ redirectUrl: url.toString() })
+43 -3
app/api/oauth/callback/route.ts
···
// Create URLSearchParams object from the search string
const params = new URLSearchParams(url.search)
-
const { session } = await client.callback(params)
+
console.log('OAuth callback params:', Object.fromEntries(params))
+
+
// Retry OAuth callback up to 3 times for network errors
+
let session
+
let lastError
+
+
for (let attempt = 1; attempt <= 3; attempt++) {
+
try {
+
console.log(`OAuth callback attempt ${attempt}/3`)
+
const result = await client.callback(params)
+
session = result.session
+
break
+
} catch (error) {
+
lastError = error
+
console.error(`OAuth callback attempt ${attempt} failed:`, error.message)
+
+
// Check if it's a network/socket error worth retrying
+
const isNetworkError = error.message?.includes('UND_ERR_SOCKET') ||
+
error.message?.includes('fetch failed') ||
+
error.message?.includes('Failed to resolve OAuth server metadata')
+
+
if (isNetworkError && attempt < 3) {
+
console.log(`Network error detected, retrying in ${attempt * 1000}ms...`)
+
await new Promise(resolve => setTimeout(resolve, attempt * 1000))
+
continue
+
}
+
+
throw error
+
}
+
}
+
+
if (!session) {
+
throw lastError || new Error('Failed to create session after retries')
+
}
+
+
console.log('OAuth callback session created:', {
+
did: session.did,
+
sub: session.sub,
+
scope: session.tokenSet?.scope,
+
aud: session.tokenSet?.aud
+
})
await setSession({ did: session.did })
return NextResponse.redirect(new URL('/', request.url))
} catch (error) {
-
console.error('OAuth callback failed:', error)
+
console.error('OAuth callback failed after all retries:', error)
return NextResponse.redirect(
-
new URL('/?error=' + encodeURIComponent('Authentication failed'), request.url)
+
new URL('/?error=' + encodeURIComponent('Authentication failed - please try again'), request.url)
)
}
}
+1 -1
app/api/oauth/client-metadata.json/route.ts
···
client_id: `${url}/api/oauth/client-metadata.json`,
client_uri: url,
redirect_uris: [`${url}/api/oauth/callback`],
-
scope: 'atproto transition:generic',
+
scope: 'atproto transition:generic transition:chat.bsky',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
application_type: 'web',
+244
app/change-request/[uri]/page.tsx
···
+
'use client'
+
+
import { useParams } from 'next/navigation'
+
import { useEffect, useState } from 'react'
+
import Link from 'next/link'
+
import { ArrowLeft, User, Calendar, FileText, ExternalLink } from 'lucide-react'
+
+
interface ChangeRequestData {
+
targetDid: string
+
originalRecord?: string
+
proposedRecord: string
+
reason: string
+
createdAt: string
+
requesterProfile?: {
+
handle: string
+
displayName?: string
+
avatar?: string
+
}
+
originalData?: any
+
proposedData?: any
+
}
+
+
export default function ChangeRequestPage() {
+
const params = useParams()
+
const uri = decodeURIComponent(params.uri as string)
+
const [changeRequest, setChangeRequest] = useState<ChangeRequestData | null>(null)
+
const [loading, setLoading] = useState(true)
+
const [error, setError] = useState<string | null>(null)
+
+
useEffect(() => {
+
const fetchChangeRequest = async () => {
+
try {
+
// Parse the URI to extract the DID and record key
+
const uriParts = uri.split('/')
+
const did = uriParts[2] // at://did:plc:xyz/collection/rkey
+
const rkey = uriParts[4]
+
+
// Fetch the change request record
+
const response = await fetch(`/api/change-request?did=${encodeURIComponent(did)}&rkey=${encodeURIComponent(rkey)}`)
+
+
if (!response.ok) {
+
throw new Error('Failed to fetch change request')
+
}
+
+
const data = await response.json()
+
setChangeRequest(data)
+
} catch (error) {
+
console.error('Error fetching change request:', error)
+
setError('Failed to load change request')
+
} finally {
+
setLoading(false)
+
}
+
}
+
+
if (uri) {
+
fetchChangeRequest()
+
}
+
}, [uri])
+
+
if (loading) {
+
return (
+
<div className="space-y-6 animate-pulse">
+
<div className="h-6 bg-surface rounded w-32"></div>
+
<div className="bg-surface border-subtle shadow-card p-6">
+
<div className="h-8 bg-surface-hover rounded w-3/4 mb-4"></div>
+
<div className="h-4 bg-surface-hover rounded w-full mb-2"></div>
+
<div className="h-4 bg-surface-hover rounded w-2/3"></div>
+
</div>
+
</div>
+
)
+
}
+
+
if (error || !changeRequest) {
+
return (
+
<div className="space-y-6">
+
<Link
+
href="/"
+
className="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors mb-6"
+
>
+
<ArrowLeft className="h-4 w-4 mr-2" />
+
Back to Dashboard
+
</Link>
+
+
<div className="bg-surface border-subtle shadow-card p-6 text-center">
+
<h1 className="text-xl font-serif font-medium text-primary mb-2">
+
Change Request Not Found
+
</h1>
+
<p className="text-secondary">
+
{error || 'The change request you\'re looking for could not be found.'}
+
</p>
+
</div>
+
</div>
+
)
+
}
+
+
const formatDate = (dateString: string) => {
+
return new Date(dateString).toLocaleDateString('en-US', {
+
year: 'numeric',
+
month: 'long',
+
day: 'numeric',
+
hour: '2-digit',
+
minute: '2-digit'
+
})
+
}
+
+
return (
+
<div className="space-y-6">
+
{/* Back Navigation */}
+
<Link
+
href="/"
+
className="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors mb-6"
+
>
+
<ArrowLeft className="h-4 w-4 mr-2" />
+
Back to Dashboard
+
</Link>
+
+
{/* Header */}
+
<div className="bg-surface border-subtle shadow-card p-6">
+
<div className="flex items-start justify-between mb-4">
+
<div>
+
<h1 className="text-2xl font-serif font-medium text-primary mb-2">
+
📝 Change Request
+
</h1>
+
<div className="flex items-center text-sm text-secondary space-x-4">
+
<div className="flex items-center">
+
<Calendar className="h-4 w-4 mr-1" />
+
{formatDate(changeRequest.createdAt)}
+
</div>
+
{changeRequest.requesterProfile && (
+
<div className="flex items-center">
+
<User className="h-4 w-4 mr-1" />
+
@{changeRequest.requesterProfile.handle}
+
</div>
+
)}
+
</div>
+
</div>
+
</div>
+
+
{/* Reason */}
+
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
+
<div className="flex items-start">
+
<FileText className="h-5 w-5 text-amber-600 mt-0.5 mr-3 flex-shrink-0" />
+
<div>
+
<h3 className="font-medium text-amber-800 dark:text-amber-200 mb-1">
+
Reason for Change
+
</h3>
+
<p className="text-amber-700 dark:text-amber-300 text-sm">
+
{changeRequest.reason}
+
</p>
+
</div>
+
</div>
+
</div>
+
</div>
+
+
{/* Record Links */}
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+
{changeRequest.originalRecord && (
+
<div className="bg-surface border-subtle shadow-card p-4">
+
<h3 className="font-medium text-primary mb-2">Original Record</h3>
+
<Link
+
href={`/project/${encodeURIComponent(changeRequest.targetDid)}`}
+
className="inline-flex items-center text-sm text-accent hover:text-accent-hover transition-colors"
+
>
+
View Current Project
+
<ExternalLink className="h-4 w-4 ml-1" />
+
</Link>
+
</div>
+
)}
+
+
<div className="bg-surface border-subtle shadow-card p-4">
+
<h3 className="font-medium text-primary mb-2">Proposed Changes</h3>
+
<Link
+
href={changeRequest.proposedRecord}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="inline-flex items-center text-sm text-accent hover:text-accent-hover transition-colors"
+
>
+
View Proposed Record
+
<ExternalLink className="h-4 w-4 ml-1" />
+
</Link>
+
</div>
+
</div>
+
+
{/* Data Comparison (if available) */}
+
{changeRequest.originalData && changeRequest.proposedData && (
+
<div className="bg-surface border-subtle shadow-card p-6">
+
<h2 className="text-lg font-serif font-medium text-primary mb-4">
+
Proposed Changes
+
</h2>
+
<div className="space-y-4">
+
{Object.keys(changeRequest.proposedData).map((key) => {
+
if (key.startsWith('$') || key === 'createdAt' || key === 'updatedAt') return null
+
+
const originalValue = changeRequest.originalData?.[key]
+
const proposedValue = changeRequest.proposedData[key]
+
+
if (originalValue === proposedValue) return null
+
+
return (
+
<div key={key} className="border-l-2 border-accent pl-4">
+
<div className="text-sm font-medium text-primary mb-1 capitalize">
+
{key.replace(/([A-Z])/g, ' $1').trim()}
+
</div>
+
<div className="space-y-1 text-sm">
+
{originalValue !== undefined && (
+
<div className="text-red-600 dark:text-red-400">
+
- {typeof originalValue === 'object' ? JSON.stringify(originalValue) : String(originalValue)}
+
</div>
+
)}
+
<div className="text-green-600 dark:text-green-400">
+
+ {typeof proposedValue === 'object' ? JSON.stringify(proposedValue) : String(proposedValue)}
+
</div>
+
</div>
+
</div>
+
)
+
})}
+
</div>
+
</div>
+
)}
+
+
{/* Future: Action buttons for project owners */}
+
<div className="bg-surface border-subtle shadow-card p-6">
+
<h3 className="font-medium text-primary mb-2">Project Owner Actions</h3>
+
<p className="text-sm text-secondary mb-4">
+
As the project owner, you can review the proposed changes and decide whether to accept them.
+
</p>
+
<div className="flex space-x-3">
+
<button
+
disabled
+
className="px-4 py-2 bg-surface-hover text-muted rounded-lg cursor-not-allowed"
+
>
+
Accept Changes (Coming Soon)
+
</button>
+
<button
+
disabled
+
className="px-4 py-2 bg-surface-hover text-muted rounded-lg cursor-not-allowed"
+
>
+
Decline (Coming Soon)
+
</button>
+
</div>
+
</div>
+
</div>
+
)
+
}
+45
lexicons/changeRequest.json
···
+
{
+
"lexicon": 1,
+
"id": "org.impactindexer.changeRequest",
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": ["targetDid", "reason", "createdAt"],
+
"properties": {
+
"targetDid": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the project being modified"
+
},
+
"originalRecord": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "URI reference to the original status record"
+
},
+
"proposedRecord": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "URI reference to the proposed status record in the requester's repository"
+
},
+
"reason": {
+
"type": "string",
+
"maxLength": 1000,
+
"maxGraphemes": 200,
+
"description": "Explanation for the proposed changes"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"updatedAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}
+2 -2
lib/auth/client.ts
···
client_name: 'Interplanetary Impact Index',
client_id: publicUrl && publicUrl.trim()
? `${publicUrl}/api/oauth/client-metadata.json`
-
: `http://localhost?redirect_uri=${enc(`${url}/api/oauth/callback`)}&scope=${enc('atproto transition:generic')}`,
+
: `http://localhost?redirect_uri=${enc(`${url}/api/oauth/callback`)}&scope=${enc('atproto transition:generic transition:chat.bsky')}`,
client_uri: url,
redirect_uris: [`${url}/api/oauth/callback`],
-
scope: 'atproto transition:generic',
+
scope: 'atproto transition:generic transition:chat.bsky',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
application_type: 'web',