fix(projects): secure project request routing

Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
This commit is contained in:
Wrench
2026-08-25 18:29:15 -04:00
parent 90f046d53b
commit dd9c6d896a
5 changed files with 148 additions and 19 deletions

View File

@@ -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" },

View File

@@ -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 (

View File

@@ -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);
});

View File

@@ -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<string>;
};
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;
}

View File

@@ -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<string | null>(null);
const seenRequestIds = React.useRef(new Set<string>());
const pendingRequestId = React.useRef<string | null>(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