Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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],
);
});
Original file line number Diff line number Diff line change
@@ -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<Project, "legacy" | "owner" | "projectChannelId">;
}): 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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
collectProjectRelatedChannelRows,
listProjectBoundChannels,
listProjectChildChannels,
isExplicitProjectRelatedChannel,
projectRelatedChannelRowKey,
uniqueProjectRelatedChannelCount,
} from "./projectRelatedChannels.ts";
Expand Down Expand Up @@ -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({
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/features/projects/lib/projectRelatedChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectRelatedChannelSource, "relatedChannelIds">,
channelId: string,
): boolean {
return (project.relatedChannelIds ?? []).some(
(candidate) => candidate.trim() === channelId,
);
}
66 changes: 66 additions & 0 deletions desktop/src/features/projects/projectEnumeration.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";

import {
enumerateProjectEvents,
Expand Down Expand Up @@ -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),
Expand Down
46 changes: 45 additions & 1 deletion desktop/src/features/projects/projectEnumeration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,6 +20,7 @@ const TOMBSTONE_COORDINATE_CHUNK_SIZE = 100;
export type ProjectEventExtraFilter = {
"#a"?: string[];
"#buzz-channel"?: string[];
authors?: string[];
};

type ProjectEventFilter = ProjectEventExtraFilter & {
Expand Down Expand Up @@ -161,6 +163,37 @@ async function fetchScopedDeletionEvents(
return pages.flat();
}

async function fetchScopedProjectStateEvents(
fetchExhaustively: FetchProjectEventsExhaustively,
projectEvents: RelayEvent[],
relayPubkey: string,
): Promise<RelayEvent[]> {
const coordinates = [
...new Set(
projectEvents.flatMap((event) => {
const coordinate = eventCoordinate(event);
return coordinate ? [coordinate] : [];
}),
),
];
if (coordinates.length === 0) return [];

const pages: Promise<RelayEvent[]>[] = [];
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.
*
Expand All @@ -177,12 +210,20 @@ export async function buildProjectsFromFetcher(
relayOrigin?: string | null;
hiddenAddresses?: ReadonlySet<string>;
viewerPubkey?: string | null;
relayPubkey?: string | null;
} = {},
): Promise<Project[]> {
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,
Expand All @@ -207,6 +248,8 @@ export async function buildProjectsFromFetcher(
return absorbStandaloneProjectRepositories(
buildProjectReadModels({
projectEvents,
projectStateEvents,
relayPubkey: options.relayPubkey,
repositoryEvents,
deletionEvents: tombstoneResult.events,
relayOrigin: options.relayOrigin ?? null,
Expand All @@ -227,13 +270,14 @@ export async function buildProjectHomeFromFetcher(
relayOrigin?: string | null;
hiddenAddresses?: ReadonlySet<string>;
viewerPubkey?: string | null;
relayPubkey?: string | null;
} = {},
): Promise<Project | null> {
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] },
),
Expand Down
Loading
Loading