Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions lexicons/com/sds/organization/create.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["name", "creatorDid"],
"required": ["name", "handlePrefix", "creatorDid"],
"properties": {
"name": {
"type": "string",
Expand All @@ -21,10 +21,10 @@
"maxLength": 500,
"description": "Optional description of the organization."
},
"handle": {
"handlePrefix": {
"type": "string",
"format": "handle",
"description": "Optional custom handle for the organization. If not provided, will be auto-generated."
"maxLength": 50,
"description": "The handle prefix (part before the first dot). The SDS hostname will be automatically appended as the suffix."
},
"creatorDid": {
"type": "string",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,6 @@ export function AuthorizeView({
if (view === View.Welcome && homeView !== View.Welcome) setView(homeView)
}, [view, homeView])

useEffect(() => {
console.log(props)
}, [props])

if (view === View.Welcome) {
return (
<WelcomeView
Expand Down
18 changes: 18 additions & 0 deletions packages/sds-demo/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Environment variables for SDS Demo
# Copy this file to .env and update the values as needed

# Node environment (development, production)
#NODE_ENV=development

# PLC Directory URL
PLC_DIRECTORY_URL=https://plc.directory

# Handle Resolver URL
HANDLE_RESOLVER_URL=https://bsky.social

# Sign Up URL (PDS URL)
#SIGN_UP_URL=https://bsky.social
SIGN_UP_URL=https://pds-eu-west4.test.certified.app

# SDS Server URL
SDS_SERVER_URL=https://sds-eu-west4.test.certified.app
3 changes: 3 additions & 0 deletions packages/sds-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
dist
.env
.env.local
.env.*.local
2 changes: 2 additions & 0 deletions packages/sds-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@atproto/oauth-client-browser": "workspace:*",
"@atproto/oauth-types": "workspace:*",
"@atproto/sds": "workspace:*",
"@atproto/syntax": "workspace:^",
"@atproto/xrpc": "workspace:*",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-html": "^1.0.4",
Expand All @@ -48,6 +49,7 @@
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"autoprefixer": "^10.4.17",
"dotenv": "^16.4.5",
"postcss": "^8.4.33",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/sds-demo/rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* eslint-env node */

require('dotenv').config()

const { default: commonjs } = require('@rollup/plugin-commonjs')
const { default: html, makeHtmlAttributes } = require('@rollup/plugin-html')
const { default: json } = require('@rollup/plugin-json')
Expand Down
8 changes: 5 additions & 3 deletions packages/sds-demo/src/components/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@ export function Button({
'inline-block rounded-md',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'transition duration-300 ease-in-out',
transparent
? 'bg-transparent text-purple-600 hover:bg-purple-100 focus:ring-purple-500'
: 'bg-purple-600 text-white hover:bg-purple-700 focus:ring-purple-800',
disabled || showProgress
? 'cursor-not-allowed bg-gray-400 text-gray-200'
: transparent
? 'bg-transparent text-purple-600 hover:bg-purple-100 focus:ring-purple-500'
: 'bg-purple-600 text-white hover:bg-purple-700 focus:ring-purple-800',
sizeClass,
className,
].join(' ')}
Expand Down
150 changes: 142 additions & 8 deletions packages/sds-demo/src/components/repository-dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { useEffect, useMemo, useState } from 'react'
import { InvalidHandleError, ensureValidHandle } from '@atproto/syntax'
import { useAuthContext } from '../auth/auth-provider.tsx'
import { SDS_SERVER_URL } from '../constants.ts'
import {
Repository,
useRepositoryContext,
} from '../contexts/repository-context.tsx'
import {
useCheckHandleAvailabilityQuery,
useCreateRecordMutation,
useListOrganizationsQuery,
} from '../queries/use-sds-queries.ts'
Expand Down Expand Up @@ -50,11 +53,15 @@ export function RepositoryDashboard() {
const { repositories, addRepository, setSelectedRepo } =
useRepositoryContext()
const [loading, setLoading] = useState(false)
const [selectedRepo, setSelectedRepoLocal] = useState<string | null>(null)
const [selectedRepo] = useState<string | null>(null)
const [newPostText, setNewPostText] = useState('')
const [showCreateOrg, setShowCreateOrg] = useState(false)
const [newOrgName, setNewOrgName] = useState('')
const [newOrgDescription, setNewOrgDescription] = useState('')
const [handleValidationError, setHandleValidationError] = useState<
string | null
>(null)
const [debouncedHandle, setDebouncedHandle] = useState<string | null>(null)
const [collaborationModal, setCollaborationModal] = useState<{
isOpen: boolean
repositoryDid: string
Expand All @@ -68,6 +75,79 @@ export function RepositoryDashboard() {
const createRecordMutation = useCreateRecordMutation()
const organizationsQuery = useListOrganizationsQuery()

// Debounce handle input for availability checking (1 second delay)
useEffect(() => {
if (!newOrgName.trim()) {
setDebouncedHandle(null)
return
}

const sdsHostname = new URL(SDS_SERVER_URL).hostname
const fullHandle = `${newOrgName.trim()}.${sdsHostname}`

// Validate format first
try {
ensureValidHandle(fullHandle)
} catch {
// Invalid format - don't check availability
setDebouncedHandle(null)
return
}

const timer = setTimeout(() => {
setDebouncedHandle(fullHandle)
}, 1000)

return () => clearTimeout(timer)
}, [newOrgName])

// Check handle availability (only when debounced handle is set and format is valid)
const handleAvailabilityQuery = useCheckHandleAvailabilityQuery(
debouncedHandle,
debouncedHandle !== null && handleValidationError === null,
)

// Validate handle in real-time
const handleOrgNameChange = (value: string) => {
setNewOrgName(value)

if (!value.trim()) {
setHandleValidationError(null)
return
}

// Construct the full repository handle with hostname suffix
// Use hostname (not host) to exclude port number, as handles cannot contain ports
const sdsHostname = new URL(SDS_SERVER_URL).hostname
const fullHandle = `${value.trim()}.${sdsHostname}`

// Validate the handle according to ATProto handle specification
try {
ensureValidHandle(fullHandle)
setHandleValidationError(null)
} catch (err) {
if (err instanceof InvalidHandleError) {
setHandleValidationError(err.message)
} else {
setHandleValidationError('Unknown validation error')
}
}
}

// Check if handle is valid (format valid AND available)
const isHandleFormatValid =
newOrgName.trim() !== '' && handleValidationError === null
const isHandleTaken =
debouncedHandle !== null &&
!handleAvailabilityQuery.isFetching &&
handleAvailabilityQuery.data === false
const isHandleAvailable =
debouncedHandle !== null && // Must have debounced (user stopped typing)
!handleAvailabilityQuery.isFetching && // Must have finished checking
handleAvailabilityQuery.data === true // Must be available
const isHandleValid =
isHandleFormatValid && isHandleAvailable && !isHandleTaken

// Helper functions for collaboration modal
const openCollaborationModal = (
repositoryDid: string,
Expand Down Expand Up @@ -130,8 +210,10 @@ export function RepositoryDashboard() {
const response = await retryApiCall(async () => {
console.log('[SDS Demo] Creating organization for user:', session.did)

// Send only the handle prefix - server will append hostname suffix
const requestPayload = {
name: newOrgName.trim(),
handlePrefix: newOrgName.trim(), // Prefix only - server appends hostname
description: newOrgDescription.trim() || undefined,
creatorDid: session.did,
}
Expand Down Expand Up @@ -185,6 +267,7 @@ export function RepositoryDashboard() {
setSelectedRepo(newOrg.did)
setNewOrgName('')
setNewOrgDescription('')
setHandleValidationError(null)
setShowCreateOrg(false)

alert(`Repository "${orgData.name}" created successfully!
Expand Down Expand Up @@ -259,7 +342,10 @@ You are the owner and can now invite collaborators to share this repository.`)
{repositories.length} repositories
</div>
<Button
onClick={() => setShowCreateOrg(true)}
onClick={() => {
setShowCreateOrg(true)
setHandleValidationError(null)
}}
size="small"
disabled={loading}
>
Expand All @@ -281,15 +367,57 @@ You are the owner and can now invite collaborators to share this repository.`)
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Repository Name *
Repository Handle *
</label>
<input
type="text"
value={newOrgName}
onChange={(e) => setNewOrgName(e.target.value)}
placeholder="Enter repository name"
className="mt-1 w-full rounded-lg border border-gray-300 p-2 focus:border-blue-500 focus:outline-none"
onChange={(e) => handleOrgNameChange(e.target.value)}
placeholder="Enter repository handle"
className={`mt-1 w-full rounded-lg border p-2 focus:outline-none ${
handleValidationError || isHandleTaken
? 'border-red-500 focus:border-red-600'
: isHandleFormatValid &&
isHandleAvailable &&
!handleAvailabilityQuery.isFetching
? 'border-green-500 focus:border-green-600'
: 'border-gray-300 focus:border-blue-500'
}`}
/>
<div className="mt-1 text-sm text-gray-600">
Your repository handle will be:{' '}
<span className="font-medium text-gray-900">
{newOrgName || '[handle]'}.{new URL(SDS_SERVER_URL).hostname}
</span>
</div>
{handleValidationError && (
<div className="mt-1 text-sm text-red-600">
<span className="font-medium">Invalid handle:</span>{' '}
{handleValidationError}
</div>
)}
{isHandleFormatValid && (
<>
{handleAvailabilityQuery.isFetching && (
<div className="mt-1 text-sm text-gray-500">
Checking availability...
</div>
)}
{isHandleTaken && (
<div className="mt-1 text-sm text-red-600">
<span className="font-medium">Handle already taken:</span>{' '}
This handle is already in use. Please choose another.
</div>
)}
{isHandleAvailable &&
!handleAvailabilityQuery.isFetching &&
debouncedHandle !== null && (
<div className="mt-1 text-sm text-green-600">
✓ Valid handle and available
</div>
)}
</>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Expand All @@ -306,7 +434,7 @@ You are the owner and can now invite collaborators to share this repository.`)
<div className="flex space-x-2">
<Button
onClick={createOrganization}
disabled={!newOrgName.trim() || loading}
disabled={!isHandleValid || loading}
size="small"
>
{loading ? <Spinner /> : 'Create Repository'}
Expand All @@ -316,6 +444,7 @@ You are the owner and can now invite collaborators to share this repository.`)
setShowCreateOrg(false)
setNewOrgName('')
setNewOrgDescription('')
setHandleValidationError(null)
}}
size="small"
disabled={loading}
Expand Down Expand Up @@ -347,7 +476,12 @@ You are the owner and can now invite collaborators to share this repository.`)
Create your first shared repository to start collaborating with
others.
</p>
<Button onClick={() => setShowCreateOrg(true)}>
<Button
onClick={() => {
setShowCreateOrg(true)
setHandleValidationError(null)
}}
>
Create Your First Repository
</Button>
</div>
Expand Down
37 changes: 21 additions & 16 deletions packages/sds-demo/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,29 @@ export const SDS_SERVER_URL: string =
// Note: These scopes are issued by PDS but NOT validated by SDS during authorization.
// SDS uses federated JWT validation (fetches JWKS from PDS) to verify token authenticity,
// then authorizes access solely based on SDS database permissions.
// Note: include:com.atproto.moderation.basePermissions is only available in local dev environment
// (localhost:2583) where the lexicon is registered via dev-env service profile.
// External PDS instances don't have this lexicon, so we only include it for localhost.
const isLocalDevPds =
ENV === 'development' &&
(SIGN_UP_URL.includes('localhost') || SIGN_UP_URL.includes('127.0.0.1'))

export const OAUTH_SCOPE: string =
searchParams.get('scope') ??
(ENV === 'development'
? [
'atproto',
'account:email',
'identity:*',
'repo:*',
'include:com.atproto.moderation.basePermissions',
].join(' ')
: [
'atproto',
'account:email',
'account:status',
'blob:*/*',
'repo:*',
'rpc:*?aud=did:web:bsky.app#bsky_appview',
].join(' '))
[
// Basic atproto scope - required for all AT Protocol operations
'atproto',
// Read account email address - used to display user email in UI
'account:email',
// Repository operations - needed for creating records in shared repositories
// via com.atproto.repo.createRecord and reading records via com.atproto.repo.getRecord
'repo:*',
// Only include moderation permissions lexicon if using local dev PDS
// (this lexicon is only registered in dev-env lexicon authority)
...(isLocalDevPds
? ['include:com.atproto.moderation.basePermissions']
: []),
].join(' ')

// Debug logging for configuration
console.log('[SDS Demo Config]', {
Expand Down
Loading
Loading