From 093e174409077db0c31737eb7dcb4e86e5c12e44 Mon Sep 17 00:00:00 2001 From: dawn Date: Fri, 11 Sep 2026 00:09:00 +0300 Subject: [PATCH] web: gate repo settings on the viewer's repo access Signed-off-by: dawn --- web/src/lib/api/access.test.ts | 65 ++++++++++++ web/src/lib/api/access.ts | 52 ++++++++++ web/src/lib/api/collaborator.ts | 20 ---- ...settingspanel--skeleton-fixture.bones.json | 98 ------------------- .../components/repo/RepoChrome.stories.svelte | 2 + web/src/lib/components/repo/RepoChrome.svelte | 5 +- .../components/repo/RepoTabs.stories.svelte | 3 + web/src/lib/components/repo/RepoTabs.svelte | 12 ++- .../settings/SettingsPanel.stories.svelte | 42 -------- web/src/routes/[handle]/[repo]/+layout.ts | 10 +- .../routes/[handle]/[repo]/+layout@.svelte | 2 + .../[[range]]/PullViewPage.stories.svelte | 3 +- .../[version]/[[range]]/PullViewPage.svelte | 7 +- .../[handle]/[repo]/settings/+layout.svelte | 41 ++++---- .../[handle]/[repo]/settings/+layout.ts | 32 ++++++ 15 files changed, 196 insertions(+), 198 deletions(-) create mode 100644 web/src/lib/api/access.test.ts create mode 100644 web/src/lib/api/access.ts delete mode 100644 web/src/lib/api/collaborator.ts delete mode 100644 web/src/lib/bones/settings-settingspanel--skeleton-fixture.bones.json create mode 100644 web/src/routes/[handle]/[repo]/settings/+layout.ts diff --git a/web/src/lib/api/access.test.ts b/web/src/lib/api/access.test.ts new file mode 100644 index 000000000..c35e4b7a1 --- /dev/null +++ b/web/src/lib/api/access.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Did } from "@atcute/lexicons/syntax"; +import { createBobbinClient } from "$lib/api/client"; +import { NO_ACCESS, resolveRepoAccess } from "$lib/api/access"; + +const owner = "did:plc:owner" as Did; +const repoDid = "did:plc:repo" as Did; + +const bobbin = (items: { subject: string }[]) => { + const fetch = vi.fn( + async () => + new Response(JSON.stringify({ items }), { + status: 200, + headers: { "content-type": "application/json" } + }) + ); + return { ctx: createBobbinClient({ serviceUrl: "https://bobbin.test", fetch }), fetch }; +}; + +describe("resolveRepoAccess", () => { + it("gives the owner everything without asking the knot", async () => { + const { ctx, fetch } = bobbin([]); + + await expect(resolveRepoAccess(ctx, { ownerDid: owner, repoDid }, owner)).resolves.toEqual({ + role: "owner", + canPush: true, + canAdminister: true + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("gives an accepted collaborator push but not administration", async () => { + const { ctx } = bobbin([{ subject: "did:plc:friend" }]); + + await expect( + resolveRepoAccess(ctx, { ownerDid: owner, repoDid }, "did:plc:friend") + ).resolves.toEqual({ role: "collaborator", canPush: true, canAdminister: false }); + }); + + it("gives a stranger nothing", async () => { + const { ctx } = bobbin([{ subject: "did:plc:friend" }]); + + await expect( + resolveRepoAccess(ctx, { ownerDid: owner, repoDid }, "did:plc:stranger") + ).resolves.toEqual(NO_ACCESS); + }); + + it("gives a signed-out viewer nothing", async () => { + const { ctx, fetch } = bobbin([]); + + await expect( + resolveRepoAccess(ctx, { ownerDid: owner, repoDid }, undefined) + ).resolves.toEqual(NO_ACCESS); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("has no collaborators to check on a repo without a DID", async () => { + const { ctx, fetch } = bobbin([{ subject: "did:plc:friend" }]); + + await expect( + resolveRepoAccess(ctx, { ownerDid: owner }, "did:plc:friend") + ).resolves.toEqual(NO_ACCESS); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/api/access.ts b/web/src/lib/api/access.ts new file mode 100644 index 000000000..6c2c571df --- /dev/null +++ b/web/src/lib/api/access.ts @@ -0,0 +1,52 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import { error } from "@sveltejs/kit"; +import type { BobbinContext, XrpcRequestInit } from "$lib/api/client"; +import { items } from "$lib/api/pagination"; + +export type RepoRole = "owner" | "collaborator" | "none"; + +export interface RepoAccess { + readonly role: RepoRole; + readonly canPush: boolean; + readonly canAdminister: boolean; +} + +export const NO_ACCESS: RepoAccess = { role: "none", canPush: false, canAdminister: false }; + +export const accessFor = (role: RepoRole): RepoAccess => ({ + role, + canPush: role !== "none", + canAdminister: role === "owner" +}); + +export interface RepoIdentity { + readonly ownerDid: string; + readonly repoDid?: string; +} + +const COLLABORATOR_PAGES = 5; + +// pending invites grant nothing, the knot requires an acceptance record +export const resolveRepoAccess = async ( + ctx: BobbinContext, + repo: RepoIdentity, + viewerDid: string | undefined, + init?: XrpcRequestInit +): Promise => { + if (!viewerDid) return NO_ACCESS; + if (viewerDid === repo.ownerDid) return accessFor("owner"); + if (!repo.repoDid) return NO_ACCESS; + for await (const collaborator of items( + ctx, + "sh.tangled.repo.listCollaborators", + { subject: repo.repoDid as Did, limit: 100 }, + { ...init, maxPages: COLLABORATOR_PAGES } + )) { + if (collaborator.subject === viewerDid) return accessFor("collaborator"); + } + return NO_ACCESS; +}; + +export const requireRepoAdmin = (access: RepoAccess): void => { + if (!access.canAdminister) error(403, "Only the repository owner can change this"); +}; diff --git a/web/src/lib/api/collaborator.ts b/web/src/lib/api/collaborator.ts deleted file mode 100644 index b8be62b06..000000000 --- a/web/src/lib/api/collaborator.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Did } from "@atcute/lexicons/syntax"; -import type { BobbinContext } from "$lib/api/client"; -import { items } from "$lib/api/pagination"; - -export const resolvePushAccess = async ( - ctx: BobbinContext, - repo: { ownerDid: string; repoDid?: string }, - viewerDid: string | undefined -): Promise => { - if (!viewerDid) return false; - if (viewerDid === repo.ownerDid) return true; - if (!repo.repoDid) return false; - for await (const collaborator of items(ctx, "sh.tangled.repo.listCollaborators", { - subject: repo.repoDid as Did, - limit: 100 - })) { - if (collaborator.subject === viewerDid) return true; - } - return false; -}; diff --git a/web/src/lib/bones/settings-settingspanel--skeleton-fixture.bones.json b/web/src/lib/bones/settings-settingspanel--skeleton-fixture.bones.json deleted file mode 100644 index 0fec2f2f8..000000000 --- a/web/src/lib/bones/settings-settingspanel--skeleton-fixture.bones.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "breakpoints": { - "375": { - "name": "settings-settingspanel--skeleton-fixture", - "viewportWidth": 375, - "width": 375, - "height": 445, - "bones": [ - [0, 0, 100, 445, 0, true, 5, true], - [3.2, 13, 93.6, 36, 4], - [3.2, 65, 93.6, 1, 0], - [3.2, 79, 26.8083, 36, 4], - [3.2, 139, 23.9458, 17, 4], - [3.2, 163, 93.3917, 17, 4], - [3.2, 187, 62.8042, 17, 4], - [53.9333, 216, 42.8667, 36, 4], - [3.2, 268, 93.6, 1, 0], - [3.2, 285, 93.6, 135, 4, true, 15, false], - [7.7333, 290, 84.5333, 41, 0, true, 4, false], - [7.7333, 298, 27.0792, 24, 4], - [7.7333, 339, 39.775, 24, 4], - [42.3875, 371, 49.8792, 36, 4] - ] - }, - "768": { - "name": "settings-settingspanel--skeleton-fixture", - "viewportWidth": 768, - "width": 768, - "height": 600, - "bones": [ - [0, 0, 100, 600, 4, true, 15, true], - [0.1302, 1, 22.9167, 598, 0, true, 2, true], - [2.8646, 21, 1.8229, 14, 0], - [5.4688, 19, 6.7749, 17, 4], - [1.1719, 51, 20.7031, 38, 4], - [27.2135, 22, 13.09, 36, 4], - [27.2135, 90, 11.6923, 17, 4], - [27.2135, 114, 40.8773, 17, 4], - [27.2135, 138, 35.3902, 17, 4], - [76.8555, 123, 20.931, 36, 4], - [27.2135, 175, 70.5729, 1, 0], - [27.2135, 192, 70.5729, 103, 4, true, 15, false], - [29.4271, 197, 66.1458, 41, 0, true, 4, false], - [29.4271, 205, 13.2222, 24, 4], - [29.4271, 252, 19.4214, 24, 4], - [71.2179, 246, 24.3551, 36, 4] - ] - }, - "1024": { - "name": "settings-settingspanel--skeleton-fixture", - "viewportWidth": 1024, - "width": 1024, - "height": 600, - "bones": [ - [0, 0, 100, 600, 4, true, 15, true], - [0.0977, 1, 17.1875, 598, 0, true, 2, true], - [2.1484, 21, 1.3672, 14, 0], - [4.1016, 19, 5.0812, 17, 4], - [0.8789, 51, 15.5273, 38, 4], - [20.4102, 22, 9.8175, 36, 4], - [20.4102, 90, 8.7692, 17, 4], - [20.4102, 114, 50.2762, 17, 4], - [20.4102, 138, 6.9244, 17, 4], - [82.6416, 123, 15.6982, 36, 4], - [20.4102, 175, 77.9297, 1, 0], - [20.4102, 192, 77.9297, 103, 4, true, 15, false], - [22.0703, 197, 74.6094, 41, 0, true, 4, false], - [22.0703, 205, 9.9167, 24, 4], - [22.0703, 252, 14.566, 24, 4], - [78.4134, 246, 18.2663, 36, 4] - ] - }, - "1440": { - "name": "settings-settingspanel--skeleton-fixture", - "viewportWidth": 1440, - "width": 1440, - "height": 600, - "bones": [ - [0, 0, 100, 600, 4, true, 15, true], - [0.0694, 1, 12.2222, 598, 0, true, 2, true], - [1.5278, 21, 0.9722, 14, 0], - [2.9167, 19, 3.6133, 17, 4], - [0.625, 51, 11.0417, 38, 4], - [14.5139, 22, 6.9813, 36, 4], - [14.5139, 90, 6.2359, 17, 4], - [14.5139, 114, 40.9364, 17, 4], - [87.6563, 99, 11.1632, 36, 4], - [14.5139, 151, 84.3056, 1, 0], - [14.5139, 168, 84.3056, 103, 4, true, 15, false], - [15.6944, 173, 81.9444, 41, 0, true, 4, false], - [15.6944, 181, 7.0519, 24, 4], - [15.6944, 228, 10.3581, 24, 4], - [84.6495, 222, 12.9894, 36, 4] - ] - } - }, - "_hash": "914e7823a14bf17d41ca9feeaf712e5c" -} diff --git a/web/src/lib/components/repo/RepoChrome.stories.svelte b/web/src/lib/components/repo/RepoChrome.stories.svelte index 19158fee4..f15d23620 100644 --- a/web/src/lib/components/repo/RepoChrome.stories.svelte +++ b/web/src/lib/components/repo/RepoChrome.stories.svelte @@ -1,6 +1,7 @@ diff --git a/web/src/lib/components/settings/SettingsPanel.stories.svelte b/web/src/lib/components/settings/SettingsPanel.stories.svelte index 965e8a09a..77d29c1ff 100644 --- a/web/src/lib/components/settings/SettingsPanel.stories.svelte +++ b/web/src/lib/components/settings/SettingsPanel.stories.svelte @@ -122,48 +122,6 @@ - - - - - {#snippet action()} - - {/snippet} - - - {#snippet label()} - - user.tld - Owner - - {/snippet} - - - {#snippet label()} - - user2.tld - Collaborator - - {/snippet} - - - - - - -
diff --git a/web/src/routes/[handle]/[repo]/+layout.ts b/web/src/routes/[handle]/[repo]/+layout.ts index 25180eb3b..6d2f35b18 100644 --- a/web/src/routes/[handle]/[repo]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/+layout.ts @@ -1,7 +1,7 @@ import { browser } from "$app/environment"; import { error, redirect } from "@sveltejs/kit"; +import { NO_ACCESS, resolveRepoAccess } from "$lib/api/access"; import { createBobbinClient } from "$lib/api/client"; -import { resolvePushAccess } from "$lib/api/collaborator"; import { count } from "$lib/api/count"; import { REPO_OWNER_DOCS } from "$lib/api/descriptors"; import { authorOf, enrich, target } from "$lib/api/enrich"; @@ -151,8 +151,8 @@ export const load: LayoutLoad = async (event) => { const sourcePromise = stream(resolved.then(({ record }) => resolveSource(ctx, record.source))); - const viewerCanPushPromise = stream( - resolved.then(({ repo }) => resolvePushAccess(ctx, repo, viewerDid).catch(() => false)) + const viewerAccessPromise = stream( + resolved.then(({ repo }) => resolveRepoAccess(ctx, repo, viewerDid).catch(() => NO_ACCESS)) ); if (!browser || identifier.startsWith("did:")) { @@ -164,7 +164,7 @@ export const load: LayoutLoad = async (event) => { repo, counts: countsPromise, viewerStarRkey: viewerStarPromise, - viewerCanPush: viewerCanPushPromise, + viewerAccess: viewerAccessPromise, source: sourcePromise }; } @@ -173,7 +173,7 @@ export const load: LayoutLoad = async (event) => { repo: repoPromise, counts: countsPromise, viewerStarRkey: viewerStarPromise, - viewerCanPush: viewerCanPushPromise, + viewerAccess: viewerAccessPromise, source: sourcePromise }; }; diff --git a/web/src/routes/[handle]/[repo]/+layout@.svelte b/web/src/routes/[handle]/[repo]/+layout@.svelte index 94200d40e..89fe35101 100644 --- a/web/src/routes/[handle]/[repo]/+layout@.svelte +++ b/web/src/routes/[handle]/[repo]/+layout@.svelte @@ -20,6 +20,7 @@ const counts = $derived(data.counts); const viewerStarRkey = $derived(data.viewerStarRkey); const source = $derived(data.source); + const access = $derived(data.viewerAccess); const synchronousRepo = $derived(data.repo instanceof Promise ? null : data.repo); let asyncRepo = $state(null); @@ -191,6 +192,7 @@ {counts} {viewerStarRkey} {source} + {access} {activeTab} showTabs={!bare} /> diff --git a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.stories.svelte b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.stories.svelte index 0b8dcd917..4d1270006 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.stories.svelte +++ b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.stories.svelte @@ -22,6 +22,7 @@ type PullFetchOptions } from "$fixtures/pull"; import PullViewRoute from "./+page.svelte"; + import { accessFor } from "$lib/api/access"; import type { PullState } from "$lib/api/records"; import type { PullView } from "$lib/api/pullRoute"; import type { CommentView } from "$lib/components/comment/comments"; @@ -110,7 +111,7 @@ Depends on the hydrant filter change, so this is stacked on \`sv-fe\`.`; const loaded = { publicConfig: { bobbinUrl, knotMirrorUrl }, counts: { stars: 128, issues: 12, pulls: 7, forks: 19 }, - viewerCanPush: Promise.resolve(viewerCanPush), + viewerAccess: Promise.resolve(accessFor(viewerCanPush ? "collaborator" : "none")), repo, uri: pullUri, sourceRepoDid, diff --git a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte index d24a96b41..39a3f4ff5 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte +++ b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte @@ -94,10 +94,7 @@ const isAuthor = $derived(auth.currentDid === pull.author.did); const isForkBased = $derived(data.sourceRepoDid !== data.repo.repoDid); - let canPush = $state(false); - $effect(() => { - void data.viewerCanPush.then((can) => (canPush = can)); - }); + const canPush = $derived((await data.viewerAccess).canPush); const pullState = $derived(pull.state); const head = $derived(pull.versions.at(-1)?.head); @@ -585,7 +582,7 @@ {/if}
- + diff --git a/web/src/routes/[handle]/[repo]/settings/+layout.svelte b/web/src/routes/[handle]/[repo]/settings/+layout.svelte index e2562adee..79f2ff6f3 100644 --- a/web/src/routes/[handle]/[repo]/settings/+layout.svelte +++ b/web/src/routes/[handle]/[repo]/settings/+layout.svelte @@ -1,8 +1,5 @@ - - - {#snippet skeleton()} - - {/snippet} - - {@const repo = await data.repo} - {@const base = `/${repo.ownerHandle}/${repo.name}/settings`} - {@const items: SettingsNavItem[] = [ + const owner = $derived(data.access.canAdminister); + const base = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings`); + const items = $derived([ { id: "general", label: "General", href: base, icon: SlidersHorizontal }, - { id: "access", label: "Access", href: `${base}/access`, icon: UsersRound }, + ...(owner + ? [{ id: "access", label: "Access", href: `${base}/access`, icon: UsersRound }] + : []), { id: "pipelines", label: "Pipelines", href: `${base}/pipelines`, icon: Layers }, - { id: "hooks", label: "Hooks", href: `${base}/hooks`, icon: Webhook }, - { id: "sites", label: "Sites", href: `${base}/sites`, icon: Globe } - ]} - {@const active = items.some((i) => i.id === segment) ? segment : "general"} - - {@render children()} - - + ...(owner + ? [ + { id: "hooks", label: "Hooks", href: `${base}/hooks`, icon: Webhook }, + { id: "sites", label: "Sites", href: `${base}/sites`, icon: Globe } + ] + : []) + ]); + const active = $derived(items.some((item) => item.id === segment) ? segment : "general"); + + + + {@render children()} + diff --git a/web/src/routes/[handle]/[repo]/settings/+layout.ts b/web/src/routes/[handle]/[repo]/settings/+layout.ts new file mode 100644 index 000000000..0fc60be98 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/settings/+layout.ts @@ -0,0 +1,32 @@ +import { error, redirect } from "@sveltejs/kit"; +import { createBobbinClient } from "$lib/api/client"; +import { stream } from "$lib/api/load"; +import { getRepo } from "$lib/api/records"; +import { loginWithReturn } from "$lib/auth/guards"; +import type { LayoutLoad } from "./$types"; + +export const load: LayoutLoad = async (event) => { + const parent = await event.parent(); + const ctx = createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }); + + const resolved = Promise.resolve(parent.repo).then(async (repo) => ({ + repo, + record: await getRepo(ctx, repo.uri) + })); + // handled early so a guard refusal cannot surface an unhandled rejection + resolved.catch(() => {}); + + const access = await parent.viewerAccess; + if (!access.canPush) { + if (!parent.auth) { + redirect(302, loginWithReturn(event.url.pathname + event.url.search)); + } + error(403, "You need push access to this repository to change its settings"); + } + + const { repo, record } = await resolved; + return { access, repo, record, defaultBranch: stream(Promise.resolve(repo.defaultBranch)) }; +}; -- 2.51.2