diff --git a/web/src/lib/api/orgCreate.test.ts b/web/src/lib/api/orgCreate.test.ts new file mode 100644 index 000000000..c5d89e291 --- /dev/null +++ b/web/src/lib/api/orgCreate.test.ts @@ -0,0 +1,77 @@ +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 { provisionDelegate } from "$lib/api/delegation"; + +vi.mock("$lib/api/delegation", () => ({ provisionDelegate: vi.fn() })); +vi.mock("$lib/api/actAs", () => ({ getOrCreateDelegatedSession: vi.fn() })); + +const service = { serviceUrl: "https://gate.example", serviceDid: "did:web:gate.example" }; +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 fullHandle = `acme${ORG_HANDLE_SUFFIX}`; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(provisionDelegate).mockResolvedValue({ + did: orgDid, + handle: fullHandle, + controllerDid + }); + vi.mocked(getOrCreateDelegatedSession).mockResolvedValue(orgAgent); +}); + +describe("createOrgAccount", () => { + it("provisions through the gate with the normalized full handle", async () => { + const org = await createOrgAccount(controller, " ACME ", service); + + expect(provisionDelegate).toHaveBeenCalledWith(controller, fullHandle, service); + expect(org).toEqual({ did: orgDid, handle: fullHandle }); + }); + + it("rejects an invalid handle before provisioning", async () => { + await expect(createOrgAccount(controller, "bad.handle", service)).rejects.toThrow(/handle/); + expect(provisionDelegate).not.toHaveBeenCalled(); + }); +}); + +describe("createOrg", () => { + it("provisions the account, then mints its first session", async () => { + const events: string[] = []; + vi.mocked(provisionDelegate).mockImplementation(async () => { + events.push("account"); + return { did: orgDid, handle: fullHandle, controllerDid }; + }); + vi.mocked(getOrCreateDelegatedSession).mockImplementation(async () => { + events.push("session"); + return orgAgent; + }); + + const org = await createOrg(controller, "acme", service); + + expect(events).toEqual(["account", "session"]); + expect(getOrCreateDelegatedSession).toHaveBeenCalledWith(controller, orgDid); + expect(org).toEqual({ did: orgDid, handle: fullHandle }); + }); + + it("hands the created account back when the first sign-in fails", async () => { + vi.mocked(getOrCreateDelegatedSession).mockRejectedValue(new Error("grant unavailable")); + + const failure = await createOrg(controller, "acme", service).catch((cause) => cause); + + expect(failure).toBeInstanceOf(OrgSetupError); + expect((failure as OrgSetupError).org).toEqual({ did: orgDid, handle: fullHandle }); + expect((failure as OrgSetupError).message).toMatch(/grant unavailable/); + }); + + it("does not mint a session when provisioning fails", async () => { + vi.mocked(provisionDelegate).mockRejectedValue(new Error("limit reached")); + + await expect(createOrg(controller, "acme", service)).rejects.toThrow(/limit reached/); + expect(getOrCreateDelegatedSession).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/api/orgCreate.ts b/web/src/lib/api/orgCreate.ts new file mode 100644 index 000000000..dde3c1d96 --- /dev/null +++ b/web/src/lib/api/orgCreate.ts @@ -0,0 +1,70 @@ +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 { isValidSubdomain, SUBDOMAIN_MAX_LENGTH, SUBDOMAIN_MIN_LENGTH } from "$lib/subdomain"; + +// TODO: change this +export const ORG_HANDLE_SUFFIX = ".tranquil.tngl.boltless.dev"; + +export interface OrgAccount { + did: Did; + handle: string; +} + +const messageOf = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +const validateOrgHandle = (raw: string): string => { + const handle = raw.trim().toLowerCase(); + if (handle.length < SUBDOMAIN_MIN_LENGTH || handle.length > SUBDOMAIN_MAX_LENGTH) { + throw new Error( + `Organization handle must be ${SUBDOMAIN_MIN_LENGTH}-${SUBDOMAIN_MAX_LENGTH} characters.` + ); + } + if (!isValidSubdomain(handle)) { + throw new Error( + "Organization handle can only contain lowercase letters, digits, and hyphens, and cannot start or end with a hyphen." + ); + } + return handle; +}; + +export const createOrgAccount = async ( + agent: OAuthUserAgent, + rawHandle: string, + service: DelegationService +): Promise => { + const handle = validateOrgHandle(rawHandle); + const fullHandle = `${handle}${ORG_HANDLE_SUFFIX}`; + + const account = await provisionDelegate(agent, fullHandle, service); + 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; + + 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; + } +} + +export const createOrg = async ( + controllerAgent: OAuthUserAgent, + rawHandle: string, + service: DelegationService +): Promise => { + const org = await createOrgAccount(controllerAgent, rawHandle, service); + 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); + } + return org; +};