fix(projects): preserve revised channel membership

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-08-31 16:54:06 -04:00
parent d0542ccc9f
commit 267244d520
16 changed files with 367 additions and 219 deletions

View File

@@ -17,12 +17,13 @@
//! not in scope.
use buzz_core::kind::KIND_PROJECT;
use buzz_core::project_revision::{ProjectCoordinate, ProjectRevision};
use buzz_core::project_revision::{apply_project_revision, ProjectCoordinate, ProjectRevision};
use buzz_sdk::{
build_delete_addressable, build_project, build_project_revision, build_project_with_tags,
ProjectMemberCoord, PROJECT_D_MAX_LEN,
};
use nostr::{Event, EventBuilder, PublicKey, Tag, Timestamp};
use uuid::Uuid;
use crate::agent_management::{build_project_channel, CreateProjectChannelDraft};
use crate::client::BuzzClient;
@@ -194,9 +195,20 @@ pub async fn add_repos_to_own_project(
let head = fetch_own_project(client, slug)
.await?
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
let effective_revision = current_project_revision(client, &head, slug).await?;
let next_ts = next_timestamp(&head, Timestamp::now())?;
let mut tags: Vec<Tag> = head.tags.iter().cloned().collect();
let home_channel = tags
.iter()
.find(|tag| tag_name(tag) == Some("buzz-channel"))
.and_then(tag_value)
.and_then(|value| value.parse::<Uuid>().ok());
carry_effective_related_channels(
&mut tags,
&effective_revision.related_channels,
home_channel,
)?;
let existing_coords: std::collections::HashSet<String> = head
.tags
.iter()
@@ -279,11 +291,33 @@ async fn fetch_own_project(client: &BuzzClient, slug: &str) -> Result<Option<Eve
fetch_project(client, slug, None).await
}
struct CurrentProjectRevision {
id: String,
related_channels: Vec<Uuid>,
}
fn carry_effective_related_channels(
tags: &mut Vec<Tag>,
related_channels: &[Uuid],
home_channel: Option<Uuid>,
) -> Result<(), CliError> {
tags.retain(|tag| tag_name(tag) != Some("buzz-related-channel"));
for channel_id in related_channels {
if Some(*channel_id) != home_channel {
tags.push(make_tag(&[
"buzz-related-channel",
&channel_id.to_string(),
])?);
}
}
Ok(())
}
async fn current_project_revision(
client: &BuzzClient,
project: &Event,
slug: &str,
) -> Result<String, CliError> {
) -> Result<CurrentProjectRevision, CliError> {
let coordinate = ProjectCoordinate {
owner: project.pubkey.to_hex(),
slug: slug.to_owned(),
@@ -303,16 +337,56 @@ async fn current_project_revision(
})
})
.collect::<Result<_, _>>()?;
let home_channel = project
.tags
.iter()
.find(|tag| tag_name(tag) == Some("buzz-channel"))
.and_then(tag_value)
.and_then(|value| value.parse::<Uuid>().ok());
let mut related_channels = Vec::new();
for channel in project
.tags
.iter()
.filter(|tag| tag_name(tag) == Some("buzz-related-channel"))
.filter_map(tag_value)
.filter_map(|value| value.parse::<Uuid>().ok())
{
if Some(channel) != home_channel
&& !related_channels.contains(&channel)
&& related_channels.len() < 64
{
related_channels.push(channel);
}
}
let parsed: Vec<(String, ProjectRevision)> = events
.iter()
.filter_map(|event| {
ProjectRevision::parse(event)
.ok()
.map(|revision| (event.id.to_hex(), revision))
})
.collect();
let mut current = project.id.to_hex();
loop {
let next = events.iter().find(|candidate| {
ProjectRevision::parse(candidate)
.is_ok_and(|revision| revision.expected_revision == current)
});
let Some(next) = next else { break };
current = next.id.to_hex();
let Some((event_id, revision)) = parsed
.iter()
.find(|(_, revision)| revision.expected_revision == current)
else {
break;
};
apply_project_revision(
&mut related_channels,
home_channel,
revision.operation,
revision.channel_id,
)
.map_err(|message| CliError::Other(format!("invalid Project revision chain: {message}")))?;
current.clone_from(event_id);
}
Ok(current)
Ok(CurrentProjectRevision {
id: current,
related_channels,
})
}
async fn cmd_related_channel_revision(
@@ -327,7 +401,7 @@ async fn cmd_related_channel_revision(
let project = fetch_project(client, slug, owner)
.await?
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
let expected_revision = current_project_revision(client, &project, slug).await?;
let expected_revision = current_project_revision(client, &project, slug).await?.id;
let coordinate = ProjectCoordinate {
owner: project.pubkey.to_hex(),
slug: slug.to_owned(),
@@ -337,12 +411,24 @@ async fn cmd_related_channel_revision(
build_project_revision(&coordinate, &expected_revision, operation, channel_id)
.map_err(crate::validate::sdk_err)?,
)?;
let raw = client.submit_event(event).await?;
let raw = client
.submit_event(event)
.await
.map_err(map_project_revision_submit_error)?;
let response = parse_write_response(&raw, "Project changed concurrently; refresh and retry")?;
println!("{response}");
Ok(())
}
fn map_project_revision_submit_error(error: CliError) -> CliError {
match error {
CliError::Relay { body, .. } if body.starts_with("conflict:") => {
CliError::Conflict("Project changed concurrently; refresh and retry".into())
}
error => error,
}
}
// ── Tag helpers ───────────────────────────────────────────────────────────────
fn tag_name(tag: &Tag) -> Option<&str> {
@@ -602,6 +688,7 @@ pub async fn cmd_remove_repo(
let head = fetch_own_project(client, slug)
.await?
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
let effective_revision = current_project_revision(client, &head, slug).await?;
let next_ts = next_timestamp(&head, Timestamp::now())?;
// Verify all requested repos exist in the project.
@@ -624,7 +711,7 @@ pub async fn cmd_remove_repo(
to_remove.iter().map(|m| m.coord.as_str()).collect();
// Keep all tags except auth and the removed members.
let tags: Vec<Tag> = head
let mut tags: Vec<Tag> = head
.tags
.iter()
.filter(|t| {
@@ -640,6 +727,16 @@ pub async fn cmd_remove_repo(
})
.cloned()
.collect();
let home_channel = tags
.iter()
.find(|tag| tag_name(tag) == Some("buzz-channel"))
.and_then(tag_value)
.and_then(|value| value.parse::<Uuid>().ok());
carry_effective_related_channels(
&mut tags,
&effective_revision.related_channels,
home_channel,
)?;
// Single rebuild validates the full envelope and strips any remaining auth.
let builder = rebuild_project(&head.content, tags, next_ts)?;
@@ -694,6 +791,7 @@ pub async fn cmd_update(
let head = fetch_own_project(client, slug)
.await?
.ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?;
let effective_revision = current_project_revision(client, &head, slug).await?;
let next_ts = next_timestamp(&head, Timestamp::now())?;
// Build the new tag set. For each singleton metadata field:
@@ -706,7 +804,7 @@ pub async fn cmd_update(
.tags
.iter()
.filter(|t| {
if tag_name(t) == Some("auth") {
if tag_name(t) == Some("auth") || tag_name(t) == Some("buzz-related-channel") {
return false;
}
// Drop singletons we're replacing or clearing.
@@ -740,6 +838,16 @@ pub async fn cmd_update(
if let Some(vis) = visibility {
tags.push(make_tag(&["buzz-visibility", vis])?);
}
let home_channel = tags
.iter()
.find(|tag| tag_name(tag) == Some("buzz-channel"))
.and_then(tag_value)
.and_then(|value| value.parse::<Uuid>().ok());
carry_effective_related_channels(
&mut tags,
&effective_revision.related_channels,
home_channel,
)?;
let builder = build_project_with_tags(&head.content, tags)
.map_err(|e| CliError::Other(format!("envelope validation failed: {e}")))?
@@ -974,6 +1082,16 @@ mod tests {
use super::*;
#[test]
fn project_revision_http_conflict_uses_conflict_exit_category() {
let error = map_project_revision_submit_error(CliError::Relay {
status: 400,
body: "conflict: expected revision is stale".into(),
});
assert!(matches!(error, CliError::Conflict(_)));
assert_eq!(crate::error::exit_code(&error), 5);
}
async fn run_default_repo_create_race(
winning_channel: &str,
) -> (Result<(), CliError>, Vec<u16>) {
@@ -1387,6 +1505,26 @@ mod tests {
assert!(rebuild_project("", tags, ts).is_ok());
}
#[test]
fn carrying_revision_channels_replaces_stale_base_membership() {
let home = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap();
let stale = Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap();
let effective = Uuid::parse_str("33333333-3333-4333-8333-333333333333").unwrap();
let mut tags = vec![
make_test_tag(&["d", "platform"]),
make_test_tag(&["buzz-related-channel", &stale.to_string()]),
];
carry_effective_related_channels(&mut tags, &[home, effective], Some(home)).unwrap();
let related: Vec<_> = tags
.iter()
.filter(|tag| tag_name(tag) == Some("buzz-related-channel"))
.filter_map(tag_value)
.collect();
assert_eq!(related, vec![effective.to_string()]);
}
// ── clear-flag semantics ──────────────────────────────────────────────────
/// Build a minimal head Event for testing update semantics without the relay.

View File

@@ -187,6 +187,7 @@ async function ensureDefaultRepository({
const patched = buildProjectPatchTemplate({
liveHead,
ownerPubkey,
relatedChannelIds: project.relatedChannelIds,
repositoryAddresses: [
...new Set([
...project.repositoryAddresses,

View File

@@ -1,105 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildProjectRelatedChannelPatchTemplate } from "./projectChannelCreation.ts";
import {
MAX_PROJECT_RELATED_CHANNELS,
PROJECT_RELATED_CHANNEL_TAG,
} from "./projectModels.ts";
const OWNER = "a".repeat(64);
const OTHER = "b".repeat(64);
const CHANNEL_A = "11111111-1111-4111-8111-111111111111";
const CHANNEL_B = "22222222-2222-4222-8222-222222222222";
function liveHead(tags = []) {
return {
content: "",
created_at: 100,
id: "project-head",
kind: 30621,
pubkey: OWNER,
sig: "sig",
tags: [
["d", "sprout"],
["name", "Sprout"],
["buzz-channel", CHANNEL_A],
...tags,
],
};
}
test("appends a related channel tag and preserves the live head", () => {
const patched = buildProjectRelatedChannelPatchTemplate({
channelId: CHANNEL_B,
liveHead: liveHead([["description", "A project"]]),
ownerPubkey: OWNER,
});
assert.equal(patched.alreadyBound, false);
assert.deepEqual(patched.project.tags, [
["d", "sprout"],
["name", "Sprout"],
["buzz-channel", CHANNEL_A],
["description", "A project"],
[PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B],
]);
});
test("is idempotent when the related channel is already tagged", () => {
const patched = buildProjectRelatedChannelPatchTemplate({
channelId: CHANNEL_B,
liveHead: liveHead([[PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B]]),
ownerPubkey: OWNER,
});
assert.equal(patched.alreadyBound, true);
assert.equal(
patched.project.tags.filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG)
.length,
1,
);
});
test("refuses to bind the home channel as a related channel", () => {
assert.throws(
() =>
buildProjectRelatedChannelPatchTemplate({
channelId: CHANNEL_A,
liveHead: liveHead(),
ownerPubkey: OWNER,
}),
/already this project's home/,
);
});
test("only the project owner can add related channels", () => {
assert.throws(
() =>
buildProjectRelatedChannelPatchTemplate({
channelId: CHANNEL_B,
liveHead: liveHead(),
ownerPubkey: OTHER,
}),
/Only the project owner/,
);
});
test("caps extra related channels", () => {
const tags = Array.from(
{ length: MAX_PROJECT_RELATED_CHANNELS },
(_, index) => {
const suffix = String(index + 1).padStart(12, "0");
return [PROJECT_RELATED_CHANNEL_TAG, `33333333-3333-4333-8333-${suffix}`];
},
);
assert.throws(
() =>
buildProjectRelatedChannelPatchTemplate({
channelId: CHANNEL_B,
liveHead: liveHead(tags),
ownerPubkey: OWNER,
}),
/extra channels/,
);
});

View File

@@ -1,71 +0,0 @@
import type { RelayEvent } from "@/shared/api/types";
import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds";
import {
isValidProjectChannelId,
MAX_PROJECT_RELATED_CHANNELS,
PROJECT_RELATED_CHANNEL_TAG,
validateProjectEventEnvelope,
} from "@/features/projects/projectModels";
import type { ProjectEventTemplate } from "./projectCreation";
/**
* Appends a `buzz-related-channel` tag to a live project head. Every other
* tag is preserved so adding a stream cannot erase unknown metadata.
*/
export function buildProjectRelatedChannelPatchTemplate({
channelId,
liveHead,
ownerPubkey,
}: {
channelId: string;
liveHead: RelayEvent;
ownerPubkey: string;
}): { alreadyBound: boolean; project: ProjectEventTemplate } {
const normalizedOwner = ownerPubkey.trim().toLowerCase();
if (normalizedOwner !== liveHead.pubkey.toLowerCase()) {
throw new Error("Only the project owner can add channels.");
}
const normalizedChannelId = channelId.trim();
if (!isValidProjectChannelId(normalizedChannelId)) {
throw new Error("Project channel is invalid.");
}
const homeChannelId = liveHead.tags.find(
(tag) => tag[0] === "buzz-channel",
)?.[1];
if (homeChannelId === normalizedChannelId) {
throw new Error("That channel is already this project's home.");
}
const existingRelated = liveHead.tags
.filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG)
.map((tag) => tag[1])
.filter((value): value is string => Boolean(value));
if (existingRelated.includes(normalizedChannelId)) {
validateProjectEventEnvelope(liveHead.tags, liveHead.content);
return {
alreadyBound: true,
project: {
kind: KIND_PROJECT_ANNOUNCEMENT,
content: liveHead.content,
tags: liveHead.tags.map((tag) => [...tag]),
},
};
}
if (existingRelated.length >= MAX_PROJECT_RELATED_CHANNELS) {
throw new Error(
`A project cannot contain more than ${MAX_PROJECT_RELATED_CHANNELS} extra channels.`,
);
}
const tags = [
...liveHead.tags.map((tag) => [...tag]),
[PROJECT_RELATED_CHANNEL_TAG, normalizedChannelId],
];
validateProjectEventEnvelope(tags, liveHead.content);
return {
alreadyBound: false,
project: {
kind: KIND_PROJECT_ANNOUNCEMENT,
content: liveHead.content,
tags,
},
};
}

View File

@@ -134,6 +134,35 @@ test("buildProjectReadModels keeps extra related channel ids", () => {
assert.deepEqual(projects[0].relatedChannelIds, [relatedA, relatedB]);
});
test("buildProjectReadModels normalizes channel UUID tags before folding revisions", () => {
const home = "11111111-1111-4111-8111-111111111111";
const related = "abcdefab-cdef-4abc-8def-abcdefabcdef";
const base = projectEvent([["buzz-related-channel", related.toUpperCase()]], {
id: "a".repeat(64),
tags: [
["d", "sprout"],
["buzz-channel", home.toUpperCase()],
["buzz-related-channel", related.toUpperCase()],
],
});
const [project] = buildProjectReadModels({
projectEvents: [base],
projectRevisionEvents: [
projectRevision(
"b".repeat(64),
base.id,
"remove-related-channel",
related.toUpperCase(),
),
],
repositoryEvents: [],
relayOrigin: RELAY_ORIGIN,
});
assert.equal(project.projectChannelId, home);
assert.deepEqual(project.relatedChannelIds, []);
});
test("buildProjectReadModels folds the relay-authorized related-channel revision chain", () => {
const relatedA = "22222222-2222-4222-8222-222222222222";
const relatedB = "33333333-3333-4333-8333-333333333333";

View File

@@ -130,6 +130,11 @@ export function isValidProjectChannelId(value: string): boolean {
);
}
function normalizedProjectChannelId(value: string): string | null {
const normalized = value.trim().toLowerCase();
return isValidProjectChannelId(normalized) ? normalized : null;
}
/** Repeatable project tag naming an extra stream besides `buzz-channel`. */
export const PROJECT_RELATED_CHANNEL_TAG = "buzz-related-channel";
@@ -366,14 +371,15 @@ export function eventToExplicitProject(
const visibility =
rawVisibility === "unlisted" ? ("unlisted" as const) : ("listed" as const);
const channel = getTag(event, "buzz-channel");
const projectChannelId =
channel && isValidProjectChannelId(channel) ? channel : null;
const projectChannelId = channel ? normalizedProjectChannelId(channel) : null;
const relatedChannelIds = [
...new Set(
getAllTags(event, PROJECT_RELATED_CHANNEL_TAG).filter(
(channelId) =>
isValidProjectChannelId(channelId) && channelId !== projectChannelId,
),
getAllTags(event, PROJECT_RELATED_CHANNEL_TAG).flatMap((channelId) => {
const normalized = normalizedProjectChannelId(channelId);
return normalized && normalized !== projectChannelId
? [normalized]
: [];
}),
),
].slice(0, MAX_PROJECT_RELATED_CHANNELS);
return {
@@ -559,8 +565,11 @@ export function applyProjectRevisionEvents(
);
if (!next) break;
const operation = singletonRevisionTag(next, "op");
const channelId = singletonRevisionTag(next, "channel");
if (!channelId || !isValidProjectChannelId(channelId)) break;
const rawChannelId = singletonRevisionTag(next, "channel");
const channelId = rawChannelId
? normalizedProjectChannelId(rawChannelId)
: null;
if (!channelId) break;
if (operation === "add-related-channel") {
if (
channelId === project.projectChannelId ||
@@ -656,11 +665,16 @@ export function addRelatedChannelToProject(
channelId: string,
createdAt: number,
): Project {
const normalizedChannelId = normalizedProjectChannelId(channelId);
const relatedChannelIds = [
...new Set([...(project.relatedChannelIds ?? []), channelId]),
].filter(
(id) => id !== project.projectChannelId && isValidProjectChannelId(id),
);
...new Set([
...(project.relatedChannelIds ?? []).flatMap((id) => {
const normalized = normalizedProjectChannelId(id);
return normalized ? [normalized] : [];
}),
...(normalizedChannelId ? [normalizedChannelId] : []),
]),
].filter((id) => id !== project.projectChannelId?.toLowerCase());
return {
...project,
createdAt,

View File

@@ -88,6 +88,33 @@ test("buildProjectPatchTemplate preserves unknown tags from the live head", () =
assert.deepEqual(memberTags.sort(), [existingAddress, newAddress].sort());
});
test("buildProjectPatchTemplate carries folded related channels into a new base", () => {
const related = "22222222-2222-4222-8222-222222222222";
const liveHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [
["d", "platform"],
["buzz-channel", "11111111-1111-4111-8111-111111111111"],
],
};
const template = buildProjectPatchTemplate({
liveHead,
ownerPubkey: OWNER,
relatedChannelIds: [related.toUpperCase()],
repositoryAddresses: [],
});
assert.deepEqual(
template.tags.filter((tag) => tag[0] === "buzz-related-channel"),
[["buzz-related-channel", related]],
);
});
test("buildProjectPatchTemplate preserves relay hints on existing members", () => {
const OWNER = "a".repeat(64);
const OTHER = "b".repeat(64);

View File

@@ -3,6 +3,8 @@ import type { Repository } from "@/features/projects/hooks";
import {
isValidProjectChannelId,
MAX_PROJECT_MEMBERS,
MAX_PROJECT_RELATED_CHANNELS,
PROJECT_RELATED_CHANNEL_TAG,
validateProjectEventEnvelope,
} from "@/features/projects/projectModels";
import {
@@ -22,10 +24,11 @@ export { repositoryDtagFromName };
/**
* Creates a project-replacement event template from a live, signed raw head
* (fetched immediately before the mutation). Only the `a` membership tags are
* patched; every other tag and the `content` field are preserved verbatim,
* satisfying NIP-MP's extension-tag preservation rule and preventing a cached
* UI projection from silently erasing unknown tags.
* (fetched immediately before the mutation). Repository membership is patched,
* and callers with a folded Project model carry its effective related channels
* into the new base. Every other tag and the `content` field are preserved
* verbatim, satisfying NIP-MP's extension-tag preservation rule and preventing
* a cached UI projection from silently erasing unknown tags.
*
* Performs full NIP-MP envelope validation on the patched output via the shared
* `validateProjectEventEnvelope` validator — the same checks applied by the
@@ -35,10 +38,12 @@ export { repositoryDtagFromName };
function buildProjectPatchTemplate({
liveHead,
ownerPubkey,
relatedChannelIds,
repositoryAddresses,
}: {
liveHead: RelayEvent;
ownerPubkey: string;
relatedChannelIds?: string[];
repositoryAddresses: string[];
}): ProjectEventTemplate {
const normalizedOwner = ownerPubkey.trim().toLowerCase();
@@ -64,7 +69,12 @@ function buildProjectPatchTemplate({
// Replace all existing `a` tags with the new set, preserving everything else
// (d, name, description, buzz-channel, buzz-visibility, relay hints embedded
// in `a` tags, and any future/unknown tags).
const nonMemberTags = liveHead.tags.filter((tag) => tag[0] !== "a");
const nonMemberTags = liveHead.tags.filter(
(tag) =>
tag[0] !== "a" &&
(relatedChannelIds === undefined ||
tag[0] !== PROJECT_RELATED_CHANNEL_TAG),
);
const existingHints = new Map<string, string>();
for (const tag of liveHead.tags) {
if (tag[0] === "a" && tag[1] && tag[2]) {
@@ -76,7 +86,27 @@ function buildProjectPatchTemplate({
return hint ? ["a", address, hint] : ["a", address];
});
const patchedTags = [...nonMemberTags, ...memberTags];
const homeChannelId = liveHead.tags
.find((tag) => tag[0] === "buzz-channel")?.[1]
?.toLowerCase();
const relatedChannelTags = [
...new Set(
(relatedChannelIds ?? []).map((channelId) =>
channelId.trim().toLowerCase(),
),
),
]
.filter(
(channelId) =>
isValidProjectChannelId(channelId) && channelId !== homeChannelId,
)
.slice(0, MAX_PROJECT_RELATED_CHANNELS)
.map((channelId) => [PROJECT_RELATED_CHANNEL_TAG, channelId]);
const patchedTags = [
...nonMemberTags,
...(relatedChannelIds === undefined ? [] : relatedChannelTags),
...memberTags,
];
const content = liveHead.content;
// Validate the full patched envelope against NIP-MP rules. This catches
@@ -161,6 +191,7 @@ export function buildAddedRepositoryEventTemplatesFromHead({
liveHead,
name,
ownerPubkey,
relatedChannelIds,
repositoryHeadExists = true,
webUrl,
}: {
@@ -171,6 +202,7 @@ export function buildAddedRepositoryEventTemplatesFromHead({
liveHead: RelayEvent;
name: string;
ownerPubkey: string;
relatedChannelIds?: string[];
/**
* Whether a kind-30617 head already exists at the new coordinate. When the
* live project head references the coordinate but no repository head exists
@@ -248,6 +280,7 @@ export function buildAddedRepositoryEventTemplatesFromHead({
const projectTemplate = buildProjectPatchTemplate({
liveHead,
ownerPubkey,
relatedChannelIds,
repositoryAddresses: newAddresses,
});

View File

@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { canManageProjectChannels } from "./ProjectChannelManagement.tsx";
const OWNER = "a".repeat(64);
const VIEWER = "b".repeat(64);
function project(overrides = {}) {
return {
legacy: false,
owner: OWNER,
...overrides,
};
}
test("Project owner and home-channel owner/admin can manage related channels", () => {
assert.equal(canManageProjectChannels(project(), OWNER), true);
assert.equal(canManageProjectChannels(project(), VIEWER, "owner"), true);
assert.equal(canManageProjectChannels(project(), VIEWER, "admin"), true);
});
test("members, guests, bots, unrelated identities, and legacy Projects cannot manage channels", () => {
for (const role of ["member", "guest", "bot", undefined]) {
assert.equal(canManageProjectChannels(project(), VIEWER, role), false);
}
assert.equal(
canManageProjectChannels(project({ legacy: true }), OWNER, "owner"),
false,
);
});

View File

@@ -7,6 +7,7 @@ import { useChannelMembersQuery } from "@/features/channels/hooks";
import type { Project } from "@/features/projects/hooks";
import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel";
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import type { ChannelMember } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
export function ProjectChannelManagement({
@@ -70,6 +71,14 @@ export function useCanManageProjectChannels(
const viewerHomeRole = homeMembersQuery.data?.find(
(member) => member.pubkey.toLowerCase() === identityPubkey?.toLowerCase(),
)?.role;
return canManageProjectChannels(project, identityPubkey, viewerHomeRole);
}
export function canManageProjectChannels(
project: Project,
identityPubkey?: string,
viewerHomeRole?: ChannelMember["role"],
): boolean {
return (
!project.legacy &&
(identityPubkey?.toLowerCase() === project.owner.toLowerCase() ||

View File

@@ -1,17 +1,25 @@
import { useQueries } from "@tanstack/react-query";
import * as React from "react";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import {
CHANNEL_MEMBERS_STALE_TIME_MS,
channelMembersQueryKey,
} from "@/features/channels/rosterFreshness";
import type { Project } from "@/features/projects/hooks";
import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel";
import { useAddProjectRepositoryMutation } from "@/features/projects/useAddProjectRepository";
import { AddProjectRepositoryDialog } from "@/features/projects/ui/AddProjectRepositoryDialog";
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import { getChannelMembers } from "@/shared/api/tauri";
import { canManageProjectChannels } from "@/features/projects/ui/ProjectChannelManagement";
export function ProjectsCategoryCreateDialogs({
channelOpen,
editableProjects,
identityPubkey,
onChannelOpenChange,
onRepositoryOpenChange,
ownerControlAgentPubkeyFor,
@@ -19,16 +27,40 @@ export function ProjectsCategoryCreateDialogs({
}: {
channelOpen: boolean;
editableProjects: Project[];
identityPubkey?: string;
onChannelOpenChange: (open: boolean) => void;
onRepositoryOpenChange: (open: boolean) => void;
ownerControlAgentPubkeyFor: (project: Project) => string | undefined;
repositoryOpen: boolean;
}) {
const { goChannel, goProject } = useAppNavigation();
const homeRosterQueries = useQueries({
queries: editableProjects.map((project) => {
const homeChannelId = project.projectChannelId;
return {
enabled:
channelOpen &&
Boolean(homeChannelId) &&
project.owner.toLowerCase() !== identityPubkey?.toLowerCase(),
queryFn: () => {
if (!homeChannelId) throw new Error("Project has no home channel.");
return getChannelMembers(homeChannelId);
},
queryKey: channelMembersQueryKey(homeChannelId ?? "none"),
staleTime: CHANNEL_MEMBERS_STALE_TIME_MS,
};
}),
});
const channelProjects = editableProjects.filter((project, index) => {
const role = homeRosterQueries[index]?.data?.find(
(member) => member.pubkey.toLowerCase() === identityPubkey?.toLowerCase(),
)?.role;
return canManageProjectChannels(project, identityPubkey, role);
});
const [channelProjectId, setChannelProjectId] = React.useState("");
const channelProject =
editableProjects.find((project) => project.id === channelProjectId) ??
editableProjects[0];
channelProjects.find((project) => project.id === channelProjectId) ??
channelProjects[0];
const createChannelMutation = useAddProjectChannelMutation();
const createRepositoryMutation = useAddProjectRepositoryMutation();
const channelsQuery = useChannelsQuery({ enabled: repositoryOpen });
@@ -57,7 +89,6 @@ export function ProjectsCategoryCreateDialogs({
if (!channelProject) throw new Error("Choose a project.");
const result = await createChannelMutation.mutateAsync({
...input,
ownerControlAgentPubkey: ownerControlAgentPubkeyFor(channelProject),
project: channelProject,
});
toast.success(`Channel "#${result.channel.name}" created.`);
@@ -76,7 +107,7 @@ export function ProjectsCategoryCreateDialogs({
onChange={(event) => setChannelProjectId(event.target.value)}
value={channelProject?.id ?? ""}
>
{editableProjects.map((project) => (
{channelProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>

View File

@@ -781,6 +781,7 @@ export function ProjectsView() {
<ProjectsCategoryCreateDialogs
channelOpen={createChannelOpen}
editableProjects={editableProjects}
identityPubkey={currentPubkey}
onChannelOpenChange={setCreateChannelOpen}
onRepositoryOpenChange={setCreateRepositoryOpen}
ownerControlAgentPubkeyFor={ownerControlAgentPubkeyFor}

View File

@@ -23,7 +23,6 @@ import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds";
export type AddProjectChannelInput = {
description?: string;
name: string;
ownerControlAgentPubkey?: string;
project: Project;
templateId?: string;
ttlSeconds?: number;

View File

@@ -107,6 +107,7 @@ export async function addProjectRepository(
existingRepositoryAddresses: project.repositoryAddresses,
liveHead,
ownerPubkey: targetOwner,
relatedChannelIds: project.relatedChannelIds,
repositoryHeadExists: existingRepoHeads.length > 0,
});

View File

@@ -57,6 +57,7 @@ async function attachProjectRepository({
const template = buildProjectPatchTemplate({
liveHead,
ownerPubkey: targetOwner,
relatedChannelIds: project.relatedChannelIds,
repositoryAddresses: [...liveAddresses, repository.repoAddress],
});
const createdAt = Math.max(

View File

@@ -4,7 +4,10 @@ import { toast } from "sonner";
import { classifyAgentManagementOrigin } from "@/features/agents/agentManagementBuffer";
import { subscribeProjectChannelRequests } from "@/features/agents/observerRelayStore";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import { useChannelsQuery } from "@/features/channels/hooks";
import {
useChannelMembersQuery,
useChannelsQuery,
} from "@/features/channels/hooks";
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
import { useProjectsQuery } from "@/features/projects/hooks";
import type { ProjectChannelRequest } from "@/features/projects/projectChannelRequest";
@@ -15,6 +18,7 @@ import {
type AcceptedProjectChannelRequest,
} from "@/features/projects/projectChannelRequestQueue";
import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel";
import { canManageProjectChannels } from "@/features/projects/ui/ProjectChannelManagement";
import { useIdentityQuery } from "@/shared/api/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
@@ -119,6 +123,9 @@ export function useProjectChannelRequests() {
candidate.name.trim().toLocaleLowerCase() ===
request.request.templateName?.trim().toLocaleLowerCase(),
) ?? null);
const homeMembersQuery = useChannelMembersQuery(
project?.projectChannelId ?? null,
);
function dismiss() {
if (mutation.isPending) return;
@@ -133,10 +140,14 @@ export function useProjectChannelRequests() {
return;
}
const identity = identityQuery.data?.pubkey;
const owner = normalizePubkey(project.owner);
const agent = normalizePubkey(sourceAgentPubkey);
if ((!identity || normalizePubkey(identity) !== owner) && agent !== owner) {
setError("Only the project owner can approve this channel.");
const viewerRole = homeMembersQuery.data?.find(
(member) =>
normalizePubkey(member.pubkey) === normalizePubkey(identity ?? ""),
)?.role;
if (!canManageProjectChannels(project, identity, viewerRole)) {
setError(
"Only the project owner or a home-channel admin can approve this channel.",
);
return;
}
if (request.request.templateName && !template) {
@@ -150,8 +161,6 @@ export function useProjectChannelRequests() {
await mutation.mutateAsync({
description: request.request.description,
name: request.request.name,
ownerControlAgentPubkey:
agent === owner ? sourceAgentPubkey : undefined,
project,
templateId: template?.id,
ttlSeconds: request.request.ttlSeconds,