From a1707d5a61f72622050b31ed45872bd815e29585 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 9 Dec 2025 18:13:18 +0000 Subject: [PATCH 1/6] chore(oauth-provider-ui): remove unnecessary console logging --- .../oauth-provider-ui/src/views/authorize/authorize-view.tsx | 4 ---- 1 file changed, 4 deletions(-) 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 ( Date: Tue, 9 Dec 2025 18:09:57 +0000 Subject: [PATCH 2/6] fix(sds-demo): conditionally include moderation scope for local dev PDS Without this patch, the OAuth scope always includes include:com.atproto.moderation.basePermissions in development mode, regardless of which PDS instance is being used. This is a problem because com.atproto.moderation.basePermissions is a permission-set lexicon that is only registered in the local dev environment (via the dev-env LexiconAuthorityProfile). External PDS instances like pds-eu-west4.test.certified.app don't have this lexicon registered, causing OAuth authorization requests to fail with "invalid_scope" errors: "Could not resolve Lexicon for NSID (com.atproto.moderation.basePermissions)". This patch solves the problem by checking if the SIGN_UP_URL points to a localhost PDS instance before including the moderation permissions scope. The scope is now only included when using the local dev PDS (localhost:2583) where the lexicon is actually available, allowing the demo to work with both local and external PDS instances. Co-authored-by: Composer --- packages/sds-demo/src/constants.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/sds-demo/src/constants.ts b/packages/sds-demo/src/constants.ts index 1a84ccee469..747449a590d 100644 --- a/packages/sds-demo/src/constants.ts +++ b/packages/sds-demo/src/constants.ts @@ -45,6 +45,13 @@ 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' @@ -53,7 +60,10 @@ export const OAUTH_SCOPE: string = 'account:email', 'identity:*', 'repo:*', - 'include:com.atproto.moderation.basePermissions', + // Only include moderation permissions lexicon if using local dev PDS + ...(isLocalDevPds + ? ['include:com.atproto.moderation.basePermissions'] + : []), ].join(' ') : [ 'atproto', From 4288801c2a7344bc8b5c7b930edce1c52a48517b Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 9 Dec 2025 18:17:23 +0000 Subject: [PATCH 3/6] fix(sds-demo): reduce OAuth scopes to minimum required permissions Without this patch, the SDS demo requests overly broad OAuth scopes including identity:* (which allows changing handle and could steal accounts), account:status, blob:*/*, and rpc:*?aud=did:web:bsky.app#bsky_appview in production, even though the demo doesn't use these permissions. This is a problem because: - identity:* triggers scary "could steal your account" warnings in OAuth consent screens, reducing user trust - The demo requests permissions it doesn't actually need, violating principle of least privilege - Production and development scopes differed unnecessarily, making maintenance harder This patch solves the problem by: - Removing identity:* scope (DID is already in token, no identity scope needed) - Removing unused production scopes (account:status, blob, bsky.app RPC) - Unifying dev and prod scopes to the same minimal set since they're identical - Adding inline comments explaining why each remaining scope is needed: - atproto: basic scope required for all AT Protocol operations - account:email: read email address for UI display - repo:*: create and read records in shared repositories The moderation permissions lexicon scope remains conditionally included only for local dev PDS instances where it's available. Co-authored-by: Composer --- packages/sds-demo/src/constants.ts | 33 +++++++++++++----------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/packages/sds-demo/src/constants.ts b/packages/sds-demo/src/constants.ts index 747449a590d..851819465c8 100644 --- a/packages/sds-demo/src/constants.ts +++ b/packages/sds-demo/src/constants.ts @@ -54,25 +54,20 @@ const isLocalDevPds = export const OAUTH_SCOPE: string = searchParams.get('scope') ?? - (ENV === 'development' - ? [ - 'atproto', - 'account:email', - 'identity:*', - 'repo:*', - // Only include moderation permissions lexicon if using local dev PDS - ...(isLocalDevPds - ? ['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]', { From 1a9dffabfc8785ff9532b95c3b9dfae7f247f109 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 9 Dec 2025 18:24:56 +0000 Subject: [PATCH 4/6] fix(sds-demo): gracefully handle accounts without profiles --- .../queries/use-get-actor-profile-query.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) 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 + } }, }) } From fd9fd664bc66942e90e3bac0aa6ee2dfad759339 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 6 Nov 2025 16:49:04 -0300 Subject: [PATCH 5/6] feat(sds-demo): support dotenv loading of variables --- packages/sds-demo/.env.example | 18 ++++++++++++++++++ packages/sds-demo/.gitignore | 3 +++ packages/sds-demo/package.json | 1 + packages/sds-demo/rollup.config.js | 2 ++ pnpm-lock.yaml | 9 +++++++++ 5 files changed, 33 insertions(+) create mode 100644 packages/sds-demo/.env.example diff --git a/packages/sds-demo/.env.example b/packages/sds-demo/.env.example new file mode 100644 index 00000000000..263c9299d1b --- /dev/null +++ b/packages/sds-demo/.env.example @@ -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 diff --git a/packages/sds-demo/.gitignore b/packages/sds-demo/.gitignore index 1521c8b7652..49d223ffbec 100644 --- a/packages/sds-demo/.gitignore +++ b/packages/sds-demo/.gitignore @@ -1 +1,4 @@ dist +.env +.env.local +.env.*.local diff --git a/packages/sds-demo/package.json b/packages/sds-demo/package.json index 19bdf11ab7b..d93ea23d2a3 100644 --- a/packages/sds-demo/package.json +++ b/packages/sds-demo/package.json @@ -48,6 +48,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", diff --git a/packages/sds-demo/rollup.config.js b/packages/sds-demo/rollup.config.js index d436881dd84..3dde99399c2 100644 --- a/packages/sds-demo/rollup.config.js +++ b/packages/sds-demo/rollup.config.js @@ -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') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dcf8207002..93bd57d3bc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1964,6 +1964,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 +6411,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 +16355,8 @@ snapshots: dotenv@16.0.3: {} + dotenv@16.6.1: {} + dotenv@8.6.0: {} dunder-proto@1.0.0: From 8959d096a1ea319bf44fafc2410c30f1c30b1776 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 6 Nov 2025 17:15:14 -0300 Subject: [PATCH 6/6] fix(sds): require handlePrefix for org creation instead of auto-generating Previously, the SDS server was auto-generating organization handles by appending a timestamp (e.g., 'name-1234567890'), which created invalid handles that violated ATProto handle specification requirements. Now the API requires a unique handlePrefix to be specified for org creation, and the SDS server appends its hostname as the suffix to form a valid FQDN handle. This ensures: - Handles are valid according to ATProto specification - Handles have the correct suffix matching the SDS hostname - No timestamp-based collision risk - User has control over the handle prefix Includes both client-side and server-side validation using @atproto/syntax to ensure handle format compliance. --- lexicons/com/sds/organization/create.json | 8 +- packages/sds-demo/package.json | 1 + packages/sds-demo/src/components/button.tsx | 8 +- .../src/components/repository-dashboard.tsx | 150 +++++++++++++++- packages/sds-demo/src/lib/sds-agent.ts | 8 +- packages/sds-demo/src/lib/sds-lexicons.ts | 9 +- .../sds-demo/src/queries/use-sds-queries.ts | 39 ++++ .../src/api/com/sds/organization/create.ts | 37 +++- packages/sds/src/lexicon/lexicons.ts | 8 +- .../types/com/sds/organization/create.ts | 4 +- .../sds/tests/organization-creation.test.ts | 166 +++++++++++++----- pnpm-lock.yaml | 3 + 12 files changed, 362 insertions(+), 79 deletions(-) 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/sds-demo/package.json b/packages/sds-demo/package.json index d93ea23d2a3..6f4f1c1f1f5 100644 --- a/packages/sds-demo/package.json +++ b/packages/sds-demo/package.json @@ -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", diff --git a/packages/sds-demo/src/components/button.tsx b/packages/sds-demo/src/components/button.tsx index bbafc034937..e0cbaa12ccf 100644 --- a/packages/sds-demo/src/components/button.tsx +++ b/packages/sds-demo/src/components/button.tsx @@ -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(' ')} diff --git a/packages/sds-demo/src/components/repository-dashboard.tsx b/packages/sds-demo/src/components/repository-dashboard.tsx index e9c95961fdd..78dac95345c 100644 --- a/packages/sds-demo/src/components/repository-dashboard.tsx +++ b/packages/sds-demo/src/components/repository-dashboard.tsx @@ -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' @@ -50,11 +53,15 @@ export function RepositoryDashboard() { const { repositories, addRepository, setSelectedRepo } = useRepositoryContext() const [loading, setLoading] = useState(false) - const [selectedRepo, setSelectedRepoLocal] = useState(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/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-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 93bd57d3bc3..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