fix(collections): moving a place out of a list needs the same role as deleting it

Moving a saved place to another list takes it out of the source list, but
updatePlace only asked for edit rights there, so an editor could empty a
shared list they may not delete from. The source now takes owner or admin,
like a delete, and the target still takes edit rights. REST and the
update_collection_place tool share the service path. The Move to list
action on desktop and phone follows the delete permission.
This commit is contained in:
Maurice
2026-10-12 09:17:02 +02:00
parent 6c8321cd63
commit aa1bb41cf5
11 changed files with 172 additions and 7 deletions
@@ -304,7 +304,7 @@ export class CollectionsMcp {
@Tool({
name: 'update_collection_place',
description:
"Update a saved place's name, address, coordinates (lat/lng), description, notes, status, category, links, tags, labels, image, or move it to another collection (set collection_id).",
"Update a saved place's name, address, coordinates (lat/lng), description, notes, status, category, links, tags, labels, image, or move it to another collection (set collection_id). Moving a place out of a list needs the same role there as deleting it (owner or admin).",
inputSchema: { placeId: idSchema, ...collectionPlaceUpdateRequestSchema.shape, links: linksInput },
annotations: TOOL_ANNOTATIONS_WRITE,
when: collectionsAddonOn,
@@ -198,9 +198,19 @@ export class CollectionsService {
/** Delete a place — owner or admin only. */
async assertCanDelete(userId: number, collectionId: number): Promise<void> {
await this.assertOwnerOrAdmin(userId, collectionId, 'Only an admin can delete places from this list');
}
/** Move a place out of a list to another one. It leaves the list just as a
* delete would, so it takes the same role. */
private async assertCanMoveOut(userId: number, collectionId: number): Promise<void> {
await this.assertOwnerOrAdmin(userId, collectionId, 'Only an admin can move places out of this list');
}
private async assertOwnerOrAdmin(userId: number, collectionId: number, refusal: string): Promise<void> {
const r = await this.roleOf(userId, collectionId);
if (r === null) httpError(404, 'Collection not found');
if (r !== 'owner' && r !== 'admin') httpError(403, 'Only an admin can delete places from this list');
if (r !== 'owner' && r !== 'admin') httpError(403, refusal);
}
private async ownerOf(collectionId: number): Promise<number> {
@@ -919,6 +929,7 @@ export class CollectionsService {
let movedTo: number | null = null;
if (body.collection_id !== undefined && body.collection_id !== currentCollection) {
await this.assertCanMoveOut(userId, currentCollection);
await this.assertCanEdit(userId, body.collection_id);
write.collection_id = body.collection_id;
write.owner_id = await this.ownerOf(body.collection_id);