diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 701c767b329..d91dbe96e09 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -128,6 +128,7 @@ export default defineConfig({ "**/projects-v3-screenshots.spec.ts", "**/project-issue-comments.spec.ts", "**/project-pr-review.spec.ts", + "**/project-related-channels.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", "**/drafts-all-fix-screenshots.spec.ts", diff --git a/desktop/src/features/projects/lib/projectRelatedChannelAccess.test.mjs b/desktop/src/features/projects/lib/projectRelatedChannelAccess.test.mjs new file mode 100644 index 00000000000..c875f7832ae --- /dev/null +++ b/desktop/src/features/projects/lib/projectRelatedChannelAccess.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + canManageProjectRelatedChannels, + listLinkableProjectChannels, +} from "./projectRelatedChannelAccess.ts"; + +const OWNER = "a".repeat(64); +const ADMIN = "b".repeat(64); +const HOME = "11111111-1111-4111-8111-111111111111"; +const RELATED = "22222222-2222-4222-8222-222222222222"; +const REPOSITORY = "33333333-3333-4333-8333-333333333333"; +const AVAILABLE = "44444444-4444-4444-8444-444444444444"; + +function project(overrides = {}) { + return { + legacy: false, + owner: OWNER, + projectChannelId: HOME, + relatedChannelIds: [RELATED], + repositories: [{ channelId: REPOSITORY }], + ...overrides, + }; +} + +function member(pubkey, role) { + return { pubkey, role }; +} + +function channel(id, overrides = {}) { + return { + id, + name: id, + archivedAt: null, + channelType: "stream", + isMember: true, + ...overrides, + }; +} + +test("project owner and active home-channel admins can manage related channels", () => { + assert.equal( + canManageProjectRelatedChannels({ + homeChannelMembers: [], + homeChannelActive: true, + identityPubkey: OWNER.toUpperCase(), + project: project(), + }), + true, + ); + for (const role of ["owner", "admin"]) { + assert.equal( + canManageProjectRelatedChannels({ + homeChannelMembers: [member(ADMIN, role)], + homeChannelActive: true, + identityPubkey: ADMIN, + project: project(), + }), + true, + ); + } +}); + +test("ordinary members, legacy projects, and non-owners without a home channel cannot manage", () => { + for (const role of ["member", "guest", "bot"]) { + assert.equal( + canManageProjectRelatedChannels({ + homeChannelMembers: [member(ADMIN, role)], + homeChannelActive: true, + identityPubkey: ADMIN, + project: project(), + }), + false, + ); + } + assert.equal( + canManageProjectRelatedChannels({ + homeChannelMembers: [member(ADMIN, "admin")], + homeChannelActive: true, + identityPubkey: ADMIN, + project: project({ legacy: true }), + }), + false, + ); + assert.equal( + canManageProjectRelatedChannels({ + homeChannelMembers: [member(ADMIN, "admin")], + homeChannelActive: true, + identityPubkey: ADMIN, + project: project({ projectChannelId: null }), + }), + false, + ); + assert.equal( + canManageProjectRelatedChannels({ + homeChannelActive: false, + homeChannelMembers: [member(ADMIN, "admin")], + identityPubkey: ADMIN, + project: project(), + }), + false, + ); +}); + +test("link candidates exclude bound, unavailable, archived, and DM channels", () => { + const candidates = listLinkableProjectChannels(project(), [ + channel(HOME), + channel(RELATED), + channel(REPOSITORY), + channel(AVAILABLE, { name: "Available" }), + channel("not-member", { isMember: false }), + channel("archived", { archivedAt: "2026-09-01T00:00:00Z" }), + channel("dm", { channelType: "dm" }), + ]); + assert.deepEqual( + candidates.map((candidate) => candidate.id), + [AVAILABLE], + ); +}); diff --git a/desktop/src/features/projects/lib/projectRelatedChannelAccess.ts b/desktop/src/features/projects/lib/projectRelatedChannelAccess.ts new file mode 100644 index 00000000000..5b928a09f4a --- /dev/null +++ b/desktop/src/features/projects/lib/projectRelatedChannelAccess.ts @@ -0,0 +1,41 @@ +import type { Project } from "@/features/projects/projectModels"; +import { listProjectBoundChannels } from "@/features/projects/lib/projectRelatedChannels"; +import type { Channel, ChannelMember } from "@/shared/api/types"; + +export function canManageProjectRelatedChannels(input: { + homeChannelActive: boolean; + homeChannelMembers: readonly ChannelMember[] | undefined; + identityPubkey: string | undefined; + project: Pick; +}): boolean { + const identity = input.identityPubkey?.toLowerCase(); + if (!identity || input.project.legacy) return false; + if (identity === input.project.owner.toLowerCase()) return true; + if (!input.project.projectChannelId || !input.homeChannelActive) return false; + + const role = input.homeChannelMembers?.find( + (member) => member.pubkey.toLowerCase() === identity, + )?.role; + return role === "owner" || role === "admin"; +} + +export function listLinkableProjectChannels( + project: Pick< + Project, + "projectChannelId" | "relatedChannelIds" | "repositories" + >, + channels: readonly Channel[], +): Channel[] { + const boundIds = new Set( + listProjectBoundChannels(project).map((binding) => binding.channelId), + ); + return channels + .filter( + (channel) => + channel.isMember && + channel.channelType !== "dm" && + channel.archivedAt === null && + !boundIds.has(channel.id), + ) + .sort((left, right) => left.name.localeCompare(right.name)); +} diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs index e6fc3196f20..71e9190a1c3 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs +++ b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs @@ -6,6 +6,7 @@ import { collectProjectRelatedChannelRows, listProjectBoundChannels, listProjectChildChannels, + isExplicitProjectRelatedChannel, projectRelatedChannelRowKey, uniqueProjectRelatedChannelCount, } from "./projectRelatedChannels.ts"; @@ -107,6 +108,12 @@ test("collects one row per repository channel binding", () => { ); }); +test("identifies only explicit related-channel membership", () => { + const project = makeProject({ relatedChannelIds: [CHANNEL_A] }); + assert.equal(isExplicitProjectRelatedChannel(project, CHANNEL_A), true); + assert.equal(isExplicitProjectRelatedChannel(project, CHANNEL_B), false); +}); + test("collapses repositories sharing one project channel", () => { const rows = collectProjectRelatedChannelRows([ makeProject({ diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.ts b/desktop/src/features/projects/lib/projectRelatedChannels.ts index d5166a48536..8b435db4df4 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.ts +++ b/desktop/src/features/projects/lib/projectRelatedChannels.ts @@ -194,3 +194,13 @@ export function listProjectChildChannels( (channel) => channel.role !== "home", ); } + +/** Whether this channel is an explicit Project membership, not a derived repository binding. */ +export function isExplicitProjectRelatedChannel( + project: Pick, + channelId: string, +): boolean { + return (project.relatedChannelIds ?? []).some( + (candidate) => candidate.trim() === channelId, + ); +} diff --git a/desktop/src/features/projects/projectEnumeration.test.mjs b/desktop/src/features/projects/projectEnumeration.test.mjs index 9f9fe26b256..ef1274d8e27 100644 --- a/desktop/src/features/projects/projectEnumeration.test.mjs +++ b/desktop/src/features/projects/projectEnumeration.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; import { enumerateProjectEvents, @@ -91,6 +92,71 @@ test("buildProjectHomeFromFetcher scopes startup lookup to the active channel", }); }); +test("buildProjectsFromFetcher consumes relay-signed effective Project tags", async () => { + const relaySecret = new Uint8Array(32).fill(11); + const relayPubkey = getPublicKey(relaySecret); + const owner = "a".repeat(64); + const identityId = "b".repeat(64); + const coordinate = `30621:${owner}:relay`; + const related = "22222222-2222-4222-8222-222222222222"; + const projectEvent = { + id: identityId, + sig: "owner-signature", + kind: 30621, + pubkey: owner, + created_at: 200, + content: "", + tags: [ + ["d", "relay"], + ["name", "Owner name"], + ], + }; + const stateEvent = finalizeEvent( + { + kind: 30623, + created_at: 201, + content: JSON.stringify({ + v: 1, + deleted: false, + project_tags: [ + ["d", "relay"], + ["name", "Effective name"], + ["buzz-related-channel", related], + ], + }), + tags: [ + ["d", "c".repeat(64)], + ["a", coordinate], + ["rev", "2"], + ["e", identityId, "", "identity"], + ["e", "d".repeat(64), "", "change"], + ], + }, + relaySecret, + ); + const calls = []; + const projects = await buildProjectsFromFetcher( + async (kinds, extraFilter) => { + calls.push({ kinds, extraFilter }); + if (kinds.includes(30621)) return [projectEvent]; + if (kinds.includes(30623)) return [stateEvent]; + return []; + }, + { relayPubkey }, + ); + + assert.equal(projects[0].name, "Effective name"); + assert.deepEqual(projects[0].relatedChannelIds, [related]); + assert.ok( + calls.some( + ({ kinds, extraFilter }) => + kinds.includes(30623) && + extraFilter.authors[0] === relayPubkey && + extraFilter["#a"][0] === coordinate, + ), + ); +}); + test("enumerateProjectEvents drains a tied boundary second before advancing", async () => { const events = [ relayEvent("a", 1_000), diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts index 109ccc2d82d..c18090e5a52 100644 --- a/desktop/src/features/projects/projectEnumeration.ts +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -3,6 +3,7 @@ import type { RelayEvent } from "@/shared/api/types"; import { KIND_DELETION, KIND_PROJECT_ANNOUNCEMENT, + KIND_PROJECT_STATE, KIND_REPO_ANNOUNCEMENT, } from "@/shared/constants/kinds"; import { absorbStandaloneProjectRepositories } from "./lib/projectCollection"; @@ -19,6 +20,7 @@ const TOMBSTONE_COORDINATE_CHUNK_SIZE = 100; export type ProjectEventExtraFilter = { "#a"?: string[]; "#buzz-channel"?: string[]; + authors?: string[]; }; type ProjectEventFilter = ProjectEventExtraFilter & { @@ -161,6 +163,37 @@ async function fetchScopedDeletionEvents( return pages.flat(); } +async function fetchScopedProjectStateEvents( + fetchExhaustively: FetchProjectEventsExhaustively, + projectEvents: RelayEvent[], + relayPubkey: string, +): Promise { + const coordinates = [ + ...new Set( + projectEvents.flatMap((event) => { + const coordinate = eventCoordinate(event); + return coordinate ? [coordinate] : []; + }), + ), + ]; + if (coordinates.length === 0) return []; + + const pages: Promise[] = []; + for ( + let index = 0; + index < coordinates.length; + index += TOMBSTONE_COORDINATE_CHUNK_SIZE + ) { + pages.push( + fetchExhaustively([KIND_PROJECT_STATE], { + authors: [relayPubkey], + "#a": coordinates.slice(index, index + TOMBSTONE_COORDINATE_CHUNK_SIZE), + }), + ); + } + return (await Promise.all(pages)).flat(); +} + /** * Core fetch-and-build logic for `fetchProjects`, extracted for testability. * @@ -177,12 +210,20 @@ export async function buildProjectsFromFetcher( relayOrigin?: string | null; hiddenAddresses?: ReadonlySet; viewerPubkey?: string | null; + relayPubkey?: string | null; } = {}, ): Promise { const [projectEvents, repositoryEvents] = await Promise.all([ fetchExhaustively([KIND_PROJECT_ANNOUNCEMENT]), fetchExhaustively([KIND_REPO_ANNOUNCEMENT]), ]); + const projectStateEvents = options.relayPubkey + ? await fetchScopedProjectStateEvents( + fetchExhaustively, + projectEvents, + options.relayPubkey, + ) + : []; // Tombstones are fetched second (not in parallel) because the `#a` scoping // needs the announcement coordinates; both announcement kinds are small, @@ -207,6 +248,8 @@ export async function buildProjectsFromFetcher( return absorbStandaloneProjectRepositories( buildProjectReadModels({ projectEvents, + projectStateEvents, + relayPubkey: options.relayPubkey, repositoryEvents, deletionEvents: tombstoneResult.events, relayOrigin: options.relayOrigin ?? null, @@ -227,13 +270,14 @@ export async function buildProjectHomeFromFetcher( relayOrigin?: string | null; hiddenAddresses?: ReadonlySet; viewerPubkey?: string | null; + relayPubkey?: string | null; } = {}, ): Promise { const projects = await buildProjectsFromFetcher( (kinds, extraFilter) => fetchExhaustively( kinds, - kinds.includes(KIND_DELETION) + kinds.includes(KIND_DELETION) || kinds.includes(KIND_PROJECT_STATE) ? extraFilter : { ...extraFilter, "#buzz-channel": [channelId] }, ), diff --git a/desktop/src/features/projects/projectFetch.ts b/desktop/src/features/projects/projectFetch.ts index 149d62de4c9..fe9321d0b6c 100644 --- a/desktop/src/features/projects/projectFetch.ts +++ b/desktop/src/features/projects/projectFetch.ts @@ -1,5 +1,6 @@ import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl"; import { getIdentity } from "@/shared/api/tauriIdentity"; +import { getRelaySelf } from "@/features/moderation/lib/relaySelf"; import { buildProjectHomeFromFetcher, buildProjectsFromFetcher, @@ -36,9 +37,12 @@ export async function fetchProjects( // Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which // is the pure, Tauri-free core of this operation. Its javadoc explains // fail-closed tombstones and NIP-OA owner-deletion suppression. - const viewerPubkey = await getIdentity() - .then((identity) => identity.pubkey) - .catch(() => undefined); + const [viewerPubkey, relayPubkey] = await Promise.all([ + getIdentity() + .then((identity) => identity.pubkey) + .catch(() => undefined), + getRelaySelf().catch(() => null), + ]); const fetcher: FetchProjectEventsExhaustively = fetchExhaustively ?? ((kinds, extraFilter) => @@ -47,6 +51,7 @@ export async function fetchProjects( relayOrigin: getCachedRelayOrigin(), hiddenAddresses: new Set(readHiddenProjectCards()), viewerPubkey, + relayPubkey, }); return projects.map((project) => markProjectDataAuthoritative(project, "relay"), @@ -58,9 +63,12 @@ export async function fetchProjectHomeForChannel( channelId: string, signal?: AbortSignal, ): Promise { - const viewerPubkey = await getIdentity() - .then((identity) => identity.pubkey) - .catch(() => undefined); + const [viewerPubkey, relayPubkey] = await Promise.all([ + getIdentity() + .then((identity) => identity.pubkey) + .catch(() => undefined), + getRelaySelf().catch(() => null), + ]); const project = await buildProjectHomeFromFetcher( (kinds, extraFilter) => fetchProjectEventsExhaustively(kinds, extraFilter, undefined, signal), @@ -69,6 +77,7 @@ export async function fetchProjectHomeForChannel( relayOrigin: getCachedRelayOrigin(), hiddenAddresses: new Set(readHiddenProjectCards()), viewerPubkey, + relayPubkey, }, ); return project ? markProjectDataAuthoritative(project, "relay") : null; diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts index e201713a3ba..fcacc19cb34 100644 --- a/desktop/src/features/projects/projectModels.ts +++ b/desktop/src/features/projects/projectModels.ts @@ -4,6 +4,7 @@ import { KIND_REPO_ANNOUNCEMENT, } from "@/shared/constants/kinds"; import { effectiveCloneUrls } from "./lib/projectCloneUrl"; +import { parseProjectState, type ProjectState } from "./projectState"; export type Repository = { id: string; @@ -56,6 +57,10 @@ export function isExplicitProject(project: Project): boolean { type BuildProjectReadModelsInput = { projectEvents: RelayEvent[]; + /** Relay-authored NIP-PC projections for the requested Project coordinates. */ + projectStateEvents?: RelayEvent[]; + /** Trusted NIP-11 relay signing key. Without it, owner identity is used. */ + relayPubkey?: string | null; repositoryEvents: RelayEvent[]; /** NIP-09 kind:5 deletion events relevant to projects and repositories. */ deletionEvents?: RelayEvent[]; @@ -454,6 +459,8 @@ function projectIsListingEligible( export function buildProjectReadModels({ projectEvents, + projectStateEvents = [], + relayPubkey, repositoryEvents, deletionEvents = [], relayOrigin, @@ -490,20 +497,65 @@ export function buildProjectReadModels({ ]), ); - const explicitProjects = deduplicateAddressableEvents(projectEvents) - .filter((event) => !isDeleted(event)) - .flatMap((event) => { - const project = eventToExplicitProject( - event, - repositoriesByAddress, - visibleRepositoriesByAddress, - ); - return project && - projectIsListingEligible(project, viewerPubkey) && - !hiddenAddresses.has(project.projectAddress) - ? [project] - : []; - }); + const currentProjectEvents = deduplicateAddressableEvents( + projectEvents, + ).filter((event) => !isDeleted(event)); + const projectStateByCoordinate = new Map(); + if (relayPubkey) { + const stateEventsByCoordinate = new Map(); + for (const event of projectStateEvents) { + const coordinates = event.tags.filter((tag) => tag[0] === "a"); + if (coordinates.length !== 1 || coordinates[0].length !== 2) continue; + const coordinate = coordinates[0][1]; + const events = stateEventsByCoordinate.get(coordinate) ?? []; + events.push(event); + stateEventsByCoordinate.set(coordinate, events); + } + for (const identityEvent of currentProjectEvents) { + const dtag = getTag(identityEvent, "d"); + if (!dtag) continue; + const coordinate = `${KIND_PROJECT_ANNOUNCEMENT}:${identityEvent.pubkey.toLowerCase()}:${dtag}`; + const candidates = ( + stateEventsByCoordinate.get(coordinate) ?? [] + ).flatMap((event) => { + try { + const state = parseProjectState(event, relayPubkey, coordinate); + return state.identityEventId === identityEvent.id ? [state] : []; + } catch { + return []; + } + }); + // An addressable query should have one winner. Ambiguous valid results + // are not safe to guess between, so retain the portable owner identity. + if (candidates.length === 1) { + projectStateByCoordinate.set(coordinate, candidates[0]); + } + } + } + + const explicitProjects = currentProjectEvents.flatMap((event) => { + const dtag = getTag(event, "d"); + const coordinate = dtag + ? `${KIND_PROJECT_ANNOUNCEMENT}:${event.pubkey.toLowerCase()}:${dtag}` + : null; + const state = coordinate + ? projectStateByCoordinate.get(coordinate) + : undefined; + if (state?.deleted) return []; + const effectiveEvent = state + ? { ...event, tags: state.projectTags.map((tag) => [...tag]) } + : event; + const project = eventToExplicitProject( + effectiveEvent, + repositoriesByAddress, + visibleRepositoriesByAddress, + ); + return project && + projectIsListingEligible(project, viewerPubkey) && + !hiddenAddresses.has(project.projectAddress) + ? [project] + : []; + }); const claimedRepositories = new Set( explicitProjects.flatMap((project) => project.repositoryAddresses.filter((address) => { diff --git a/desktop/src/features/projects/projectState.test.mjs b/desktop/src/features/projects/projectState.test.mjs new file mode 100644 index 00000000000..6e277fa1b9b --- /dev/null +++ b/desktop/src/features/projects/projectState.test.mjs @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; + +import { + buildProjectRelatedChannelChangeTemplate, + changeProjectRelatedChannels, + fetchProjectState, + parseProjectState, +} from "./projectState.ts"; + +const RELAY_SECRET = new Uint8Array(32).fill(7); +const RELAY_PUBKEY = getPublicKey(RELAY_SECRET); +const OWNER = "a".repeat(64); +const IDENTITY_ID = "b".repeat(64); +const COORDINATE = `30621:${OWNER}:buzz`; +const CHANNEL_A = "11111111-1111-4111-8111-111111111111"; +const CHANNEL_B = "22222222-2222-4222-8222-222222222222"; + +function stateEvent({ + revision = "1", + identityId = IDENTITY_ID, + content, +} = {}) { + return finalizeEvent( + { + kind: 30623, + created_at: 100, + content: + content ?? + JSON.stringify({ + v: 1, + deleted: false, + project_tags: [ + ["d", "buzz"], + ["name", "Buzz"], + ["buzz-related-channel", CHANNEL_A], + ], + }), + tags: [ + ["d", "c".repeat(64)], + ["a", COORDINATE], + ["rev", revision], + ["e", identityId, "", "identity"], + ["e", "d".repeat(64), "", "change"], + ], + }, + RELAY_SECRET, + ); +} + +test("parseProjectState accepts a signed exact-coordinate strict v1 projection", () => { + const state = parseProjectState(stateEvent(), RELAY_PUBKEY, COORDINATE); + assert.equal(state.revision, "1"); + assert.equal(state.identityEventId, IDENTITY_ID); + assert.deepEqual(state.projectTags.at(-1), [ + "buzz-related-channel", + CHANNEL_A, + ]); +}); + +test("parseProjectState rejects wrong authors, signatures, revisions, coordinates, and unknown content fields", () => { + assert.throws( + () => parseProjectState(stateEvent(), "e".repeat(64), COORDINATE), + /signed by this relay/, + ); + const badSignature = JSON.parse(JSON.stringify(stateEvent())); + badSignature.sig = "0".repeat(128); + assert.throws( + () => parseProjectState(badSignature, RELAY_PUBKEY, COORDINATE), + /signed by this relay/, + ); + assert.throws( + () => + parseProjectState( + stateEvent({ revision: "01" }), + RELAY_PUBKEY, + COORDINATE, + ), + /revision is not canonical/, + ); + assert.throws( + () => parseProjectState(stateEvent(), RELAY_PUBKEY, `30621:${OWNER}:other`), + /requested coordinate/, + ); + assert.throws( + () => + parseProjectState( + stateEvent({ + content: JSON.stringify({ + v: 1, + deleted: false, + project_tags: [], + extra: true, + }), + }), + RELAY_PUBKEY, + COORDINATE, + ), + /strict version 1/, + ); +}); + +test("buildProjectRelatedChannelChangeTemplate emits the exact sorted kind:47010 command", () => { + assert.deepEqual( + buildProjectRelatedChannelChangeTemplate( + COORDINATE, + { add: [CHANNEL_B, CHANNEL_A], remove: [] }, + "7", + ), + { + kind: 47010, + tags: [ + ["a", COORDINATE], + ["expected-revision", "7"], + ], + content: JSON.stringify({ + v: 1, + patch: { + related_channels: { add: [CHANNEL_A, CHANNEL_B], remove: [] }, + }, + }), + }, + ); +}); + +test("fetchProjectState distinguishes absence from a present untrusted projection", async () => { + const deps = { + getRelayPubkey: async () => RELAY_PUBKEY, + fetchEvents: async () => [], + }; + assert.equal(await fetchProjectState(COORDINATE, deps), null); + + await assert.rejects( + fetchProjectState(COORDINATE, { + ...deps, + fetchEvents: async () => [{ ...stateEvent(), content: "{}" }], + }), + /untrusted or unsupported/, + ); +}); + +test("changeProjectRelatedChannels refetches and retries one revision conflict", async () => { + const states = [stateEvent({ revision: "4" }), stateEvent({ revision: "5" })]; + const signedTemplates = []; + let fetchCount = 0; + let publishCount = 0; + await changeProjectRelatedChannels( + { projectAddress: COORDINATE }, + { add: [CHANNEL_B], remove: [] }, + { + getRelayPubkey: async () => RELAY_PUBKEY, + fetchEvents: async () => [states[fetchCount++]], + signEvent: async (template) => { + signedTemplates.push(template); + return stateEvent(); + }, + publishEvent: async () => { + publishCount += 1; + if (publishCount === 1) { + throw new Error("conflict: Project revision is 5"); + } + }, + }, + ); + + assert.equal(fetchCount, 2); + assert.equal(publishCount, 2); + assert.deepEqual( + signedTemplates.map((template) => template.tags[1][1]), + ["4", "5"], + ); +}); diff --git a/desktop/src/features/projects/projectState.ts b/desktop/src/features/projects/projectState.ts new file mode 100644 index 00000000000..265efb72a4e --- /dev/null +++ b/desktop/src/features/projects/projectState.ts @@ -0,0 +1,353 @@ +import { verifyEvent } from "nostr-tools/pure"; + +import { getRelaySelf } from "@/features/moderation/lib/relaySelf"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { signRelayEvent } from "@/shared/api/tauri"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_PROJECT_CHANGE, + KIND_PROJECT_STATE, +} from "@/shared/constants/kinds"; +import type { Project } from "./projectModels"; + +const MAX_PROJECT_D_TAG_BYTES = 1_024; +const MAX_PATCH_CHANNELS = 64; +const MAX_REVISION = 9_223_372_036_854_775_807n; +const CANONICAL_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const LOWER_HEX_64 = /^[0-9a-f]{64}$/; + +export type ProjectState = { + deleted: boolean; + identityEventId: string; + projectTags: string[][]; + revision: string; +}; + +export type ProjectRelatedChannelPatch = { + add: string[]; + remove: string[]; +}; + +export type ProjectRelatedChannelChangeTemplate = { + content: string; + kind: number; + tags: string[][]; +}; + +type ProjectStateFilter = { + "#a": string[]; + authors: string[]; + kinds: number[]; + limit: number; +}; + +type ProjectStateDeps = { + fetchEvents: (filter: ProjectStateFilter) => Promise; + getRelayPubkey: () => Promise; +}; + +type ChangeProjectRelatedChannelsDeps = ProjectStateDeps & { + publishEvent: ( + event: RelayEvent, + timeoutMessage: string, + failureMessage: string, + ) => Promise; + signEvent: ( + input: ProjectRelatedChannelChangeTemplate, + ) => Promise; +}; + +function parseProjectCoordinate(coordinate: string): { dtag: string } { + const first = coordinate.indexOf(":"); + const second = coordinate.indexOf(":", first + 1); + const kind = coordinate.slice(0, first); + const owner = coordinate.slice(first + 1, second); + const dtag = coordinate.slice(second + 1); + if ( + kind !== String(KIND_PROJECT_ANNOUNCEMENT) || + second < 0 || + !LOWER_HEX_64.test(owner) || + dtag.length === 0 || + new TextEncoder().encode(dtag).byteLength > MAX_PROJECT_D_TAG_BYTES + ) { + throw new Error("Invalid canonical Project coordinate."); + } + return { dtag }; +} + +function exactObjectKeys( + value: Record, + expected: readonly string[], +): boolean { + const keys = Object.keys(value).sort(); + return ( + keys.length === expected.length && + keys.every((key, index) => key === [...expected].sort()[index]) + ); +} + +function parseStrictContent(content: string): { + deleted: boolean; + projectTags: string[][]; +} { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error("Project State content is not valid JSON."); + } + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + !exactObjectKeys(parsed as Record, [ + "deleted", + "project_tags", + "v", + ]) + ) { + throw new Error("Project State content is not strict version 1."); + } + const body = parsed as Record; + if ( + body.v !== 1 || + typeof body.deleted !== "boolean" || + !Array.isArray(body.project_tags) || + !body.project_tags.every( + (tag) => + Array.isArray(tag) && + tag.length > 0 && + tag.every((value) => typeof value === "string"), + ) + ) { + throw new Error("Project State content is not strict version 1."); + } + const projectTags = body.project_tags as string[][]; + if (body.deleted && projectTags.length !== 0) { + throw new Error("Deleted Project State must not contain Project tags."); + } + return { deleted: body.deleted, projectTags }; +} + +function singleExactTag(event: RelayEvent, name: string): string[] | undefined { + const tags = event.tags.filter((tag) => tag[0] === name); + return tags.length === 1 ? tags[0] : undefined; +} + +function hasValidSignature(event: RelayEvent): boolean { + try { + return verifyEvent(event); + } catch { + return false; + } +} + +/** Validate one relay-authored NIP-PC projection for an exact Project coordinate. */ +export function parseProjectState( + event: RelayEvent, + relayPubkey: string, + coordinate: string, +): ProjectState { + const { dtag } = parseProjectCoordinate(coordinate); + const normalizedRelay = relayPubkey.trim().toLowerCase(); + if (!LOWER_HEX_64.test(normalizedRelay)) { + throw new Error("The relay did not advertise a valid signing identity."); + } + if ( + event.kind !== KIND_PROJECT_STATE || + event.pubkey !== normalizedRelay || + !hasValidSignature(event) + ) { + throw new Error("Project State is not validly signed by this relay."); + } + + const coordinateTag = singleExactTag(event, "a"); + if (coordinateTag?.length !== 2 || coordinateTag[1] !== coordinate) { + throw new Error("Project State does not match the requested coordinate."); + } + const revisionTag = singleExactTag(event, "rev"); + const revision = revisionTag?.[1] ?? ""; + if ( + revisionTag?.length !== 2 || + !/^[1-9][0-9]*$/.test(revision) || + BigInt(revision) > MAX_REVISION + ) { + throw new Error("Project State revision is not canonical."); + } + const identityTags = event.tags.filter( + (tag) => tag[0] === "e" && tag[3] === "identity", + ); + if ( + identityTags.length !== 1 || + identityTags[0].length !== 4 || + !LOWER_HEX_64.test(identityTags[0][1] ?? "") || + identityTags[0][2] !== "" + ) { + throw new Error("Project State identity marker is malformed."); + } + const { deleted, projectTags } = parseStrictContent(event.content); + const projectDTags = projectTags.filter((tag) => tag[0] === "d"); + if ( + !deleted && + (projectDTags.length !== 1 || + projectDTags[0].length !== 2 || + projectDTags[0][1] !== dtag) + ) { + throw new Error( + "Project State tags do not match the requested coordinate.", + ); + } + return { + deleted, + identityEventId: identityTags[0][1], + projectTags, + revision, + }; +} + +function projectCoordinate( + project: string | Pick, +): string { + return typeof project === "string" ? project : project.projectAddress; +} + +/** Fetch and validate the current relay-authored state at mutation time. */ +export async function fetchProjectState( + project: string | Pick, + deps?: Partial, +): Promise { + const coordinate = projectCoordinate(project); + parseProjectCoordinate(coordinate); + const getRelayPubkey = deps?.getRelayPubkey ?? getRelaySelf; + const fetchEvents = + deps?.fetchEvents ?? relayClient.fetchEvents.bind(relayClient); + const relayPubkey = await getRelayPubkey(); + if (!relayPubkey) { + throw new Error("Could not verify the relay identity for Project State."); + } + const normalizedRelay = relayPubkey.toLowerCase(); + const events = await fetchEvents({ + kinds: [KIND_PROJECT_STATE], + authors: [normalizedRelay], + "#a": [coordinate], + limit: 2, + }); + if (events.length > 1) { + throw new Error("The relay returned ambiguous Project State."); + } + if (events.length === 0) return null; + try { + return parseProjectState(events[0], normalizedRelay, coordinate); + } catch { + throw new Error( + "The relay returned untrusted or unsupported Project State.", + ); + } +} + +function canonicalizePatch( + patch: ProjectRelatedChannelPatch, +): ProjectRelatedChannelPatch { + const add = [...patch.add].sort(); + const remove = [...patch.remove].sort(); + if (add.length > MAX_PATCH_CHANNELS || remove.length > MAX_PATCH_CHANNELS) { + throw new Error("A Project change may add or remove at most 64 channels."); + } + if (add.length === 0 && remove.length === 0) { + throw new Error("A Project related-channel change must not be empty."); + } + for (const channelId of [...add, ...remove]) { + if (!CANONICAL_UUID.test(channelId)) { + throw new Error("Related channels must use canonical lowercase UUIDs."); + } + } + if ( + new Set(add).size !== add.length || + new Set(remove).size !== remove.length + ) { + throw new Error("A Project change must not contain duplicate channels."); + } + const removeSet = new Set(remove); + if (add.some((channelId) => removeSet.has(channelId))) { + throw new Error("A Project change cannot add and remove the same channel."); + } + return { add, remove }; +} + +/** Build the exact NIP-PC kind:47010 command template. */ +export function buildProjectRelatedChannelChangeTemplate( + project: string | Pick, + patch: ProjectRelatedChannelPatch, + expectedRevision: string, +): ProjectRelatedChannelChangeTemplate { + const coordinate = projectCoordinate(project); + parseProjectCoordinate(coordinate); + if ( + !/^[1-9][0-9]*$/.test(expectedRevision) || + BigInt(expectedRevision) > MAX_REVISION + ) { + throw new Error("Expected Project revision is not canonical."); + } + const canonicalPatch = canonicalizePatch(patch); + return { + kind: KIND_PROJECT_CHANGE, + tags: [ + ["a", coordinate], + ["expected-revision", expectedRevision], + ], + content: JSON.stringify({ + v: 1, + patch: { related_channels: canonicalPatch }, + }), + }; +} + +function isRevisionConflict(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes("conflict: Project revision is ") + ); +} + +/** Publish a related-channel change, retrying one stale-revision conflict. */ +export async function changeProjectRelatedChannels( + project: Pick, + patch: ProjectRelatedChannelPatch, + deps?: Partial, +): Promise { + const fetchDeps: Partial = { + fetchEvents: deps?.fetchEvents, + getRelayPubkey: deps?.getRelayPubkey, + }; + const signEvent = deps?.signEvent ?? signRelayEvent; + const publishEvent = + deps?.publishEvent ?? relayClient.publishEvent.bind(relayClient); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const state = await fetchProjectState(project, fetchDeps); + if (!state) { + throw new Error( + "Project State is not available yet. Refresh and try again.", + ); + } + if (state.deleted) { + throw new Error("This Project has been deleted."); + } + const event = await signEvent( + buildProjectRelatedChannelChangeTemplate(project, patch, state.revision), + ); + try { + await publishEvent( + event, + "Could not confirm the Project channel change. Refresh and try again.", + "Failed to change the Project's related channels.", + ); + return; + } catch (error) { + if (attempt === 0 && isRevisionConflict(error)) continue; + throw error; + } + } +} diff --git a/desktop/src/features/projects/projectStateReadModel.test.mjs b/desktop/src/features/projects/projectStateReadModel.test.mjs new file mode 100644 index 00000000000..549befb3c09 --- /dev/null +++ b/desktop/src/features/projects/projectStateReadModel.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; + +import { buildProjectReadModels } from "./projectModels.ts"; + +const RELAY_SECRET = new Uint8Array(32).fill(9); +const RELAY_PUBKEY = getPublicKey(RELAY_SECRET); +const OWNER = "a".repeat(64); +const IDENTITY_ID = "b".repeat(64); +const COORDINATE = `30621:${OWNER}:buzz`; +const RELATED = "22222222-2222-4222-8222-222222222222"; + +function identityEvent() { + return { + id: IDENTITY_ID, + sig: "owner-signature-not-reverified-by-this-read-model", + kind: 30621, + pubkey: OWNER, + created_at: 200, + content: "", + tags: [ + ["d", "buzz"], + ["name", "Owner name"], + ], + }; +} + +function projection({ deleted = false, identityId = IDENTITY_ID } = {}) { + return finalizeEvent( + { + kind: 30623, + created_at: 201, + content: JSON.stringify({ + v: 1, + deleted, + project_tags: deleted + ? [] + : [ + ["d", "buzz"], + ["name", "Effective name"], + ["buzz-related-channel", RELATED], + ], + }), + tags: [ + ["d", "c".repeat(64)], + ["a", COORDINATE], + ["rev", "2"], + ["e", identityId, "", "identity"], + ["e", "d".repeat(64), "", "change"], + ], + }, + RELAY_SECRET, + ); +} + +test("read model uses verified effective tags while preserving owner identity fields", () => { + const [project] = buildProjectReadModels({ + projectEvents: [identityEvent()], + projectStateEvents: [projection()], + repositoryEvents: [], + relayPubkey: RELAY_PUBKEY, + }); + assert.equal(project.name, "Effective name"); + assert.equal(project.owner, OWNER); + assert.equal(project.createdAt, 200); + assert.equal(project.id, COORDINATE); + assert.deepEqual(project.relatedChannelIds, [RELATED]); +}); + +test("read model suppresses matching deleted state and falls back for a stale identity marker", () => { + assert.deepEqual( + buildProjectReadModels({ + projectEvents: [identityEvent()], + projectStateEvents: [projection({ deleted: true })], + repositoryEvents: [], + relayPubkey: RELAY_PUBKEY, + }), + [], + ); + + const [fallback] = buildProjectReadModels({ + projectEvents: [identityEvent()], + projectStateEvents: [projection({ identityId: "e".repeat(64) })], + repositoryEvents: [], + relayPubkey: RELAY_PUBKEY, + }); + assert.equal(fallback.name, "Owner name"); + assert.deepEqual(fallback.relatedChannelIds, []); +}); diff --git a/desktop/src/features/projects/ui/AddProjectChannelDialog.tsx b/desktop/src/features/projects/ui/AddProjectChannelDialog.tsx new file mode 100644 index 00000000000..71d02d55a36 --- /dev/null +++ b/desktop/src/features/projects/ui/AddProjectChannelDialog.tsx @@ -0,0 +1,99 @@ +import { Hash } from "lucide-react"; +import * as React from "react"; + +import type { Project } from "@/features/projects/projectModels"; +import type { Channel } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; + +export function AddProjectChannelDialog({ + channels, + isAdding, + onAdd, + onOpenChange, + open, + project, +}: { + channels: Channel[]; + isAdding: boolean; + onAdd: (channel: Channel) => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + project: Project; +}) { + const [errorMessage, setErrorMessage] = React.useState(null); + + React.useEffect(() => { + if (open) setErrorMessage(null); + }, [open]); + + async function handleAdd(channel: Channel) { + setErrorMessage(null); + try { + await onAdd(channel); + onOpenChange(false); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "Could not add the channel to this project.", + ); + } + } + + return ( + { + if (!nextOpen && isAdding) return; + onOpenChange(nextOpen); + }} + open={open} + > + +
+ {channels.length === 0 ? ( +

+ Every available channel is already in this project. +

+ ) : ( + channels.map((channel) => ( + + )) + )} + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx index 9c774c9a3b2..47cd3cfca6b 100644 --- a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx @@ -2,75 +2,58 @@ import { Plus } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; -import { useIsManagedAgent } from "@/features/agent-memory/hooks"; -import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import type { Project } from "@/features/projects/hooks"; -import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel"; -import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; +import { listLinkableProjectChannels } from "@/features/projects/lib/projectRelatedChannelAccess"; +import { useChangeProjectRelatedChannelsMutation } from "@/features/projects/useChangeProjectRelatedChannels"; +import type { Channel } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; +import { AddProjectChannelDialog } from "./AddProjectChannelDialog"; export function ProjectChannelManagement({ - identityPubkey, + canManage, + channels, project, }: { - identityPubkey?: string; + canManage: boolean; + channels: Channel[]; project: Project; }) { - const { goChannel } = useAppNavigation(); - const [createOpen, setCreateOpen] = React.useState(false); - const createMutation = useAddProjectChannelMutation(); - const ownerProfileQuery = useUsersBatchQuery([project.owner], { - enabled: Boolean(identityPubkey), - }); - const projectOwnerProfile = - ownerProfileQuery.data?.profiles[project.owner.toLowerCase()]; - const projectOwnerIsManaged = useIsManagedAgent(project.owner) === true; - const viewerIsProjectOwner = - identityPubkey?.toLowerCase() === project.owner.toLowerCase(); - const viewerOwnsProjectAgent = ownsAuthorAgent( - projectOwnerProfile, - identityPubkey, + const [addOpen, setAddOpen] = React.useState(false); + const changeMutation = useChangeProjectRelatedChannelsMutation(); + const candidates = React.useMemo( + () => listLinkableProjectChannels(project, channels), + [channels, project], ); - const canEdit = - !project.legacy && - (viewerIsProjectOwner || projectOwnerIsManaged || viewerOwnsProjectAgent); - const ownerControlAgentPubkey = - viewerOwnsProjectAgent && !projectOwnerIsManaged && !viewerIsProjectOwner - ? project.owner - : undefined; return ( <> - {canEdit ? ( - { - const result = await createMutation.mutateAsync({ - ...input, - ownerControlAgentPubkey, + {canManage ? ( + { + await changeMutation.mutateAsync({ + add: [channel.id], project, }); - toast.success(`Channel "#${result.channel.name}" created.`); - await goChannel(result.channel.id); + toast.success(`Channel "#${channel.name}" added to project.`); }} - onOpenChange={setCreateOpen} - testId="create-project-channel-dialog" - title="Create a project channel" + onOpenChange={setAddOpen} + open={addOpen} + project={project} /> ) : null} ); + return contextMenuTrigger ? ( + {button} + ) : ( + button + ); } function ChannelContextRow({ channel, + contextMenuTrigger = false, onClick, projectHome, testId, }: { channel: Channel; + contextMenuTrigger?: boolean; onClick?: () => void; projectHome?: boolean; testId: string; }) { const Icon = projectHome ? ProjectChannelIcon : Hash; - if (onClick) { - return ( - } onClick={onClick} testId={testId}> + let row: React.ReactNode; + if (onClick || contextMenuTrigger) { + row = ( + } + onClick={onClick} + testId={testId} + > {channel.name} ); + } else { + row = ( +
+ }>{channel.name} +
+ ); } - return ( -
- }>{channel.name} -
- ); + return row; } export function ProjectHomeContextPanel({ @@ -234,6 +287,19 @@ export function ProjectHomeContextPanel({ null, Boolean(firstRepository), ); + const homeChannelMembersQuery = useChannelMembersQuery( + project.projectChannelId, + ); + const homeChannel = channels.find( + (candidate) => candidate.id === project.projectChannelId, + ); + const canManageChannels = canManageProjectRelatedChannels({ + homeChannelActive: homeChannel?.archivedAt === null, + homeChannelMembers: homeChannelMembersQuery.data, + identityPubkey, + project, + }); + const changeChannelsMutation = useChangeProjectRelatedChannelsMutation(); const channelsById = new Map( channels.map((candidate) => [candidate.id, candidate]), ); @@ -325,7 +391,8 @@ export function ProjectHomeContextPanel({ collapsible headerAction={ } @@ -335,9 +402,14 @@ export function ProjectHomeContextPanel({ {listedChannels.length > 0 ? ( listedChannels.map((binding) => { const isHome = binding.role === "home"; - return ( + const canRemove = + canManageChannels && + !isHome && + isExplicitProjectRelatedChannel(project, binding.channel.id); + const row = ( ); + if (!canRemove) return row; + return ( + + {row} + + { + void changeChannelsMutation + .mutateAsync({ + project, + remove: [binding.channel.id], + }) + .then(() => { + toast.success( + `Channel "#${binding.channel.name}" removed from project.`, + ); + }) + .catch((error: unknown) => { + toast.error( + error instanceof Error + ? error.message + : "Could not remove the channel from this project.", + ); + }); + }} + > + + Remove from project + + + + ); }) ) : (

+ changeProjectRelatedChannels(project, { add, remove }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: projectsQueryKey }); + }, + }); +} diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 5d4308c5c27..c9cf61a7bae 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -70,6 +70,9 @@ export const KIND_REPO_ANNOUNCEMENT = 30617; export const KIND_REPO_STATE = 30618; // NIP-MP: project grouping above NIP-34 repositories. export const KIND_PROJECT_ANNOUNCEMENT = 30621; +// NIP-PC: relay-signed effective Project state and collaborative change command. +export const KIND_PROJECT_STATE = 30623; +export const KIND_PROJECT_CHANGE = 47010; export const KIND_GIT_PATCH = 1617; export const KIND_GIT_PULL_REQUEST = 1618; export const KIND_GIT_PR_UPDATE = 1619; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b31504fe0a0..32ef0cca036 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { CommunitiesProvider } from "@/features/communities/useCommunities"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { sha256 } from "@noble/hashes/sha2.js"; import { emit, listen } from "@tauri-apps/api/event"; import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; import { decode, npubEncode, nsecEncode } from "nostr-tools/nip19"; @@ -68,6 +69,8 @@ import { KIND_MEMBER_REMOVED_NOTIFICATION, KIND_PERSONA, KIND_PROJECT_ANNOUNCEMENT, + KIND_PROJECT_CHANGE, + KIND_PROJECT_STATE, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_STREAM_MESSAGE_EDIT, @@ -215,6 +218,8 @@ type E2eConfig = { projectHeadBranch?: string; /** Override the repository access channel for project authorization states. */ projectAccessChannelId?: string; + /** Role held by the mock viewer in the Buzz Project's home channel. */ + projectHomeRole?: "owner" | "admin" | "member"; /** Make remote project snapshots fail with this git-facing message. */ projectRepoSnapshotError?: string; /** Delay remote repository snapshots so project loading UI is observable. */ @@ -1593,6 +1598,10 @@ const DEFAULT_REAL_IDENTITY = { username: "tyler", } satisfies TestIdentity; +// The mock relay uses a real key for relay-authored projections so production +// signature verification exercises the same trust boundary as a live relay. +const MOCK_PROJECT_RELAY_IDENTITY = DEFAULT_REAL_IDENTITY; + const ALICE_PUBKEY = "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; const BOB_PUBKEY = @@ -6065,6 +6074,7 @@ const MOCK_PROJECT_SUBJECTS = [ const MOCK_PROJECT_KINDS = new Set([ KIND_PROJECT_ANNOUNCEMENT, + KIND_PROJECT_STATE, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_GIT_PATCH, @@ -6231,33 +6241,158 @@ function buildMockProjectEvents(): RelayEvent[] { if (!window.__BUZZ_E2E_REPOSITORY_ONLY_PROJECTS__) { const projectOwner = window.__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__ ?? MOCK_PROJECT_SEEDS[0].owner; - events.push( - createMockEvent( - KIND_PROJECT_ANNOUNCEMENT, - "", + const identityEvent = createMockEvent( + KIND_PROJECT_ANNOUNCEMENT, + "", + [ + ["d", "buzz"], + ["name", "buzz"], + ["description", "The complete Buzz community platform."], + ["a", `${KIND_REPO_ANNOUNCEMENT}:${projectOwner}:buzz`], + ["a", `${KIND_REPO_ANNOUNCEMENT}:${ALICE_PUBKEY}:relay-tools`], [ - ["d", "buzz"], - ["name", "buzz"], - ["description", "The complete Buzz community platform."], - ["a", `${KIND_REPO_ANNOUNCEMENT}:${projectOwner}:buzz`], - ["a", `${KIND_REPO_ANNOUNCEMENT}:${ALICE_PUBKEY}:relay-tools`], - [ - "buzz-channel", - getConfig()?.mock?.projectAccessChannelId ?? - STARTER_PROJECT_HOME_CHANNEL_ID, - ], - ["buzz-related-channel", "9dae0116-799b-5071-a0a8-fdd30a91a35d"], + "buzz-channel", + getConfig()?.mock?.projectAccessChannelId ?? + STARTER_PROJECT_HOME_CHANNEL_ID, ], - projectOwner, - now, - "project-buzz".padEnd(64, "0"), - ), + ["buzz-related-channel", "9dae0116-799b-5071-a0a8-fdd30a91a35d"], + ], + projectOwner, + now, + "b".repeat(64), + ); + events.push( + identityEvent, + buildMockProjectStateEvent(identityEvent, 1, identityEvent.id), ); } return events; } +function projectCoordinateFromIdentity(event: RelayEvent): string { + const dtag = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (!dtag) throw new Error("Mock Project identity is missing its d tag."); + return `${KIND_PROJECT_ANNOUNCEMENT}:${event.pubkey}:${dtag}`; +} + +function buildMockProjectStateEvent( + identityEvent: RelayEvent, + revision: number, + changeEventId: string, + projectTags = identityEvent.tags, +): RelayEvent { + const coordinate = projectCoordinateFromIdentity(identityEvent); + return finalizeEvent( + { + kind: KIND_PROJECT_STATE, + content: JSON.stringify({ + v: 1, + deleted: false, + project_tags: projectTags, + }), + tags: [ + ["d", bytesToHex(sha256(new TextEncoder().encode(coordinate)))], + ["a", coordinate], + ["rev", String(revision)], + ["e", identityEvent.id, "", "identity"], + ["e", changeEventId, "", "change"], + ], + created_at: Math.floor(Date.now() / 1000) + revision, + }, + hexToBytes(MOCK_PROJECT_RELAY_IDENTITY.privateKey), + ); +} + +function applyMockProjectRelatedChannelChange( + socket: MockSocket, + event: RelayEvent, +): void { + const coordinate = event.tags.find((tag) => tag[0] === "a")?.[1]; + const expectedRevision = event.tags.find( + (tag) => tag[0] === "expected-revision", + )?.[1]; + const store = getMockProjectEventStore(); + const projectionIndex = store.findIndex( + (candidate) => + candidate.kind === KIND_PROJECT_STATE && + candidate.tags.some( + (tag) => tag.length === 2 && tag[0] === "a" && tag[1] === coordinate, + ), + ); + const projection = store[projectionIndex]; + if (!coordinate || !expectedRevision || !projection) { + throw new Error("Mock Project change is missing its current projection."); + } + const currentRevision = projection.tags.find((tag) => tag[0] === "rev")?.[1]; + if (expectedRevision !== currentRevision) { + sendWsText(socket.handler, [ + "OK", + event.id, + false, + `conflict: Project revision is ${currentRevision ?? "unknown"}`, + ]); + return; + } + + const body = JSON.parse(event.content) as { + patch: { related_channels: { add: string[]; remove: string[] } }; + }; + const patch = body.patch.related_channels; + + const projectionBody = JSON.parse(projection.content) as { + project_tags: string[][]; + }; + const identityId = projection.tags.find( + (tag) => tag[0] === "e" && tag[3] === "identity", + )?.[1]; + const identityEvent = store.find( + (candidate) => + candidate.kind === KIND_PROJECT_ANNOUNCEMENT && + candidate.id === identityId, + ); + if (!identityEvent) { + throw new Error("Mock Project State is missing its identity event."); + } + + const related = new Set( + projectionBody.project_tags + .filter((tag) => tag[0] === "buzz-related-channel") + .map((tag) => tag[1]), + ); + for (const channelId of patch.remove) related.delete(channelId); + for (const channelId of patch.add) related.add(channelId); + + const effectiveProjectTags = [ + ...projectionBody.project_tags.filter( + (tag) => tag[0] !== "buzz-related-channel", + ), + ...[...related] + .sort() + .map((channelId) => ["buzz-related-channel", channelId]), + ]; + const nextRevision = Number(currentRevision) + 1; + store.splice( + projectionIndex, + 1, + buildMockProjectStateEvent( + identityEvent, + nextRevision, + event.id, + effectiveProjectTags, + ), + ); + const acceptedProjectEvents = + window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ ?? []; + acceptedProjectEvents.push({ + content: event.content, + kind: event.kind, + tags: event.tags, + }); + window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ = acceptedProjectEvents; + sendWsText(socket.handler, ["OK", event.id, true, ""]); +} + function getMockProjectEventStore(): RelayEvent[] { if (!mockProjectEventStore) { mockProjectEventStore = buildMockProjectEvents(); @@ -7367,8 +7502,18 @@ async function handleGetChannelMembers( const identity = getIdentity(config); if (!identity) { const channel = getMockChannel(args.channelId); + const members = cloneMembers(channel.members); + if ( + args.channelId === STARTER_PROJECT_HOME_CHANNEL_ID && + config?.mock?.projectHomeRole + ) { + const viewer = members.find( + (member) => member.pubkey === MOCK_IDENTITY_PUBKEY, + ); + if (viewer) viewer.role = config.mock.projectHomeRole; + } return { - members: cloneMembers(channel.members), + members, next_cursor: null, }; } @@ -11077,6 +11222,11 @@ function sendToMockSocket(args: { return; } + if (event.kind === KIND_PROJECT_CHANGE) { + applyMockProjectRelatedChannelChange(socket, event); + return; + } + if (isMockProjectScopedEvent(event)) { if (event.pubkey !== DEFAULT_MOCK_IDENTITY.pubkey) { sendWsText(socket.handler, [ @@ -14504,7 +14654,9 @@ export function maybeInstallE2eTauriMocks() { ), ); } - return activeConfig?.mock?.relaySelf ?? null; + return ( + activeConfig?.mock?.relaySelf ?? MOCK_PROJECT_RELAY_IDENTITY.pubkey + ); case "archive_identity": case "unarchive_identity": // The spec only verifies UI state, not the submitted request shape; diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index 2ee454aefcc..2fea19669e9 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -384,9 +384,9 @@ test("creating a project opens its channel conversation", async ({ page }) => { await expect(repositoryAction).toHaveCSS("opacity", "1"); await page.getByTestId("project-home-context-channel").hover(); await page.getByTestId("add-project-channel").click(); - await expect(page.getByTestId("create-project-channel-dialog")).toBeVisible(); + await expect(page.getByTestId("add-project-channel-dialog")).toBeVisible(); await page.keyboard.press("Escape"); - await expect(page.getByTestId("create-project-channel-dialog")).toBeHidden(); + await expect(page.getByTestId("add-project-channel-dialog")).toBeHidden(); await expect( page.getByTestId("sidebar-project-multi-repo-demo"), ).toBeVisible(); diff --git a/desktop/tests/e2e/project-related-channels.spec.ts b/desktop/tests/e2e/project-related-channels.spec.ts new file mode 100644 index 00000000000..4c8e725b60a --- /dev/null +++ b/desktop/tests/e2e/project-related-channels.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const WELCOME_CHANNEL_ID = "5f0b1b3c-2a37-5366-9b8c-31a4b21d8e77"; +const WELCOME_CHANNEL_ROW = "project-home-context-channel-welcome-everyone"; + +async function openBuzzProject(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-projects-view").click(); + await page.getByTestId("projects-section-projects").click(); + const projectEntry = page + .locator( + '[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]', + ) + .first(); + await expect(projectEntry).toBeVisible({ timeout: 10_000 }); + await projectEntry.click(); + await expect(page.getByTestId("project-home-context-panel")).toBeVisible(); +} + +async function addWelcomeChannel(page: import("@playwright/test").Page) { + await page.getByTestId("project-home-context-channel").hover(); + await page.getByTestId("add-project-channel").click(); + const dialog = page.getByTestId("add-project-channel-dialog"); + await expect(dialog).toBeVisible(); + await dialog + .getByTestId(`add-existing-project-channel-${WELCOME_CHANNEL_ID}`) + .click(); + await expect(dialog).toBeHidden(); + await expect(page.getByTestId(WELCOME_CHANNEL_ROW)).toBeVisible(); +} + +async function acceptedProjectChanges(page: import("@playwright/test").Page) { + return page.evaluate(() => + (window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ ?? []) + .filter((event) => event.kind === 47010) + .map((event) => ({ + content: JSON.parse(event.content), + tags: event.tags, + })), + ); +} + +test("Project owner links an existing channel", async ({ page }) => { + await installMockBridge(page); + await openBuzzProject(page); + await addWelcomeChannel(page); + + await expect + .poll(async () => (await acceptedProjectChanges(page)).length) + .toBe(1); + const [change] = await acceptedProjectChanges(page); + expect(change.tags).toEqual([ + ["a", `30621:${"deadbeef".repeat(8)}:buzz`], + ["expected-revision", "1"], + ]); + expect(change.content).toEqual({ + v: 1, + patch: { + related_channels: { add: [WELCOME_CHANNEL_ID], remove: [] }, + }, + }); +}); + +test("home-channel admin links and removes an existing channel", async ({ + page, +}) => { + await page.addInitScript((owner) => { + window.__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__ = owner; + }, TEST_IDENTITIES.alice.pubkey); + await installMockBridge(page, { projectHomeRole: "admin" }); + await openBuzzProject(page); + await addWelcomeChannel(page); + + const row = page.getByTestId(WELCOME_CHANNEL_ROW); + await row.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Remove from project" }).click(); + await expect(row).toHaveCount(0); + + await expect + .poll(async () => (await acceptedProjectChanges(page)).length) + .toBe(2); + const [add, remove] = await acceptedProjectChanges(page); + expect(add.tags).toContainEqual(["expected-revision", "1"]); + expect(remove.tags).toContainEqual(["expected-revision", "2"]); + expect(add.content.patch.related_channels).toEqual({ + add: [WELCOME_CHANNEL_ID], + remove: [], + }); + expect(remove.content.patch.related_channels).toEqual({ + add: [], + remove: [WELCOME_CHANNEL_ID], + }); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 6694be68f5f..3c88095bb60 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -156,6 +156,8 @@ type MockBridgeOptions = { pocketVoiceImportResult?: "success" | "cancel" | "invalid"; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; + /** Role held by the mock viewer in the Buzz Project's home channel. */ + projectHomeRole?: "owner" | "admin" | "member"; /** Relay NIP-11 identity used to sign authoritative repository state. */ relaySelf?: string | null; /** Native-like huddle state seeded from authoritative role-bearing membership. */