mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
fix(projects): preserve revised channel membership
Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -187,6 +187,7 @@ async function ensureDefaultRepository({
|
||||
const patched = buildProjectPatchTemplate({
|
||||
liveHead,
|
||||
ownerPubkey,
|
||||
relatedChannelIds: project.relatedChannelIds,
|
||||
repositoryAddresses: [
|
||||
...new Set([
|
||||
...project.repositoryAddresses,
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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() ||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -781,6 +781,7 @@ export function ProjectsView() {
|
||||
<ProjectsCategoryCreateDialogs
|
||||
channelOpen={createChannelOpen}
|
||||
editableProjects={editableProjects}
|
||||
identityPubkey={currentPubkey}
|
||||
onChannelOpenChange={setCreateChannelOpen}
|
||||
onRepositoryOpenChange={setCreateRepositoryOpen}
|
||||
ownerControlAgentPubkeyFor={ownerControlAgentPubkeyFor}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -107,6 +107,7 @@ export async function addProjectRepository(
|
||||
existingRepositoryAddresses: project.repositoryAddresses,
|
||||
liveHead,
|
||||
ownerPubkey: targetOwner,
|
||||
relatedChannelIds: project.relatedChannelIds,
|
||||
repositoryHeadExists: existingRepoHeads.length > 0,
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ async function attachProjectRepository({
|
||||
const template = buildProjectPatchTemplate({
|
||||
liveHead,
|
||||
ownerPubkey: targetOwner,
|
||||
relatedChannelIds: project.relatedChannelIds,
|
||||
repositoryAddresses: [...liveAddresses, repository.repoAddress],
|
||||
});
|
||||
const createdAt = Math.max(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user