feat(namespace): allow member overwrite of published skills via namespace setting

Add per-namespace allowMemberOverwrite (default false). When off, publish
owner-isolation behaves exactly as before. When on, any namespace member may
publish a new version to a (namespace, slug) coordinate already published by
another member; the version attaches to the original owner's Skill record
(Skill.ownerId unchanged, SkillVersion.createdBy records the actual publisher).
validateOnly dry-run applies the same rule. Setting is mutable via the existing
namespace update API (OWNER/ADMIN only) and exposed in the web namespace edit
dialog.
This commit is contained in:
felix021 2026-09-01 23:42:04 +08:00
parent 08723fd01a
commit ab7e02bda0
18 changed files with 304 additions and 45 deletions

View file

@ -13,5 +13,7 @@ public record NamespaceRequest(
String displayName,
@Size(max = 512, message = "{validation.namespace.description.size}")
String description
String description,
Boolean allowMemberOverwrite
) {}

View file

@ -14,6 +14,7 @@ public record NamespaceResponse(
String description,
NamespaceType type,
String avatarUrl,
boolean allowMemberOverwrite,
String createdBy,
Instant createdAt,
Instant updatedAt
@ -27,6 +28,7 @@ public record NamespaceResponse(
namespace.getDescription(),
namespace.getType(),
namespace.getAvatarUrl(),
namespace.isAllowMemberOverwrite(),
namespace.getCreatedBy(),
namespace.getCreatedAt(),
namespace.getUpdatedAt()

View file

@ -77,6 +77,7 @@ public class NamespacePortalCommandAppService {
request.displayName(),
request.description(),
null,
request.allowMemberOverwrite(),
userId
);
return NamespaceResponse.from(updated);

View file

@ -0,0 +1,2 @@
ALTER TABLE namespace
ADD COLUMN allow_member_overwrite BOOLEAN NOT NULL DEFAULT FALSE;

View file

@ -176,7 +176,7 @@ class NamespacePortalControllerTest {
updated.setType(NamespaceType.TEAM);
updated.setDescription("Updated description");
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(existing);
given(namespaceService.updateNamespace(1L, "Team A+", "Updated description", null, "owner-1"))
given(namespaceService.updateNamespace(1L, "Team A+", "Updated description", null, null, "owner-1"))
.willReturn(updated);
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/api/v1/namespaces/team-a")

View file

@ -31,6 +31,9 @@ public class Namespace {
@Column(name = "avatar_url", length = 512)
private String avatarUrl;
@Column(name = "allow_member_overwrite", nullable = false)
private boolean allowMemberOverwrite = false;
@Column(name = "created_by")
private String createdBy;
@ -71,6 +74,8 @@ public class Namespace {
public void setType(NamespaceType type) { this.type = type; }
public String getAvatarUrl() { return avatarUrl; }
public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; }
public boolean isAllowMemberOverwrite() { return allowMemberOverwrite; }
public void setAllowMemberOverwrite(boolean allowMemberOverwrite) { this.allowMemberOverwrite = allowMemberOverwrite; }
public String getCreatedBy() { return createdBy; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }

View file

@ -65,7 +65,7 @@ public class NamespaceService {
*/
@Transactional
public Namespace updateNamespace(Long namespaceId, String displayName, String description, String avatarUrl,
String operatorUserId) {
Boolean allowMemberOverwrite, String operatorUserId) {
Namespace namespace = namespaceRepository.findById(namespaceId)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId));
assertNotImmutable(namespace);
@ -81,6 +81,9 @@ public class NamespaceService {
if (avatarUrl != null) {
namespace.setAvatarUrl(avatarUrl);
}
if (allowMemberOverwrite != null) {
namespace.setAllowMemberOverwrite(allowMemberOverwrite);
}
return namespaceRepository.save(namespace);
}

View file

@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.event.ReviewSubmittedEvent;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
@ -174,11 +175,9 @@ public class SkillPublishService {
// 2. Check membership
boolean isSuperAdmin = platformRoles.contains("SUPER_ADMIN");
if (!isSuperAdmin) {
var member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId);
if (member.isEmpty()) {
errors.add("Publisher is not a member of namespace: " + namespaceSlug);
}
var member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId);
if (!isSuperAdmin && member.isEmpty()) {
errors.add("Publisher is not a member of namespace: " + namespaceSlug);
}
if (requiresSecurityScanner(visibility) && !securityScanService.isEnabled()) {
errors.add("error.security.scanner.required");
@ -234,6 +233,7 @@ public class SkillPublishService {
// 6. Slug conflict, archived skill, and version-exists checks
if (resolvedSlug != null && errors.isEmpty()) {
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), resolvedSlug);
boolean overwriteAllowed = namespace.isAllowMemberOverwrite() && member.isPresent();
for (Skill existing : existingSkills) {
if (existing.getOwnerId().equals(publisherId)) {
if (existing.getStatus() == SkillStatus.ARCHIVED) {
@ -249,7 +249,7 @@ public class SkillPublishService {
boolean hasPublished = !skillVersionRepository
.findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED)
.isEmpty();
if (hasPublished) {
if (hasPublished && !overwriteAllowed) {
errors.add("Name conflict: slug \"" + resolvedSlug + "\" is already published by another user");
break;
}
@ -349,10 +349,12 @@ public class SkillPublishService {
boolean isSuperAdmin = platformRoles.contains("SUPER_ADMIN");
// 2. Check publisher is member unless SUPER_ADMIN short-circuits permission checks
if (!isSuperAdmin && !bypassMembershipCheck) {
namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId)
.orElseThrow(() -> new DomainBadRequestException("error.skill.publish.publisher.notMember", namespaceSlug));
// 2. Check publisher is member unless SUPER_ADMIN short-circuits permission checks.
// Membership is still resolved for super admin / bypass callers: it gates the
// allowMemberOverwrite relaxation below (see canOverwriteOtherOwners).
var membership = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId);
if (!isSuperAdmin && !bypassMembershipCheck && membership.isEmpty()) {
throw new DomainBadRequestException("error.skill.publish.publisher.notMember", namespaceSlug);
}
// 3. Validate package
@ -401,41 +403,56 @@ public class SkillPublishService {
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug);
// Check if any other owner's skill has published versions
// Only PUBLISHED status blocks same-name publishing (UPLOADED/PENDING_REVIEW allowed)
// Only PUBLISHED status blocks same-name publishing (UPLOADED/PENDING_REVIEW allowed).
// When the namespace opts into allowMemberOverwrite and the publisher is a member, the
// published skill owned by another user becomes the publish target instead: the new
// version attaches to it so the (namespace, slug) coordinate keeps its identity and
// original owner (SkillVersion.createdBy still records the actual publisher).
Skill overwriteTarget = null;
for (Skill existing : existingSkills) {
if (!existing.getOwnerId().equals(publisherId)) {
boolean hasPublished = !skillVersionRepository
.findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED)
.isEmpty();
if (hasPublished) {
// Distinguish between PRIVATE and PUBLIC/NAMESPACE_ONLY conflicts
if (existing.getVisibility() == SkillVisibility.PRIVATE) {
throw new DomainBadRequestException("error.skill.publish.nameConflict.private", skillSlug);
} else {
throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug);
if (!canOverwriteOtherOwners(namespace, membership)) {
// Distinguish between PRIVATE and PUBLIC/NAMESPACE_ONLY conflicts
if (existing.getVisibility() == SkillVisibility.PRIVATE) {
throw new DomainBadRequestException("error.skill.publish.nameConflict.private", skillSlug);
} else {
throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug);
}
}
if (overwriteTarget == null) {
overwriteTarget = existing;
}
}
}
}
// Find or create skill for current user
Skill skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId)
.orElseGet(() -> {
Skill newSkill = new Skill(namespace.getId(), skillSlug, publisherId, visibility);
newSkill.setCreatedBy(publisherId);
try {
Skill savedSkill = skillRepository.save(newSkill);
// save() may defer the unique-constraint check until transaction commit.
// Flush here so this boundary can translate the coordinate race.
skillRepository.flush();
return savedSkill;
} catch (DataIntegrityViolationException ex) {
// A concurrent publish for the same (namespace, slug, owner) coordinate inserted
// the skill first and won the unique-constraint race. Surface a deterministic
// business conflict instead of leaking the violation as an HTTP 500.
throw new DomainBadRequestException("error.skill.publish.concurrentConflict", skillSlug);
}
});
Skill skill;
if (overwriteTarget != null) {
skill = overwriteTarget;
} else {
skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId)
.orElseGet(() -> {
Skill newSkill = new Skill(namespace.getId(), skillSlug, publisherId, visibility);
newSkill.setCreatedBy(publisherId);
try {
Skill savedSkill = skillRepository.save(newSkill);
// save() may defer the unique-constraint check until transaction commit.
// Flush here so this boundary can translate the coordinate race.
skillRepository.flush();
return savedSkill;
} catch (DataIntegrityViolationException ex) {
// A concurrent publish for the same (namespace, slug, owner) coordinate inserted
// the skill first and won the unique-constraint race. Surface a deterministic
// business conflict instead of leaking the violation as an HTTP 500.
throw new DomainBadRequestException("error.skill.publish.concurrentConflict", skillSlug);
}
});
}
if (skill.getStatus() == SkillStatus.ARCHIVED) {
throw new DomainBadRequestException("error.skill.publish.archived", skillSlug);
@ -690,6 +707,16 @@ public class SkillPublishService {
}
}
/**
* Overwriting another owner's published (namespace, slug) coordinate is allowed only when
* the namespace opts in via allowMemberOverwrite and the publisher actually is a namespace
* member. With the setting off (the default) the owner-isolation behavior is unchanged for
* everyone, including OWNER/ADMIN and SUPER_ADMIN.
*/
private boolean canOverwriteOtherOwners(Namespace namespace, java.util.Optional<NamespaceMember> membership) {
return namespace.isAllowMemberOverwrite() && membership.isPresent();
}
private void assertCanManageLifecycle(Skill skill,
String actorUserId,
Map<Long, NamespaceRole> userNamespaceRoles) {

View file

@ -95,6 +95,7 @@ class NamespaceServiceTest {
"New Name",
"New Desc",
"http://avatar.url",
null,
operatorUserId
);
@ -102,12 +103,30 @@ class NamespaceServiceTest {
verify(namespaceRepository).save(namespace);
}
@Test
void updateNamespace_shouldUpdateAllowMemberOverwrite() {
Long namespaceId = 1L;
String operatorUserId = "user-1";
Namespace namespace = new Namespace("slug", "Old Name", "user-1");
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId))
.thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER)));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
when(namespaceAccessPolicy.canMutateSettings(namespace)).thenReturn(true);
when(namespaceRepository.save(any(Namespace.class))).thenReturn(namespace);
namespaceService.updateNamespace(namespaceId, null, null, null, true, operatorUserId);
assertTrue(namespace.isAllowMemberOverwrite());
verify(namespaceRepository).save(namespace);
}
@Test
void updateNamespace_shouldThrowExceptionWhenNotFound() {
when(namespaceRepository.findById(1L)).thenReturn(Optional.empty());
assertThrows(DomainBadRequestException.class, () ->
namespaceService.updateNamespace(1L, "Name", "Desc", null, "user-1"));
namespaceService.updateNamespace(1L, "Name", "Desc", null, null, "user-1"));
}
@Test
@ -120,7 +139,7 @@ class NamespaceServiceTest {
.thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.MEMBER)));
assertThrows(DomainForbiddenException.class, () ->
namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId));
namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, null, operatorUserId));
}
@Test
@ -136,7 +155,7 @@ class NamespaceServiceTest {
when(namespaceAccessPolicy.canMutateSettings(namespace)).thenReturn(false);
assertThrows(DomainBadRequestException.class, () ->
namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId));
namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, null, operatorUserId));
}
@Test
@ -149,7 +168,7 @@ class NamespaceServiceTest {
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true);
assertThrows(DomainBadRequestException.class, () ->
namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId));
namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, null, operatorUserId));
}
@Test
@ -162,7 +181,7 @@ class NamespaceServiceTest {
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true);
DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () ->
namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId));
namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, null, operatorUserId));
assertEquals("error.namespace.system.immutable", exception.messageCode());
verify(namespaceMemberRepository, never()).findByNamespaceIdAndUserId(namespaceId, operatorUserId);

