Skip to content
Merged
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
2 changes: 1 addition & 1 deletion desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,7 @@ export function AppShell() {
/>
) : null}
<SidebarProvider
className="relative z-10 min-h-0 flex-1 flex-col overflow-visible"
className="relative z-10 min-h-0 min-w-0 flex-1 flex-col overflow-visible"
data-testid="app-sidebar-layer"
>
<AppProfilePanelProvider>
Expand Down
5 changes: 2 additions & 3 deletions desktop/src/features/projects/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
} from "./projectModels";
import {
buildProjectsFromFetcher,
type FetchProjectEventsExhaustively,
fetchProjectEventsExhaustively,
} from "./projectEnumeration";
import { projectMatchesRouteId } from "./projectRoutes";
Expand Down Expand Up @@ -163,9 +164,7 @@ export function eventToProject(
}

export async function fetchProjects(
fetchExhaustively: (
kinds: number[],
) => Promise<RelayEvent[]> = fetchProjectEventsExhaustively,
fetchExhaustively: FetchProjectEventsExhaustively = fetchProjectEventsExhaustively,
): Promise<Project[]> {
// Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which
// is the pure, Tauri-free core of this operation. That helper's javadoc
Expand Down
50 changes: 50 additions & 0 deletions desktop/src/features/projects/lib/projectCloneUrl.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { deriveRelayCloneUrl, effectiveCloneUrls } from "./projectCloneUrl.ts";
import {
projectRepoHost,
projectRepoHostForProject,
repositoryDisplayPath,
} from "./projectRepoHost.ts";

const OWNER = "a".repeat(64);
Expand Down Expand Up @@ -104,3 +105,52 @@ test("projectRepoHostForProject recognizes an implicit relay repository", () =>
{ kind: "buzz" },
);
});

test("repositoryDisplayPath renders an external repo as host/path without .git", () => {
assert.equal(
repositoryDisplayPath(
{
cloneUrls: ["https://github.com/block/buzz.git"],
dtag: "buzz",
owner: OWNER,
},
ORIGIN,
),
"github.com/block/buzz",
);
});

test("repositoryDisplayPath renders a relay-hosted repo as owner/repo", () => {
assert.equal(
repositoryDisplayPath(
{ cloneUrls: [], dtag: "buzz", owner: OWNER },
ORIGIN,
"thomas",
),
"thomas/buzz",
);
});

test("repositoryDisplayPath falls back to a shortened pubkey owner", () => {
assert.equal(
repositoryDisplayPath(
{ cloneUrls: [], dtag: "buzz", owner: OWNER },
ORIGIN,
),
`${"a".repeat(8)}…/buzz`,
);
});

