diff --git a/cspell.config.yaml b/cspell.config.yaml index 615fc2ee..cebcc528 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -179,6 +179,7 @@ words: - skohub - solidcommunity - solidproject + - solidweb - SRON - Tahoma - Warchoł diff --git a/docs/onboarding.md b/docs/onboarding.md index 7886a8d1..53a90cbf 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -59,7 +59,7 @@ instead, in `VITE_POD_PROVIDERS` ([configuration.md](configuration.md#vite_pod_p `AppShell` takes the list as its `providers`: - providers **with a `signUpUrl`** are offered on the first step for - creating a Pod (currently iGrant.io Data Pod); + creating a Pod ([which ones](#offered-for-creating-a-pod)); - **every** provider is suggested on the WebID step under "Or pick your provider", for users who do not want to type their WebID. Picking one calls `loginWithProvider(oidcIssuer)`, which starts the same Solid-OIDC @@ -69,6 +69,28 @@ instead, in `VITE_POD_PROVIDERS` ([configuration.md](configuration.md#vite_pod_p Issuers are the exact values each provider advertises in its `/.well-known/openid-configuration`. +### Offered for creating a Pod + +Every suggested provider is offered for creating a Pod: each lets anyone +sign up, with no invite and no waiting list (checked on 2026-10-24). + +| Provider | Sign-up page | +| --- | --- | +| iGrant.io Data Pod | | +| solidcommunity.net | | +| Inrupt PodSpaces | | +| solidweb.me | | + +solidcommunity.net and solidweb.me run the Community Solid Server: its +"Create account" page makes the account, and the Pod is created from the +account's page after that. Inrupt PodSpaces is a Developer Preview, which +Inrupt says is not for production use or for sensitive or personal data. + +`npm run check:providers` requests each sign-up page weekly +([testing.md](testing.md#pod-providers)). A provider whose sign-up +closes, or comes to need an invite, loses its `signUpUrl` and stays +suggested for logging in; say here why it was left out. + ## WebID validation A chosen provider's issuer passes `isSecureUrl` in the use case, like every diff --git a/docs/testing.md b/docs/testing.md index 7a031367..d1d882f6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -897,7 +897,8 @@ these. `npm run check:providers` provider instead. Its issuer must answer `/.well-known/openid-configuration` with JSON whose `issuer` is the entry's `oidcIssuer`, character for character, as the login compares -them. Its `signUpUrl`, where it has one, must answer too. A request that +them. Its `signUpUrl`, where it has one, must answer too, to a request +that asks for HTML as a browser does. A request that fails or answers 5xx is tried three times. The [Links workflow](../.github/workflows/links.yml) runs the check weekly, and by hand, in a job of its own. It is not a gate, so a provider that diff --git a/packages/domain/src/podProvider.test.ts b/packages/domain/src/podProvider.test.ts index 5b8e55c8..1bc7f447 100644 --- a/packages/domain/src/podProvider.test.ts +++ b/packages/domain/src/podProvider.test.ts @@ -7,11 +7,16 @@ import { POD_PROVIDERS } from "./podProvider"; import { isSecureUrl } from "./webId"; describe("POD_PROVIDERS", () => { - it("offers iGrant.io Data Pod for creating a Pod", () => { - expect(POD_PROVIDERS.find((p) => p.id === "igrant")).toMatchObject({ - name: "iGrant.io Data Pod", - signUpUrl: "https://igrant.io/datapod.html", - }); + it("offers every suggested provider for creating a Pod", () => { + expect(POD_PROVIDERS.map(({ id, signUpUrl }) => ({ id, signUpUrl }))).toEqual([ + { id: "igrant", signUpUrl: "https://igrant.io/datapod.html" }, + { + id: "solidcommunity", + signUpUrl: "https://solidcommunity.net/.account/login/password/register/", + }, + { id: "inrupt", signUpUrl: "https://start.inrupt.com/" }, + { id: "solidweb", signUpUrl: "https://solidweb.me/.account/login/password/register/" }, + ]); }); it("only suggests https issuers and sign-up pages, with unique ids", () => { diff --git a/packages/domain/src/podProvider.ts b/packages/domain/src/podProvider.ts index 4a435864..0bdcf6c1 100644 --- a/packages/domain/src/podProvider.ts +++ b/packages/domain/src/podProvider.ts @@ -36,15 +36,18 @@ export const POD_PROVIDERS: readonly PodProvider[] = [ id: "solidcommunity", name: "solidcommunity.net", oidcIssuer: "https://solidcommunity.net/", + signUpUrl: "https://solidcommunity.net/.account/login/password/register/", }, { id: "inrupt", name: "Inrupt PodSpaces", oidcIssuer: "https://login.inrupt.com", + signUpUrl: "https://start.inrupt.com/", }, { id: "solidweb", name: "solidweb.me", oidcIssuer: "https://solidweb.me/", + signUpUrl: "https://solidweb.me/.account/login/password/register/", }, ]; diff --git a/packages/ui/src/ui/onboarding/OnboardingFlow.test.tsx b/packages/ui/src/ui/onboarding/OnboardingFlow.test.tsx index 3b174a77..518858a0 100644 --- a/packages/ui/src/ui/onboarding/OnboardingFlow.test.tsx +++ b/packages/ui/src/ui/onboarding/OnboardingFlow.test.tsx @@ -39,7 +39,9 @@ describe("OnboardingFlow", () => { expect(buttons[0]).toHaveTextContent("Try it without logging in"); expect(buttons[0]).toHaveClass("primary"); // Creating a Pod stays offered, no longer as the main action. - expect(screen.getByRole("link", { name: /Create a Pod with/ })).not.toHaveClass("primary"); + for (const link of screen.getAllByRole("link", { name: /Create a Pod with/ })) { + expect(link).not.toHaveClass("primary"); + } fireEvent.click(screen.getByRole("button", { name: "Try it without logging in" })); expect(props.onTryAsGuest).toHaveBeenCalledOnce(); }); @@ -47,19 +49,24 @@ describe("OnboardingFlow", () => { it("offers no guest's study unless it may", () => { renderFlow(); expect(screen.queryByRole("button", { name: "Try it without logging in" })).toBeNull(); - expect(screen.getByRole("link", { name: /Create a Pod with/ })).toHaveClass("primary"); + for (const link of screen.getAllByRole("link", { name: /Create a Pod with/ })) { + expect(link).toHaveClass("primary"); + } }); - it("links to iGrant.io sign-up in a new tab, without leaking the opener", () => { + it("links to each provider's sign-up in a new tab, without leaking the opener", () => { renderFlow(); - expect(screen.getByText("iGrant.io Data Pod")).toBeInTheDocument(); - const link = screen.getByRole("link", { - name: "Create a Pod with iGrant.io Data Pod (opens in a new tab)", - }); - expect(link).toHaveTextContent("Create a Pod ↗"); - expect(link).toHaveAttribute("href", "https://igrant.io/datapod.html"); - expect(link).toHaveAttribute("target", "_blank"); - expect(link).toHaveAttribute("rel", "noopener noreferrer"); + expect(screen.getAllByRole("link")).toHaveLength(POD_PROVIDERS.length); + for (const provider of POD_PROVIDERS) { + expect(screen.getByText(provider.name)).toBeInTheDocument(); + const link = screen.getByRole("link", { + name: `Create a Pod with ${provider.name} (opens in a new tab)`, + }); + expect(link).toHaveTextContent("Create a Pod ↗"); + expect(link).toHaveAttribute("href", provider.signUpUrl); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + } }); it("lists whichever providers it is given", () => { diff --git a/scripts/checkProviders.test.ts b/scripts/checkProviders.test.ts index ee5d34e9..869cbf60 100644 --- a/scripts/checkProviders.test.ts +++ b/scripts/checkProviders.test.ts @@ -42,6 +42,18 @@ describe("providerProblems", () => { expect(await providerProblems(PROVIDER, fetch)).toEqual([]); }); + it("asks for the discovery document as JSON and for the sign-up page as a browser does", async () => { + const fetch = fakeFetch({ + [CONFIGURATION]: [json({ issuer: "https://login.example/" })], + "https://example/sign-up": [new Response("")], + }); + await providerProblems(PROVIDER, fetch); + const accept = (url: string) => + fetch.mock.calls.find(([input]) => String(input) === url)?.[1]?.headers.accept; + expect(accept(CONFIGURATION)).toBe("application/json"); + expect(accept("https://example/sign-up")).toBe("text/html, */*;q=0.8"); + }); + it("reads no sign-up page for a provider that has none", async () => { const fetch = fakeFetch({ [CONFIGURATION]: [json({ issuer: "https://login.example/" })] }); const { signUpUrl: _, ...withoutSignUp } = PROVIDER; diff --git a/scripts/checkProviders.ts b/scripts/checkProviders.ts index 405c7dfd..5b8468be 100644 --- a/scripts/checkProviders.ts +++ b/scripts/checkProviders.ts @@ -22,13 +22,22 @@ export function configurationUrl(issuer: string): string { return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`; } +/** What the discovery document is asked for as. */ +const JSON_ACCEPT = "application/json"; +/** + * What a sign-up page is asked for as: a browser's request, since a newcomer + * opens it in one. Some servers (the Community Solid Server) answer a page + * of theirs only to a request that prefers HTML. + */ +const PAGE_ACCEPT = "text/html, */*;q=0.8"; + /** A GET, tried again while it fails or the server answers 5xx; the last error is thrown. */ -async function get(fetch: typeof globalThis.fetch, url: string): Promise { +async function get(fetch: typeof globalThis.fetch, url: string, accept: string): Promise { let failure: unknown; for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { try { const response = await fetch(url, { - headers: { accept: "application/json, text/html;q=0.9, */*;q=0.8" }, + headers: { accept }, signal: AbortSignal.timeout(TIMEOUT_MS), }); if (response.status < 500 || attempt === ATTEMPTS) return response; @@ -62,7 +71,7 @@ export async function providerProblems( const problems: string[] = []; const url = configurationUrl(provider.oidcIssuer); try { - const response = await get(fetch, url); + const response = await get(fetch, url, JSON_ACCEPT); if (!response.ok) { problems.push(`${provider.name}: ${url} answers ${response.status}.`); } else { @@ -78,7 +87,7 @@ export async function providerProblems( } if (provider.signUpUrl !== undefined) { try { - const response = await get(fetch, provider.signUpUrl); + const response = await get(fetch, provider.signUpUrl, PAGE_ACCEPT); if (!response.ok) problems.push( `${provider.name}: its sign-up page ${provider.signUpUrl} answers ${response.status}.`,