diff --git a/desktop/src/features/projects/lib/projectHomeChannel.test.mjs b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs index a7c02ccb6a..3faaab0c0c 100644 --- a/desktop/src/features/projects/lib/projectHomeChannel.test.mjs +++ b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs @@ -80,13 +80,58 @@ test("isProjectHomeChannel is false for unbound channels", () => { assert.equal(isProjectHomeChannel(null, [project()]), false); }); -test("findProjectHomeByChannelId prefers the oldest listed home", () => { +test("findProjectHomeByChannelId rejects bare and unauthorized route assertions", () => { const base = { createdAt: 0, legacy: false, + owner: OWNER, projectChannelId: "channel-a", visibility: "listed", }; + + assert.equal( + findProjectHomeByChannelId("channel-a", [ + { ...base, id: "bare", repositories: [] }, + { + ...base, + id: "unauthorized", + owner: MAINTAINER, + repositories: [{ channelId: "channel-a", owner: OWNER }], + }, + ]), + null, + ); +}); + +test("findProjectHomeByChannelId ignores an older unauthorized competitor", () => { + const base = { + createdAt: 0, + legacy: false, + owner: OWNER, + projectChannelId: "channel-a", + visibility: "listed", + }; + const selected = findProjectHomeByChannelId("channel-a", [ + { ...base, createdAt: 50, id: "attacker", repositories: [] }, + { + ...base, + createdAt: 100, + id: "authorized", + repositories: [{ channelId: "channel-a", owner: OWNER }], + }, + ]); + assert.equal(selected?.id, "authorized"); +}); + +test("findProjectHomeByChannelId prefers the oldest listed authoritative home", () => { + const base = { + createdAt: 0, + legacy: false, + owner: OWNER, + projectChannelId: "channel-a", + repositories: [{ channelId: "channel-a", owner: OWNER }], + visibility: "listed", + }; const selected = findProjectHomeByChannelId("channel-a", [ { ...base, createdAt: 200, id: "later" }, { ...base, createdAt: 50, id: "hidden", visibility: "unlisted" }, diff --git a/desktop/src/features/projects/lib/projectHomeChannel.ts b/desktop/src/features/projects/lib/projectHomeChannel.ts index 091a4409bf..5389aa22fd 100644 --- a/desktop/src/features/projects/lib/projectHomeChannel.ts +++ b/desktop/src/features/projects/lib/projectHomeChannel.ts @@ -9,7 +9,10 @@ export function findProjectHomeByChannelId( if (!channelId) return null; const matching = projects .filter( - (project) => !project.legacy && project.projectChannelId === channelId, + (project) => + !project.legacy && + project.projectChannelId === channelId && + hasAuthoritativeHomeBinding(project), ) .sort((left, right) => left.createdAt - right.createdAt); return ( diff --git a/desktop/src/features/projects/projectChannelRequestQueue.test.mjs b/desktop/src/features/projects/projectChannelRequestQueue.test.mjs new file mode 100644 index 0000000000..1fd49cd98b --- /dev/null +++ b/desktop/src/features/projects/projectChannelRequestQueue.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + advanceProjectChannelRequestQueue, + createProjectChannelRequestQueue, + enqueueProjectChannelRequest, +} from "./projectChannelRequestQueue.ts"; + +function candidate(requestId) { + return { + agentPubkey: `agent-${requestId}`, + request: { requestId }, + }; +} + +test("accepted requests advance in order while duplicate active requests stay suppressed", () => { + const queue = createProjectChannelRequestQueue(); + const first = candidate("request-a"); + const second = candidate("request-b"); + + assert.equal(enqueueProjectChannelRequest(queue, first), first); + assert.equal(enqueueProjectChannelRequest(queue, second), null); + assert.equal(enqueueProjectChannelRequest(queue, first), null); + assert.equal(advanceProjectChannelRequestQueue(queue), second); + assert.equal(enqueueProjectChannelRequest(queue, first), null); + assert.equal(advanceProjectChannelRequestQueue(queue), null); +}); diff --git a/desktop/src/features/projects/projectChannelRequestQueue.ts b/desktop/src/features/projects/projectChannelRequestQueue.ts new file mode 100644 index 0000000000..24fc3a5ecb --- /dev/null +++ b/desktop/src/features/projects/projectChannelRequestQueue.ts @@ -0,0 +1,45 @@ +import type { ProjectChannelRequest } from "@/features/projects/projectChannelRequest"; + +export type AcceptedProjectChannelRequest = { + agentPubkey: string; + request: ProjectChannelRequest; +}; + +export type ProjectChannelRequestQueue = { + activeRequestId: string | null; + pending: AcceptedProjectChannelRequest[]; + seenRequestIds: Set; +}; + +export function createProjectChannelRequestQueue(): ProjectChannelRequestQueue { + return { + activeRequestId: null, + pending: [], + seenRequestIds: new Set(), + }; +} + +export function enqueueProjectChannelRequest( + queue: ProjectChannelRequestQueue, + candidate: AcceptedProjectChannelRequest, +): AcceptedProjectChannelRequest | null { + const requestId = candidate.request.requestId; + if (queue.seenRequestIds.has(requestId)) return null; + queue.seenRequestIds.add(requestId); + + if (queue.activeRequestId === null) { + queue.activeRequestId = requestId; + return candidate; + } + + queue.pending.push(candidate); + return null; +} + +export function advanceProjectChannelRequestQueue( + queue: ProjectChannelRequestQueue, +): AcceptedProjectChannelRequest | null { + const next = queue.pending.shift() ?? null; + queue.activeRequestId = next?.request.requestId ?? null; + return next; +} diff --git a/desktop/src/features/projects/useProjectChannelRequests.ts b/desktop/src/features/projects/useProjectChannelRequests.ts index e8dae441c8..371bb1a9c1 100644 --- a/desktop/src/features/projects/useProjectChannelRequests.ts +++ b/desktop/src/features/projects/useProjectChannelRequests.ts @@ -8,6 +8,12 @@ import { 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"; +import { + advanceProjectChannelRequestQueue, + createProjectChannelRequestQueue, + enqueueProjectChannelRequest, + type AcceptedProjectChannelRequest, +} from "@/features/projects/projectChannelRequestQueue"; import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel"; import { useIdentityQuery } from "@/shared/api/hooks"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -26,8 +32,7 @@ export function useProjectChannelRequests() { string | null >(null); const [error, setError] = React.useState(null); - const seenRequestIds = React.useRef(new Set()); - const pendingRequestId = React.useRef(null); + const acceptedRequests = React.useRef(createProjectChannelRequestQueue()); const bufferedRequests = React.useRef< Array<{ agentPubkey: string; request: ProjectChannelRequest }> >([]); @@ -42,17 +47,15 @@ export function useProjectChannelRequests() { channelsRef.current, agentPubkey, next.request.homeChannelId, - ) !== "accept" || - seenRequestIds.current.has(next.requestId) + ) !== "accept" ) { return; } - seenRequestIds.current.add(next.requestId); - if (pendingRequestId.current !== null) return; - pendingRequestId.current = next.requestId; - setError(null); - setSourceAgentPubkey(agentPubkey); - setRequest(next); + const accepted = enqueueProjectChannelRequest(acceptedRequests.current, { + agentPubkey, + request: next, + }); + if (accepted) showRequest(accepted); }, ); @@ -87,6 +90,16 @@ export function useProjectChannelRequests() { [], ); + function showRequest(candidate: AcceptedProjectChannelRequest | null) { + setError(null); + setSourceAgentPubkey(candidate?.agentPubkey ?? null); + setRequest(candidate?.request ?? null); + } + + function advanceRequestQueue() { + showRequest(advanceProjectChannelRequestQueue(acceptedRequests.current)); + } + const project = request == null ? null @@ -109,10 +122,7 @@ export function useProjectChannelRequests() { function dismiss() { if (mutation.isPending) return; - setRequest(null); - setSourceAgentPubkey(null); - pendingRequestId.current = null; - setError(null); + advanceRequestQueue(); } async function approve() { @@ -148,9 +158,7 @@ export function useProjectChannelRequests() { visibility: request.request.visibility, }); toast.success(`Channel "#${request.request.name}" created.`); - setRequest(null); - setSourceAgentPubkey(null); - pendingRequestId.current = null; + advanceRequestQueue(); } catch (nextError) { setError( nextError instanceof Error