diff --git a/lexicons/com/sds/organization/create.json b/lexicons/com/sds/organization/create.json index 81cc360ea9b..f9463d2d79c 100644 --- a/lexicons/com/sds/organization/create.json +++ b/lexicons/com/sds/organization/create.json @@ -9,7 +9,7 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["name", "creatorDid"], + "required": ["name", "handlePrefix", "creatorDid"], "properties": { "name": { "type": "string", @@ -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", diff --git a/packages/oauth/oauth-provider-ui/src/views/authorize/authorize-view.tsx b/packages/oauth/oauth-provider-ui/src/views/authorize/authorize-view.tsx index 2a3b5c623e5..da43819cad7 100644 --- a/packages/oauth/oauth-provider-ui/src/views/authorize/authorize-view.tsx +++ b/packages/oauth/oauth-provider-ui/src/views/authorize/authorize-view.tsx @@ -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 ( (null) + const [selectedRepo] = useState(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(null) const [collaborationModal, setCollaborationModal] = useState<{ isOpen: boolean repositoryDid: string @@ -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, @@ -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, } @@ -185,6 +267,7 @@ export function RepositoryDashboard() { setSelectedRepo(newOrg.did) setNewOrgName('') setNewOrgDescription('') + setHandleValidationError(null) setShowCreateOrg(false) alert(`Repository "${orgData.name}" created successfully! @@ -259,7 +342,10 @@ You are the owner and can now invite collaborators to share this repository.`) {repositories.length} repositories diff --git a/packages/sds-demo/src/constants.ts b/packages/sds-demo/src/constants.ts index 1a84ccee469..851819465c8 100644 --- a/packages/sds-demo/src/constants.ts +++ b/packages/sds-demo/src/constants.ts @@ -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]', { diff --git a/packages/sds-demo/src/lib/sds-agent.ts b/packages/sds-demo/src/lib/sds-agent.ts index bce6b26ae5e..15404e3bb1f 100644 --- a/packages/sds-demo/src/lib/sds-agent.ts +++ b/packages/sds-demo/src/lib/sds-agent.ts @@ -18,7 +18,7 @@ const sdsLexicons: LexiconDoc[] = [ encoding: 'application/json', schema: { type: 'object', - required: ['name', 'creatorDid'], + required: ['name', 'handlePrefix', 'creatorDid'], properties: { name: { type: 'string', @@ -30,11 +30,11 @@ const sdsLexicons: LexiconDoc[] = [ maxLength: 500, description: 'Optional description of the organization.', }, - handle: { + handlePrefix: { type: 'string', - format: 'handle', + maxLength: 50, description: - 'Optional custom handle for the organization. If not provided, will be auto-generated.', + 'The handle prefix (part before the first dot). The SDS hostname will be automatically appended as the suffix.', }, creatorDid: { type: 'string', diff --git a/packages/sds-demo/src/lib/sds-lexicons.ts b/packages/sds-demo/src/lib/sds-lexicons.ts index 3b1d610baf7..4e8cfc0bab4 100644 --- a/packages/sds-demo/src/lib/sds-lexicons.ts +++ b/packages/sds-demo/src/lib/sds-lexicons.ts @@ -14,7 +14,7 @@ export const SDS_LEXICONS: LexiconDoc[] = [ encoding: 'application/json', schema: { type: 'object', - required: ['name', 'creatorDid'], + required: ['name', 'handlePrefix', 'creatorDid'], properties: { name: { type: 'string', @@ -26,10 +26,11 @@ export const SDS_LEXICONS: LexiconDoc[] = [ maxLength: 500, description: 'Optional description of the organization.', }, - handle: { + handlePrefix: { type: 'string', - format: 'handle', - description: 'Optional custom handle for the organization.', + maxLength: 50, + description: + 'The handle prefix (part before the first dot). The SDS hostname will be automatically appended as the suffix.', }, creatorDid: { type: 'string', diff --git a/packages/sds-demo/src/queries/use-get-actor-profile-query.ts b/packages/sds-demo/src/queries/use-get-actor-profile-query.ts index 1b87fc90ef0..6eb5e13a8ba 100644 --- a/packages/sds-demo/src/queries/use-get-actor-profile-query.ts +++ b/packages/sds-demo/src/queries/use-get-actor-profile-query.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query' +import { ComAtprotoRepoGetRecord } from '@atproto/api' import { useAuthContext } from '../auth/auth-provider.tsx' export function useGetActorProfileQuery() { @@ -8,12 +9,20 @@ export function useGetActorProfileQuery() { queryKey: ['profile', agent?.assertDid ?? null], queryFn: async () => { if (!agent) return null - const { data } = await agent.com.atproto.repo.getRecord({ - repo: agent.assertDid, - collection: 'app.bsky.actor.profile', - rkey: 'self', - }) - return data + try { + const { data } = await agent.com.atproto.repo.getRecord({ + repo: agent.assertDid, + collection: 'app.bsky.actor.profile', + rkey: 'self', + }) + return data + } catch (error) { + // Handle RecordNotFound gracefully - profile may not exist yet + if (error instanceof ComAtprotoRepoGetRecord.RecordNotFoundError) { + return null + } + throw error + } }, }) } diff --git a/packages/sds-demo/src/queries/use-sds-queries.ts b/packages/sds-demo/src/queries/use-sds-queries.ts index 0f479defe60..84de987e73e 100644 --- a/packages/sds-demo/src/queries/use-sds-queries.ts +++ b/packages/sds-demo/src/queries/use-sds-queries.ts @@ -2,6 +2,45 @@ import { useMutation, useQuery } from '@tanstack/react-query' import { useAuthContext } from '../auth/auth-provider.tsx' import { RepositoryPermissions } from '../services/collaboration-service.ts' +// Check if a handle is available (not taken) +export function useCheckHandleAvailabilityQuery( + handle: string | null, + enabled: boolean, +) { + const auth = useAuthContext() + + return useQuery({ + queryKey: ['sds', 'handle-availability', handle], + queryFn: async (): Promise => { + if (!handle || !auth.signedIn || !auth.agent) { + throw new Error('Handle or agent not available') + } + + try { + // Try to resolve the handle - if it succeeds, handle is taken + await auth.agent.call('com.atproto.identity.resolveHandle', { + handle, + }) + // If resolveHandle succeeds, handle is taken + return false + } catch (error: any) { + // If resolveHandle fails with "Unable to resolve handle", handle is available + if ( + error?.message?.includes('Unable to resolve handle') || + error?.status === 400 + ) { + return true + } + // Re-throw other errors + throw error + } + }, + enabled: enabled && !!handle && auth.signedIn && !!auth.agent, + retry: false, // Don't retry availability checks + staleTime: 30 * 1000, // Consider availability fresh for 30 seconds + }) +} + export interface SdsCollaborator { userDid: string handle?: string diff --git a/packages/sds/src/api/com/sds/organization/create.ts b/packages/sds/src/api/com/sds/organization/create.ts index 081cb50d4de..de52c465586 100644 --- a/packages/sds/src/api/com/sds/organization/create.ts +++ b/packages/sds/src/api/com/sds/organization/create.ts @@ -1,5 +1,6 @@ import * as plc from '@did-plc/lib' import { Secp256k1Keypair } from '@atproto/crypto' +import { InvalidHandleError, ensureValidHandle } from '@atproto/syntax' import { InvalidRequestError, XRPCError } from '@atproto/xrpc-server' import { AccountStatus } from '../../../../account-manager/helpers/account' import { Server } from '../../../../lexicon' @@ -22,7 +23,7 @@ export default function (server: Server, ctx: SdsAppContext) { }, ], handler: async ({ input, auth: _auth }) => { - const { name, description, handle, creatorDid } = input.body + const { name, description, handlePrefix, creatorDid } = input.body if (!creatorDid) { throw new InvalidRequestError('Creator DID is required') @@ -41,10 +42,36 @@ export default function (server: Server, ctx: SdsAppContext) { throw new InvalidRequestError('Organization name is required') } - // Generate a unique handle if not provided - const orgHandle = - handle || - `${name.trim().toLowerCase().replace(/\s+/g, '-')}-${Date.now()}` + if (!handlePrefix?.trim()) { + throw new InvalidRequestError('Handle prefix is required') + } + + // Extract hostname from SDS public URL and construct full handle + // Use hostname (not host) to exclude port number, as handles cannot contain ports + const sdsPublicUrl = ctx.cfg.service.publicUrl + const sdsHostname = new URL(sdsPublicUrl).hostname + const orgHandle = `${handlePrefix.trim()}.${sdsHostname}` + + // Validate handle format using @atproto/syntax + try { + ensureValidHandle(orgHandle) + } catch (err) { + if (err instanceof InvalidHandleError) { + throw new InvalidRequestError(err.message, 'InvalidHandle') + } + throw err + } + + // Check if handle already exists + const existingAccount = await ctx.accountManager.getAccount(orgHandle, { + includeDeactivated: true, + }) + if (existingAccount) { + throw new InvalidRequestError( + `Handle already taken: ${orgHandle}`, + 'HandleTaken', + ) + } try { // Create a new DID and signing key for the organization diff --git a/packages/sds/src/lexicon/lexicons.ts b/packages/sds/src/lexicon/lexicons.ts index a2034b927d5..1de5f9a9133 100644 --- a/packages/sds/src/lexicon/lexicons.ts +++ b/packages/sds/src/lexicon/lexicons.ts @@ -13800,7 +13800,7 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['name', 'creatorDid'], + required: ['name', 'handlePrefix', 'creatorDid'], properties: { name: { type: 'string', @@ -13812,11 +13812,11 @@ export const schemaDict = { maxLength: 500, description: 'Optional description of the organization.', }, - handle: { + handlePrefix: { type: 'string', - format: 'handle', + maxLength: 50, description: - 'Optional custom handle for the organization. If not provided, will be auto-generated.', + 'The handle prefix (part before the first dot). The SDS hostname will be automatically appended as the suffix.', }, creatorDid: { type: 'string', diff --git a/packages/sds/src/lexicon/types/com/sds/organization/create.ts b/packages/sds/src/lexicon/types/com/sds/organization/create.ts index 92637b5934c..12747cd1af1 100644 --- a/packages/sds/src/lexicon/types/com/sds/organization/create.ts +++ b/packages/sds/src/lexicon/types/com/sds/organization/create.ts @@ -22,8 +22,8 @@ export interface InputSchema { name: string /** Optional description of the organization. */ description?: string - /** Optional custom handle for the organization. If not provided, will be auto-generated. */ - handle?: string + /** The handle prefix (part before the first dot). The SDS hostname will be automatically appended as the suffix. */ + handlePrefix: string /** DID of the user creating the organization. */ creatorDid: string } diff --git a/packages/sds/tests/organization-creation.test.ts b/packages/sds/tests/organization-creation.test.ts index e759ae99c9b..7318e87828f 100644 --- a/packages/sds/tests/organization-creation.test.ts +++ b/packages/sds/tests/organization-creation.test.ts @@ -1,61 +1,137 @@ -import { TestNetwork } from '@atproto/dev-env' -import { SdsAppContext } from '../src/sds-context' +import { AtpAgent } from '@atproto/api' +import { TestNetworkWithSds } from '@atproto/dev-env' describe('organization creation', () => { - let network: TestNetwork - let ctx: SdsAppContext + let network: TestNetworkWithSds + let user: { did: string; agent: AtpAgent } beforeAll(async () => { - network = await TestNetwork.create({ - pds: { port: 0 }, - plc: { port: 0 }, - bsky: { port: 0 }, + network = await TestNetworkWithSds.create({ + dbPostgresSchema: 'organization_creation_test', }) - // Create a simple mock context for testing - ctx = { - authVerifier: { - authorization: () => ({ - auth: { credentials: { did: 'did:test:user' } }, - }), - }, - accountManager: { - createAccount: jest.fn().mockResolvedValue({ - did: 'did:test:org', - handle: 'test-org.sds.local', - }), - }, - sharedRepoManager: { - grantAccess: jest.fn().mockResolvedValue(undefined), - }, - idResolver: { - resolveIdentity: jest.fn().mockResolvedValue({ - did: 'did:test:org', - handle: 'test-org.sds.local', - }), + // Create test user on PDS + const sc = network.serviceHeaders + const pdsAgent = network.pds.getClient() + + const userAccount = await pdsAgent.com.atproto.server.createAccount( + { + handle: 'org-creator.test', + email: 'creator@test.com', + password: 'password123', }, - } as any + { headers: sc }, + ) + + user = { + did: userAccount.data.did, + agent: new AtpAgent({ service: network.pds.url }), // Login to PDS, not SDS + } + await user.agent.login({ + identifier: 'org-creator.test', + password: 'password123', + }) + + // Create a new agent pointing to SDS for organization creation calls + const sdsAgent = new AtpAgent({ service: network.sds.url }) + // Copy session from PDS login to SDS agent + if (user.agent.session) { + sdsAgent.session = user.agent.session + } + user.agent = sdsAgent }) afterAll(async () => { - await network?.close() + await network.close() }) - it('should create organization with proper permissions', async () => { - // This test verifies the business logic structure - // In a real test environment, we would test the actual endpoint - const organizationData = { - name: 'Test Organization', - description: 'A test organization', - } + it('should require handlePrefix to be provided', async () => { + await expect( + user.agent.call('com.sds.organization.create', undefined, { + name: 'Test Organization', + creatorDid: user.did, + }), + ).rejects.toThrow(/Handle prefix is required/) + }) + + it('should validate handle format', async () => { + // Invalid handle prefix (contains dot - server will append hostname) + await expect( + user.agent.call('com.sds.organization.create', undefined, { + name: 'Test Organization', + handlePrefix: 'invalid.handle', + creatorDid: user.did, + }), + ).rejects.toThrow(/InvalidHandle/) - // Verify the expected flow: - // 1. Create account for organization - // 2. Grant creator admin access - // 3. Return organization details + // Invalid handle format (double dots after server appends hostname) + await expect( + user.agent.call('com.sds.organization.create', undefined, { + name: 'Test Organization', + handlePrefix: 'invalid..prefix', + creatorDid: user.did, + }), + ).rejects.toThrow(/InvalidHandle/) + }) + + it('should reject duplicate handles', async () => { + const handlePrefix = 'test-org' + + // Create first organization + const firstResponse = await user.agent.call( + 'com.sds.organization.create', + undefined, + { + name: 'Test Organization', + handlePrefix, + creatorDid: user.did, + }, + ) + + const expectedHandle = `${handlePrefix}.${new URL(network.sds.url).hostname}` + expect(firstResponse.data.handle).toBe(expectedHandle) + + // Try to create second organization with same prefix + await expect( + user.agent.call('com.sds.organization.create', undefined, { + name: 'Another Organization', + handlePrefix, + creatorDid: user.did, + }), + ).rejects.toThrow(/Handle already taken/) + }) + + it('should create organization with valid handle prefix', async () => { + const handlePrefix = 'valid-org' + const sdsHostname = new URL(network.sds.url).host + const expectedHandle = `${handlePrefix}.${sdsHostname}` + + const response = await user.agent.call( + 'com.sds.organization.create', + undefined, + { + name: 'Valid Organization', + description: 'A valid test organization', + handlePrefix, + creatorDid: user.did, + }, + ) - expect(organizationData.name).toBe('Test Organization') - expect(ctx.accountManager).toBeDefined() - expect(ctx.sharedRepoManager).toBeDefined() + expect(response.data).toMatchObject({ + handle: expectedHandle, + name: 'Valid Organization', + description: 'A valid test organization', + permissions: { + read: true, + create: true, + update: true, + delete: true, + admin: true, + owner: true, + }, + accessType: 'owner', + }) + expect(response.data.did).toBeDefined() + expect(response.data.createdAt).toBeDefined() }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dcf8207002..5b26c4168be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1931,6 +1931,9 @@ importers: '@atproto/sds': specifier: workspace:* version: link:../sds + '@atproto/syntax': + specifier: workspace:^ + version: link:../syntax '@atproto/xrpc': specifier: workspace:* version: link:../xrpc @@ -1964,6 +1967,9 @@ importers: autoprefixer: specifier: ^10.4.17 version: 10.4.19(postcss@8.5.3) + dotenv: + specifier: ^16.4.5 + version: 16.6.1 postcss: specifier: ^8.4.33 version: 8.5.3 @@ -6408,6 +6414,10 @@ packages: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dotenv@8.6.0: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} @@ -16348,6 +16358,8 @@ snapshots: dotenv@16.0.3: {} + dotenv@16.6.1: {} + dotenv@8.6.0: {} dunder-proto@1.0.0: