diff --git a/web/src/lib/api/sites.test.ts b/web/src/lib/api/sites.test.ts new file mode 100644 index 000000000..cebd22405 --- /dev/null +++ b/web/src/lib/api/sites.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import oauthMetadata from "$lib/oauth-client-metadata"; +import { missingPermissions } from "$lib/auth/scopes"; + +vi.mock("$lib/auth/agent", () => ({ + createClient: () => ({}), + mintServiceAuth: async () => "token", + serviceDidForHost: (host: string) => `did:web:${host}`, + hostForServiceDid: () => null +})); + +const { sitesPermissions } = await import("$lib/api/sites"); + +const sites = new URL("https://sites.test"); + +describe("sites permissions", () => { + it("covers every method the repo sites panel calls, deploySite included", () => { + const permissions = sitesPermissions(sites); + expect(permissions).toHaveLength(4); + expect(missingPermissions("atproto", permissions)).toEqual(permissions); + expect(missingPermissions(oauthMetadata.scope, permissions)).toEqual([]); + expect(permissions).toContainEqual({ + resource: "rpc", + lxm: "org.tangled.temp.repo.deploySite", + aud: "did:web:sites.test" + }); + }); +}); diff --git a/web/src/lib/api/sites.ts b/web/src/lib/api/sites.ts index bb9f8bf18..cbd15f712 100644 --- a/web/src/lib/api/sites.ts +++ b/web/src/lib/api/sites.ts @@ -1,5 +1,9 @@ import { authedGet, authedPost, type AppviewContext } from "$lib/api/appview"; +import { serviceDidForHost } from "$lib/auth/agent"; +import type { Permission } from "$lib/auth/scopes"; +import { didOf } from "$lib/api/syntax"; import type { XrpcRequestInit } from "$lib/api/client"; +import type { Nsid } from "@atcute/lexicons/syntax"; interface DomainClaimResponse { domain?: string; @@ -11,27 +15,18 @@ const RELEASE_DOMAIN = "org.tangled.temp.site.releaseDomain"; const GET_SITE_CONFIG = "org.tangled.temp.repo.getSiteConfig"; const UPDATE_SITE_CONFIG = "org.tangled.temp.repo.updateSiteConfig"; const DISABLE_SITE = "org.tangled.temp.repo.disableSite"; +const DEPLOY_SITE = "org.tangled.temp.repo.deploySite"; +// these procedures differ only by nsid and input shape +const posting = + (nsid: string, body: (arg: Arg) => object) => + (ctx: AppviewContext, arg: Arg, init?: XrpcRequestInit): Promise => + authedPost(ctx, nsid, body(arg), init).then(() => undefined); -export const getDomainClaim = async ( - ctx: AppviewContext, - init?: XrpcRequestInit -): Promise => { - const res = await authedGet(ctx, GET_DOMAIN_CLAIM, undefined, init); - return res.domain ?? null; -}; - -export const claimDomain = ( - ctx: AppviewContext, - domain: string, - init?: XrpcRequestInit -): Promise => authedPost(ctx, CLAIM_DOMAIN, { domain }, init).then(() => undefined); +const byRepoDid = (repoDid: string) => ({ repoDid }); -export const releaseDomain = ( - ctx: AppviewContext, - domain: string, - init?: XrpcRequestInit -): Promise => authedPost(ctx, RELEASE_DOMAIN, { domain }, init).then(() => undefined); +export const claimDomain = posting(CLAIM_DOMAIN, (domain: string) => ({ domain })); +export const releaseDomain = posting(RELEASE_DOMAIN, (domain: string) => ({ domain })); export interface SiteConfig { branch: string; @@ -43,15 +38,6 @@ interface SiteConfigResponse { config?: SiteConfig; } -export const getSiteConfig = async ( - ctx: AppviewContext, - repoDid: string, - init?: XrpcRequestInit -): Promise => { - const res = await authedGet(ctx, GET_SITE_CONFIG, { repoDid }, init); - return res.config ?? null; -}; - export interface SiteConfigParams { repoDid: string; name: string; @@ -62,14 +48,31 @@ export interface SiteConfigParams { isIndex: boolean; } -export const updateSiteConfig = ( +export const updateSiteConfig = posting(UPDATE_SITE_CONFIG, (params: SiteConfigParams) => params); +export const deploySite = posting(DEPLOY_SITE, byRepoDid); +export const disableSite = posting(DISABLE_SITE, byRepoDid); + +const SITE_METHODS = [GET_SITE_CONFIG, UPDATE_SITE_CONFIG, DISABLE_SITE, DEPLOY_SITE] as const; + +// repo-panel methods; the account-level claim tab (/settings/sites) needs no grant +export const sitesPermissions = (service: URL): readonly Permission[] => { + const aud = didOf(serviceDidForHost(service.host)); + return SITE_METHODS.map((lxm) => ({ resource: "rpc", lxm: lxm as Nsid, aud })); +}; + +export const getDomainClaim = async ( ctx: AppviewContext, - params: SiteConfigParams, init?: XrpcRequestInit -): Promise => authedPost(ctx, UPDATE_SITE_CONFIG, params, init).then(() => undefined); +): Promise => { + const res = await authedGet(ctx, GET_DOMAIN_CLAIM, undefined, init); + return res.domain ?? null; +}; -export const disableSite = ( +export const getSiteConfig = async ( ctx: AppviewContext, repoDid: string, init?: XrpcRequestInit -): Promise => authedPost(ctx, DISABLE_SITE, { repoDid }, init).then(() => undefined); \ No newline at end of file +): Promise => { + const res = await authedGet(ctx, GET_SITE_CONFIG, { repoDid }, init); + return res.config ?? null; +}; diff --git a/web/src/lib/oauth-client-metadata.ts b/web/src/lib/oauth-client-metadata.ts index 055d2ae84..888b0eb91 100644 --- a/web/src/lib/oauth-client-metadata.ts +++ b/web/src/lib/oauth-client-metadata.ts @@ -59,9 +59,10 @@ const scopes = [ "rpc:org.tangled.temp.site.claimDomain?aud=*", "rpc:org.tangled.temp.site.getDomainClaim?aud=*", "rpc:org.tangled.temp.site.releaseDomain?aud=*", + "rpc:org.tangled.temp.repo.disableSite?aud=*", + "rpc:org.tangled.temp.repo.deploySite?aud=*", "rpc:org.tangled.temp.repo.getSiteConfig?aud=*", "rpc:org.tangled.temp.repo.updateSiteConfig?aud=*", - "rpc:org.tangled.temp.repo.disableSite?aud=*", "rpc:sh.tangled.actor.getTrending?aud=*", "rpc:sh.tangled.ci.cancelPipeline?aud=*", "rpc:sh.tangled.ci.triggerPipeline?aud=*", diff --git a/web/src/routes/[handle]/[repo]/settings/sites/+page.svelte b/web/src/routes/[handle]/[repo]/settings/sites/+page.svelte index 42c64b82e..95e395c45 100644 --- a/web/src/routes/[handle]/[repo]/settings/sites/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/sites/+page.svelte @@ -1,6 +1,7 @@ +{#snippet siteType(value: "index" | "subpath", label: string, served: string)} +
+ pick({ type: value })} name="site-type" + >{label} + {served} +
+{/snippet} + {#snippet skeleton()} @@ -165,18 +207,28 @@

Serve a static site directly from this repository. Choose a branch and the directory containing your index.html.
- This settings panel is limited to the repository owner (the service itself does not - re-verify repository ownership). + This settings panel is limited to the repository owner (the service itself does not re-verify + repository ownership).

- {@const actionError = save.error ?? disable.error} + {@const actionError = save.error ?? disable.error ?? redeploy.error} - {#if loaded.loading || loaded.data === null} + {#if ungranted.length > 0} + + + + + {:else if loaded.loading || loaded.data === null} -{:else if !owner} + {:else if !owner} {:else if loaded.error} @@ -205,15 +257,21 @@ > Open + + {#if redeploy.data} + Redeploy queued + {/if} {/if} - + form.directory, (value) => pick({ directory: value })} placeholder="/" - disabled={save.loading} + disabled={busy} class="w-full sm:w-80" /> @@ -252,24 +310,8 @@ stack >
-
- pick({ type: "index" })} - name="site-type">Index site - {domain} -
-
- pick({ type: "subpath" })} - name="site-type">Sub-path site - {domain}/{repo.name} -
+ {@render siteType("index", "Index site", domain)} + {@render siteType("subpath", "Sub-path site", `${domain}/${repo.name}`)}
@@ -283,7 +325,7 @@ variant="danger" icon={Unlink} loading={disable.loading} - disabled={save.loading} + disabled={busy} onclick={confirmDisable} > Disable