View file

@ -1395,6 +1395,171 @@ class SkillPublishServiceTest {
assertEquals("test-skill", result.slug());
}
@Test
void testPublishFromEntries_ShouldOverwriteOtherOwnersPublishedSkillWhenNamespaceAllows() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-200";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
namespace.setAllowMemberOverwrite(true);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
// Existing skill owned by another user with a published version
Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC);
setId(existingSkill, 1L);
SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100");
publishedVersion.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
List<SkillVersion> savedVersions = new java.util.ArrayList<>();
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
if (saved.getId() == null) setId(saved, 10L);
savedVersions.add(saved);
return saved;
});
List<Skill> savedSkills = new java.util.ArrayList<>();
when(skillRepository.save(any(Skill.class))).thenAnswer(invocation -> {
savedSkills.add(invocation.getArgument(0));
return invocation.getArgument(0);
});
SkillPublishService.PublishResult result = service.publishFromEntries(
namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()
);
// Overwrite lands on the original owner's skill record: ownership preserved,
// actual publisher recorded on the version.
assertNotNull(result);
assertEquals("test-skill", result.slug());
assertEquals(1, savedVersions.size());
assertEquals(1L, savedVersions.get(0).getSkillId());
assertEquals(publisherId, savedVersions.get(0).getCreatedBy());
assertEquals(1, savedSkills.size());
assertEquals("user-100", savedSkills.get(0).getOwnerId());
assertEquals(publisherId, savedSkills.get(0).getUpdatedBy());
}
@Test
void testPublishFromEntries_ShouldOverwritePrivateSkillWhenNamespaceAllows() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-200";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
namespace.setAllowMemberOverwrite(true);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PRIVATE);
setId(existingSkill, 1L);
SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100");
publishedVersion.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
if (saved.getId() == null) setId(saved, 10L);
return saved;
});
when(skillRepository.save(any(Skill.class))).thenAnswer(invocation -> invocation.getArgument(0));
SkillPublishService.PublishResult result = service.publishFromEntries(
namespaceSlug, entries, publisherId, SkillVisibility.PRIVATE, Set.of()
);
assertNotNull(result);
assertEquals("test-skill", result.slug());
}
@Test
void testValidateOnly_ShouldNotReportConflictWhenNamespaceAllows() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-200";
List<PackageEntry> entries = skillEntries("test-skill", "1.0.0");
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
namespace.setAllowMemberOverwrite(true);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PRIVATE);
setId(existingSkill, 1L);
SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100");
publishedVersion.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(anyString())).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion));
SkillPublishService.DryRunResult result = service.validateOnly(
namespaceSlug, entries, publisherId, SkillVisibility.PRIVATE, Set.of()
);
assertTrue(result.valid());
assertTrue(result.errors().isEmpty());
}
@Test
void testValidateOnly_ShouldReportConflictWhenNamespaceDisallows() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-200";
List<PackageEntry> entries = skillEntries("test-skill", "1.0.0");
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PUBLIC);
setId(existingSkill, 1L);
SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100");
publishedVersion.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(anyString())).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion));
SkillPublishService.DryRunResult result = service.validateOnly(
namespaceSlug, entries, publisherId, SkillVisibility.PRIVATE, Set.of()
);
assertFalse(result.valid());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("Name conflict")));
}
@Test
void testPublishFromEntries_ShouldAutoWithdrawPendingVersions() throws Exception {
String namespaceSlug = "test-ns";

View file

@ -763,14 +763,17 @@ export const namespaceApi = {
})
},
async update(slug: string, request: { displayName?: string; description?: string }): Promise<Namespace> {
const body: Record<string, string> = {}
async update(slug: string, request: { displayName?: string; description?: string; allowMemberOverwrite?: boolean }): Promise<Namespace> {
const body: Record<string, string | boolean> = {}
if (request.displayName !== undefined) {
body.displayName = request.displayName.trim()
}
if (request.description !== undefined) {
body.description = request.description === '' ? '' : request.description.trim()
}
if (request.allowMemberOverwrite !== undefined) {
body.allowMemberOverwrite = request.allowMemberOverwrite
}
return fetchJson<Namespace>(`/api/v1/namespaces/${normalizeNamespaceSlug(slug)}`, {
method: 'PUT',
headers: await ensureCsrfHeaders({

View file

@ -3754,6 +3754,7 @@ export interface components {
slug: string;
displayName: string;
description?: string;
allowMemberOverwrite?: boolean;
};
ApiResponseNamespaceResponse: {
/** Format: int32 */
@ -3775,6 +3776,7 @@ export interface components {
/** @enum {string} */
type?: "GLOBAL" | "TEAM";
avatarUrl?: string;
allowMemberOverwrite?: boolean;
createdBy?: string;
/** Format: date-time */
createdAt?: string;

View file

@ -108,6 +108,7 @@ export interface Namespace {
description?: string
type: 'GLOBAL' | 'TEAM'
avatarUrl?: string
allowMemberOverwrite: boolean
status: NamespaceStatus
createdAt: string
updatedAt?: string

View file

@ -28,11 +28,13 @@ export function EditNamespaceDialog({ namespace, children }: EditNamespaceDialog
const [open, setOpen] = useState(false)
const [displayName, setDisplayName] = useState(namespace.displayName)
const [description, setDescription] = useState(namespace.description ?? '')
const [allowMemberOverwrite, setAllowMemberOverwrite] = useState(namespace.allowMemberOverwrite)
const [displayNameError, setDisplayNameError] = useState<string | null>(null)
const resetDialog = () => {
setDisplayName(namespace.displayName)
setDescription(namespace.description ?? '')
setAllowMemberOverwrite(namespace.allowMemberOverwrite)
setDisplayNameError(null)
updateMutation.reset()
}
@ -56,6 +58,7 @@ export function EditNamespaceDialog({ namespace, children }: EditNamespaceDialog
slug: namespace.slug,
displayName: trimmedDisplayName,
description: description.trim(),
allowMemberOverwrite,
})
toast.success(t('namespaceEdit.saveSuccess'))
setOpen(false)
@ -97,6 +100,24 @@ export function EditNamespaceDialog({ namespace, children }: EditNamespaceDialog
rows={3}
/>
</div>
<div className="flex items-start space-x-2">
<input
id="edit-allow-member-overwrite"
type="checkbox"
checked={allowMemberOverwrite}
onChange={(event) => setAllowMemberOverwrite(event.target.checked)}
className="mt-1.5 h-4 w-4 shrink-0 accent-primary"
/>
<div className="space-y-1">
<Label htmlFor="edit-allow-member-overwrite">
{t('namespaceEdit.allowMemberOverwriteLabel')}
</Label>
<p className="text-xs text-muted-foreground">
{t('namespaceEdit.allowMemberOverwriteHint')}
</p>
</div>
</div>
</div>
{updateMutation.error ? (

View file

@ -1236,6 +1236,8 @@
"displayNameLabel": "Display Name",
"displayNameRequired": "Display name is required",
"descriptionLabel": "Description",
"allowMemberOverwriteLabel": "Allow member overwrite",
"allowMemberOverwriteHint": "When enabled, any namespace member may publish new versions to a skill slug already published by another member. When disabled (default), published slugs are locked to their original owner.",
"saveAction": "Save",
"saving": "Saving...",
"saveSuccess": "Namespace updated successfully",

View file

@ -1301,6 +1301,8 @@
"displayNameLabel": "Отображаемое имя",
"displayNameRequired": "Требуется отображаемое имя",
"descriptionLabel": "Описание",
"allowMemberOverwriteLabel": "Разрешить перезапись участниками",
"allowMemberOverwriteHint": "Если включено, любой участник пространства может публиковать новые версии skill с slug, уже опубликованным другим участником (версия сохраняется за исходным владельцем). По умолчанию выключено: опубликованный slug принадлежит первому опубликовавшему.",
"saveAction": "Сохранить",
"saving": "Сохранение...",
"saveSuccess": "Пространство имён успешно обновлено",

View file

@ -1236,6 +1236,8 @@
"displayNameLabel": "显示名称",
"displayNameRequired": "显示名称不能为空",
"descriptionLabel": "描述",
"allowMemberOverwriteLabel": "允许成员覆盖发布",
"allowMemberOverwriteHint": "开启后,空间内任意成员可向其他成员已发布的同名 skill 发布新版本(版本挂在原发布者名下)。默认关闭:已发布的 slug 归首次发布者所有。",
"saveAction": "保存",
"saving": "保存中...",
"saveSuccess": "命名空间已更新",

View file

@ -193,8 +193,8 @@ export function useUpdateNamespace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ slug, displayName, description }: { slug: string; displayName?: string; description?: string }) =>
namespaceApi.update(slug, { displayName, description }),
mutationFn: ({ slug, displayName, description, allowMemberOverwrite }: { slug: string; displayName?: string; description?: string; allowMemberOverwrite?: boolean }) =>
namespaceApi.update(slug, { displayName, description, allowMemberOverwrite }),
onSuccess: (namespace) => {
queryClient.invalidateQueries({ queryKey: ['namespaces', namespace.slug] })
queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] })