diff --git a/web/src/lib/api/orgCreate.test.ts b/web/src/lib/api/orgCreate.test.ts index c5d89e291..6a9372145 100644 --- a/web/src/lib/api/orgCreate.test.ts +++ b/web/src/lib/api/orgCreate.test.ts @@ -2,35 +2,64 @@ import type { Did } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getOrCreateDelegatedSession } from "$lib/api/actAs"; -import { createOrg, createOrgAccount, ORG_HANDLE_SUFFIX, OrgSetupError } from "$lib/api/orgCreate"; +import { + addOrgMembers, + createOrg, + createOrgAccount, + ORG_HANDLE_SUFFIX, + writeOrgProfile +} from "$lib/api/orgCreate"; +import { putProfile } from "$lib/api/profile"; +import { addController, getScopePresets } from "$lib/api/tranquil"; import { provisionDelegate } from "$lib/api/delegation"; vi.mock("$lib/api/delegation", () => ({ provisionDelegate: vi.fn() })); vi.mock("$lib/api/actAs", () => ({ getOrCreateDelegatedSession: vi.fn() })); +vi.mock(import("$lib/api/profile"), async (importOriginal) => ({ + ...(await importOriginal()), + uploadProfileAvatar: vi.fn(), + putProfile: vi.fn() +})); +vi.mock("$lib/api/tranquil", () => ({ + addController: vi.fn(), + getScopePresets: vi.fn() +})); -const service = { serviceUrl: "https://gate.example", serviceDid: "did:web:gate.example" }; +const service = { serviceUrl: "https://gate.example" }; +const ownerScopes = "owner-scopes"; +const editorScopes = + "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/* rpc:*"; const controllerDid = "did:plc:controller" as Did; const controller = { sub: controllerDid } as OAuthUserAgent; const orgAgent = {} as OAuthUserAgent; const orgDid = "did:plc:org" as Did; +const memberDid = "did:plc:member" as Did; const fullHandle = `acme${ORG_HANDLE_SUFFIX}`; beforeEach(() => { vi.resetAllMocks(); + vi.mocked(getScopePresets).mockResolvedValue([ + { name: "owner", label: "Owner", description: "", scopes: ownerScopes }, + { name: "editor", label: "Editor", description: "", scopes: editorScopes } + ]); vi.mocked(provisionDelegate).mockResolvedValue({ did: orgDid, handle: fullHandle, controllerDid }); vi.mocked(getOrCreateDelegatedSession).mockResolvedValue(orgAgent); + vi.mocked(addController).mockResolvedValue({ success: true }); }); describe("createOrgAccount", () => { - it("provisions through the gate with the normalized full handle", async () => { + it("provisions through the sidecar without calling the controller PDS delegation APIs", async () => { const org = await createOrgAccount(controller, " ACME ", service); expect(provisionDelegate).toHaveBeenCalledWith(controller, fullHandle, service); + expect(getScopePresets).not.toHaveBeenCalled(); expect(org).toEqual({ did: orgDid, handle: fullHandle }); + expect(putProfile).not.toHaveBeenCalled(); + expect(addController).not.toHaveBeenCalled(); }); it("rejects an invalid handle before provisioning", async () => { @@ -39,8 +68,63 @@ describe("createOrgAccount", () => { }); }); +describe("writeOrgProfile", () => { + it("writes the organization profile through its delegated agent", async () => { + await writeOrgProfile(orgAgent, { + description: " A software team ", + website: "https://example.com" + }); + + expect(putProfile).toHaveBeenCalledWith(orgAgent, { + $type: "sh.tangled.actor.profile", + bluesky: false, + isOrganization: true, + description: "A software team", + links: ["https://example.com/"] + }); + }); +}); + +describe("addOrgMembers", () => { + it("grants members Tranquil's repository editor preset", async () => { + await addOrgMembers(orgAgent, { + ownerDid: controllerDid, + members: [{ did: memberDid, handle: "member.example" }] + }); + + expect(addController).toHaveBeenCalledWith(orgAgent, { + controllerDid: memberDid, + grantedScopes: editorScopes + }); + }); + + it("ignores the owner when they are also included in the members list", async () => { + const memberErrors = await addOrgMembers(orgAgent, { + ownerDid: controllerDid, + members: [{ did: controllerDid, handle: "owner.example" }] + }); + + expect(getScopePresets).not.toHaveBeenCalled(); + expect(addController).not.toHaveBeenCalled(); + expect(memberErrors).toEqual([]); + }); + + it("returns member failures to the page flow", async () => { + vi.mocked(addController).mockRejectedValue(new Error("insufficient scope")); + + const memberErrors = await addOrgMembers(orgAgent, { + ownerDid: controllerDid, + members: [{ did: memberDid, handle: "member.example" }] + }); + + expect(memberErrors).toEqual([ + "Unable to add member.example as a member. Try again later." + ]); + }); +}); + describe("createOrg", () => { - it("provisions the account, then mints its first session", async () => { + it("orchestrates account, session, profile, and members in order", async () => { const events: string[] = []; vi.mocked(provisionDelegate).mockImplementation(async () => { events.push("account"); @@ -50,28 +134,67 @@ describe("createOrg", () => { events.push("session"); return orgAgent; }); + vi.mocked(putProfile).mockImplementation(async () => { + events.push("profile"); + }); + vi.mocked(addController).mockImplementation(async () => { + events.push("member"); + return { success: true }; + }); - const org = await createOrg(controller, "acme", service); + const org = await createOrg( + controller, + { + handle: "acme", + description: "A team", + members: [{ did: memberDid, handle: "member.example" }] + }, + service + ); - expect(events).toEqual(["account", "session"]); - expect(getOrCreateDelegatedSession).toHaveBeenCalledWith(controller, orgDid); - expect(org).toEqual({ did: orgDid, handle: fullHandle }); + expect(events).toEqual(["account", "session", "profile", "member"]); + expect(org).toEqual({ did: orgDid, handle: fullHandle, warnings: [] }); }); - it("hands the created account back when the first sign-in fails", async () => { - vi.mocked(getOrCreateDelegatedSession).mockRejectedValue(new Error("grant unavailable")); + it("continues independent setup after a partial failure and returns warnings", async () => { + vi.mocked(putProfile).mockRejectedValue(new Error("profile unavailable")); + vi.mocked(addController).mockRejectedValue(new Error("controller rejected")); - const failure = await createOrg(controller, "acme", service).catch((cause) => cause); + const org = await createOrg( + controller, + { + handle: "acme", + members: [{ did: memberDid, handle: "member.example" }] + }, + service + ); - expect(failure).toBeInstanceOf(OrgSetupError); - expect((failure as OrgSetupError).org).toEqual({ did: orgDid, handle: fullHandle }); - expect((failure as OrgSetupError).message).toMatch(/grant unavailable/); + expect(addController).toHaveBeenCalled(); + expect(org.warnings).toEqual([ + "Unable to save the organization's profile. Try again later.", + "Unable to add member.example as a member. Try again later." + ]); }); - it("does not mint a session when provisioning fails", async () => { - vi.mocked(provisionDelegate).mockRejectedValue(new Error("limit reached")); + it("reports authorization failure without attempting dependent setup", async () => { + vi.mocked(getOrCreateDelegatedSession).mockRejectedValue(new Error("grant unavailable")); + + const org = await createOrg(controller, { handle: "acme", members: [] }, service); - await expect(createOrg(controller, "acme", service)).rejects.toThrow(/limit reached/); - expect(getOrCreateDelegatedSession).not.toHaveBeenCalled(); + expect(org.warnings).toEqual([ + "Unable to authorize the organization, so its profile and members were not saved. Try again later." + ]); + expect(putProfile).not.toHaveBeenCalled(); + expect(addController).not.toHaveBeenCalled(); + }); +}); + +it("retries setup using the existing account without consuming another quota slot", async () => { + await createOrg(controller, { handle: "acme", members: [] }, service, { + did: orgDid, + handle: fullHandle }); + expect(provisionDelegate).not.toHaveBeenCalled(); + expect(getOrCreateDelegatedSession).toHaveBeenCalledWith(controller, orgDid); + expect(putProfile).toHaveBeenCalled(); }); diff --git a/web/src/lib/api/orgCreate.ts b/web/src/lib/api/orgCreate.ts index dde3c1d96..63cbe598b 100644 --- a/web/src/lib/api/orgCreate.ts +++ b/web/src/lib/api/orgCreate.ts @@ -1,19 +1,44 @@ import type { Did } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { getOrCreateDelegatedSession } from "$lib/api/actAs"; -import { provisionDelegate, type DelegationService } from "$lib/api/delegation"; +import { uploadProfileAvatar, putProfile, normalizeProfileLink } from "$lib/api/profile"; +import type { ProfileRecord } from "$lib/api/records"; import { isValidSubdomain, SUBDOMAIN_MAX_LENGTH, SUBDOMAIN_MIN_LENGTH } from "$lib/subdomain"; +import { addController, getScopePresets } from "$lib/api/tranquil"; +import { provisionDelegate, type DelegationService } from "$lib/api/delegation"; // TODO: change this export const ORG_HANDLE_SUFFIX = ".tranquil.tngl.boltless.dev"; +export interface OrgMember { + did: Did; + handle: string; +} + +export interface OrgProfileInput { + description?: string; + website?: string; + avatarFile?: File; +} + +export interface OrgCreationInput extends OrgProfileInput { + handle: string; + members: OrgMember[]; +} + +export interface AddOrgMembersInput { + ownerDid: Did; + members: OrgMember[]; +} + export interface OrgAccount { did: Did; handle: string; } -const messageOf = (cause: unknown): string => - cause instanceof Error ? cause.message : String(cause); +export interface CreatedOrg extends OrgAccount { + warnings: string[]; +} const validateOrgHandle = (raw: string): string => { const handle = raw.trim().toLowerCase(); @@ -42,29 +67,93 @@ export const createOrgAccount = async ( return { did: account.did, handle: account.handle }; }; -// the account exists but its first sign-in failed. submitting again would -// spend another quota slot, so the caller gets the org back to navigate to -export class OrgSetupError extends Error { - readonly org: OrgAccount; +export const writeOrgProfile = async ( + orgAgent: OAuthUserAgent, + input: OrgProfileInput +): Promise => { + const description = input.description?.trim() || undefined; + const website = normalizeProfileLink(input.website ?? ""); + const avatar = input.avatarFile + ? await uploadProfileAvatar(orgAgent, input.avatarFile) + : undefined; + + const record: ProfileRecord = { + $type: "sh.tangled.actor.profile", + bluesky: false, + isOrganization: true, + ...(avatar ? { avatar } : {}), + ...(description ? { description } : {}), + ...(website ? { links: [website] } : {}) + }; + await putProfile(orgAgent, record); +}; + +export const addOrgMembers = async ( + orgAgent: OAuthUserAgent, + input: AddOrgMembersInput +): Promise => { + const members = input.members.filter((member) => member.did !== input.ownerDid); + if (members.length === 0) return []; - constructor(org: OrgAccount, cause: unknown) { - super(`${org.handle} was created, but could not be set up: ${messageOf(cause)}`, { cause }); - this.name = "OrgSetupError"; - this.org = org; + const presets = await getScopePresets(orgAgent); + const memberPreset = presets.find((preset) => preset.name === "editor"); + if (!memberPreset) { + throw new Error('tranquil has no "editor" scope preset available.'); } -} + + const results = await Promise.allSettled( + members.map((member) => + addController(orgAgent, { + controllerDid: member.did, + grantedScopes: memberPreset.scopes + }) + ) + ); + return results.flatMap((result, i) => { + if (result.status === "fulfilled") return []; + return [`Unable to add ${members[i].handle} as a member. Try again later.`]; + }); +}; export const createOrg = async ( controllerAgent: OAuthUserAgent, - rawHandle: string, - service: DelegationService -): Promise => { - const org = await createOrgAccount(controllerAgent, rawHandle, service); + input: OrgCreationInput, + service: DelegationService, + existingOrg?: OrgAccount +): Promise => { + // Validate the website before creating an account we cannot roll back. + normalizeProfileLink(input.website ?? ""); + const org = existingOrg ?? (await createOrgAccount(controllerAgent, input.handle, service)); + + let orgAgent: OAuthUserAgent; try { - // minting the org's first session also writes its empty profile record - await getOrCreateDelegatedSession(controllerAgent, org.did); - } catch (cause) { - throw new OrgSetupError(org, cause); + orgAgent = await getOrCreateDelegatedSession(controllerAgent, org.did); + } catch { + return { + ...org, + warnings: [ + "Unable to authorize the organization, so its profile and members were not saved. Try again later." + ] + }; } - return org; + + const warnings: string[] = []; + try { + await writeOrgProfile(orgAgent, input); + } catch { + warnings.push("Unable to save the organization's profile. Try again later."); + } + + try { + warnings.push( + ...(await addOrgMembers(orgAgent, { + ownerDid: controllerAgent.sub, + members: input.members + })) + ); + } catch { + warnings.push("Unable to add members. Try again later."); + } + + return { ...org, warnings }; }; diff --git a/web/src/lib/components/org/CreationForm.svelte b/web/src/lib/components/org/CreationForm.svelte index cdd176d60..1f21ec41a 100644 --- a/web/src/lib/components/org/CreationForm.svelte +++ b/web/src/lib/components/org/CreationForm.svelte @@ -126,7 +126,7 @@ />

- Organization {incompleteOrg.handle} was created, but some setup steps could not be completed: + Organization {incompleteOrg.handle} was created, but some setup did not finish:

    {#each incompleteOrg.warnings as warning, index (index)}