test("repositoryDisplayPath fails closed without a resolvable clone URL", () => {
assert.equal(
repositoryDisplayPath({ cloneUrls: [], dtag: "buzz", owner: OWNER }, null),
null,
);
assert.equal(
repositoryDisplayPath(
{ cloneUrls: ["not a URL"], dtag: "buzz", owner: OWNER },
ORIGIN,
),
null,
);
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { projectRepoUnavailableReason } from "./projectRepoAvailability.ts";
import {
projectRepoUnavailableReason,
refineRepoUnavailableReason,
} from "./projectRepoAvailability.ts";

test("classifies a missing repository", () => {
assert.equal(
Expand Down Expand Up @@ -47,3 +50,58 @@ test("keeps unmatched failures generic", () => {
"unknown",
);
});

test("refines a masked 404 into an unbound-repository reason", () => {
assert.equal(
refineRepoUnavailableReason({
reason: "missing",
repositoryChannelId: null,
memberChannelIds: ["11111111-1111-4111-8111-111111111111"],
}),
"unbound",
);
});

test("refines a masked 404 into an access-denied reason for non-members", () => {
assert.equal(
refineRepoUnavailableReason({
reason: "missing",
repositoryChannelId: "22222222-2222-4222-8222-222222222222",
memberChannelIds: ["11111111-1111-4111-8111-111111111111"],
}),
"access",
);
});

test("keeps missing when the viewer is a member of the bound channel", () => {
assert.equal(
refineRepoUnavailableReason({
reason: "missing",
repositoryChannelId: "11111111-1111-4111-8111-111111111111",
memberChannelIds: ["11111111-1111-4111-8111-111111111111"],
}),
"missing",
);
});

test("does not guess while memberships are still loading", () => {
assert.equal(
refineRepoUnavailableReason({
reason: "missing",
repositoryChannelId: "22222222-2222-4222-8222-222222222222",
memberChannelIds: null,
}),
"missing",
);
});

test("never rewrites non-missing reasons", () => {
assert.equal(
refineRepoUnavailableReason({
reason: "network",
repositoryChannelId: null,
memberChannelIds: [],
}),
"network",
);
});
32 changes: 32 additions & 0 deletions desktop/src/features/projects/lib/projectRepoAvailability.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export type ProjectRepoUnavailableReason =
| "missing"
| "access"
| "unbound"
| "authentication"
| "network"
| "ref"
Expand Down Expand Up @@ -46,3 +48,33 @@ export function projectRepoUnavailableReason(
}
return "unknown";
}

/**
* The relay deliberately answers channel-ACL denials with the same 404 as a
* genuinely absent repository (SEC-005 anti-enumeration), so the git error
* alone cannot distinguish "never initialized" from "you have no access".
* The announcement events ARE visible to every relay member though, so the
* client can re-classify a `missing` result using the repository's
* `buzz-channel` binding and the viewer's own channel memberships:
*
* - no binding at all → `unbound` (the relay refuses access for everyone
* until the owner binds a channel)
* - bound to a channel the viewer is not a member of → `access`
* - bound to a channel the viewer IS a member of → keep `missing` (the
* repository truly has no git data pointer on the relay)
*
* `memberChannelIds === null` means memberships are still loading — the
* reason is left untouched rather than guessed.
*/
export function refineRepoUnavailableReason(input: {
reason: ProjectRepoUnavailableReason;
repositoryChannelId: string | null | undefined;
memberChannelIds: readonly string[] | null;
}): ProjectRepoUnavailableReason {
if (input.reason !== "missing") return input.reason;
if (!input.repositoryChannelId) return "unbound";
if (input.memberChannelIds === null) return input.reason;
return input.memberChannelIds.includes(input.repositoryChannelId)
? input.reason
: "access";
}
35 changes: 35 additions & 0 deletions desktop/src/features/projects/lib/projectRepoHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,41 @@ export function projectRepoHostForRepository(
return projectRepoHost(cloneUrl, relayOrigin);
}

/**
* Human-readable location of a repository's git data — "github.com/block/buzz"
* for external repos (host + path, `.git` stripped), or "owner/repo" for
* Buzz-hosted ones (the relay host and full owner pubkey carry no signal;
* `ownerLabel` should be the resolved profile name, falling back to a
* shortened pubkey). Returns `null` when no clone URL can be resolved.
*/
export function repositoryDisplayPath(
repository: RepositoryHostInput | null | undefined,
relayOrigin: string | null | undefined,
ownerLabel?: string | null,
): string | null {
if (!repository) return null;
const cloneUrl = effectiveCloneUrls(
repository.cloneUrls,
relayOrigin,
repository.owner,
repository.dtag,
)[0];
if (!cloneUrl) return null;

if (projectRepoHost(cloneUrl, relayOrigin).kind === "buzz") {
const owner = ownerLabel?.trim() || `${repository.owner.slice(0, 8)}…`;
return `${owner}/${repository.dtag}`;
}

try {
const url = new URL(cloneUrl);
const path = url.pathname.replace(/\.git$/, "").replace(/\/+$/, "");
return `${url.host}${path}`;
} catch {
return null;
}
}

export function projectRepoHostForProject(
project:
| RepositoryHostInput
Expand Down
133 changes: 132 additions & 1 deletion desktop/src/features/projects/lib/projectsViewHelpers.test.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,145 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { relativeTime } from "./projectsViewHelpers.ts";
import {
isProjectAccessibleToViewer,
isRepositoryAccessibleToViewer,
relativeTime,
} from "./projectsViewHelpers.ts";

const DAY_SECONDS = 24 * 60 * 60;

function localSeconds(year, month, day) {
return Math.floor(new Date(year, month, day, 12).getTime() / 1_000);
}

const REPO_OWNER = "a".repeat(64);
const VIEWER = "b".repeat(64);
const BOUND_CHANNEL = "11111111-1111-4111-8111-111111111111";
const RELAY_ORIGIN = "https://relay.example";

function makeRepository(overrides = {}) {
return {
channelId: BOUND_CHANNEL,
cloneUrls: [`${RELAY_ORIGIN}/git/${REPO_OWNER}/repo-a`],
contributors: [],
createdAt: 0,
defaultBranch: "main",
dtag: "repo-a",
id: `${REPO_OWNER}:repo-a`,
name: "repo-a",
owner: REPO_OWNER,
repoAddress: `30617:${REPO_OWNER}:repo-a`,
...overrides,
};
}

function makeAccessInput(overrides = {}) {
return {
currentPubkey: VIEWER,
localRepoNames: new Set(),
memberChannelIds: [],
relayOrigin: RELAY_ORIGIN,
...overrides,
};
}

test("a channel-bound repository is accessible only to channel members", () => {
const repository = makeRepository();

assert.equal(
isRepositoryAccessibleToViewer(
repository,
makeAccessInput({ memberChannelIds: [BOUND_CHANNEL] }),
),
true,
);
assert.equal(
isRepositoryAccessibleToViewer(
repository,
makeAccessInput({ memberChannelIds: [] }),
),
false,
);
});

test("an unbound repository stays accessible to its owner", () => {
const repository = makeRepository({ channelId: null });

assert.equal(
isRepositoryAccessibleToViewer(repository, makeAccessInput()),
false,
);
assert.equal(
isRepositoryAccessibleToViewer(
repository,
makeAccessInput({ currentPubkey: REPO_OWNER }),
),
true,
);
});

test("external hosting and local checkouts bypass the channel gate", () => {
assert.equal(
isRepositoryAccessibleToViewer(
makeRepository({ cloneUrls: ["https://github.com/acme/site.git"] }),
makeAccessInput(),
),
true,
);
assert.equal(
isRepositoryAccessibleToViewer(
makeRepository(),
makeAccessInput({ localRepoNames: new Set(["repo-a"]) }),
),
true,
);
});

test("channel-bound repositories stay visible while memberships load", () => {
assert.equal(
isRepositoryAccessibleToViewer(
makeRepository(),
makeAccessInput({ memberChannelIds: null }),
),
true,
);
});

test("a project is accessible when any repository is, or when owned", () => {
const accessible = makeRepository({ channelId: BOUND_CHANNEL });
const restricted = makeRepository({
channelId: "22222222-2222-4222-8222-222222222222",
dtag: "repo-b",
id: `${REPO_OWNER}:repo-b`,
name: "repo-b",
repoAddress: `30617:${REPO_OWNER}:repo-b`,
});
const input = makeAccessInput({ memberChannelIds: [BOUND_CHANNEL] });

assert.equal(
isProjectAccessibleToViewer(
{ owner: REPO_OWNER, repositories: [restricted, accessible] },
input,
),
true,
);
assert.equal(
isProjectAccessibleToViewer(
{ owner: REPO_OWNER, repositories: [restricted] },
input,
),
false,
);
assert.equal(
isProjectAccessibleToViewer(
{ owner: VIEWER, repositories: [restricted] },
input,
),
true,
);
});

test("relativeTime switches to an absolute date at seven days", () => {
const now = localSeconds(2025, 5, 15);

Expand Down
Loading
Loading