From e563ab3cb47ee2afd0f4d36e6666980f03b24ceb Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Fri, 4 Sep 2026 17:44:10 +0100 Subject: [PATCH] web/api: implement org creations and inherit tranquil's ActAs Signed-off-by: oppiliappan --- web/src/lib/api/actAs.test.ts | 106 +++++++++++++ web/src/lib/api/actAs.ts | 209 +++++++++++++++++++++++++ web/src/lib/api/orgCreate.test.ts | 180 +++++++++++++++++++++ web/src/lib/api/orgCreate.ts | 190 ++++++++++++++++++++++ web/src/lib/auth.svelte.ts | 36 ++++- web/src/lib/auth.test.ts | 17 +- web/src/lib/auth/accounts.ts | 66 +++++++- web/src/routes/org/new/+page.server.ts | 6 + web/src/routes/org/new/+page.svelte | 43 +++++ 9 files changed, 841 insertions(+), 12 deletions(-) create mode 100644 web/src/lib/api/actAs.test.ts create mode 100644 web/src/lib/api/actAs.ts create mode 100644 web/src/lib/api/orgCreate.test.ts create mode 100644 web/src/lib/api/orgCreate.ts create mode 100644 web/src/routes/org/new/+page.server.ts create mode 100644 web/src/routes/org/new/+page.svelte diff --git a/web/src/lib/api/actAs.test.ts b/web/src/lib/api/actAs.test.ts new file mode 100644 index 000000000..5ddfcdef1 --- /dev/null +++ b/web/src/lib/api/actAs.test.ts @@ -0,0 +1,106 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent, Session } from "@atcute/oauth-browser-client"; +import { describe, expect, it, vi } from "vitest"; +import { + createDelegatedSession, + delegatedSessionController, + type DelegatedSessionDependencies +} from "$lib/api/actAs"; + +const sessionWithClaims = (claims: unknown): Session => { + const payload = btoa(JSON.stringify(claims)) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + return { token: { access: `header.${payload}.signature` } } as Session; +}; + +describe("delegatedSessionController", () => { + it("returns the controller DID from the access token act claim", () => { + const controller = "did:plc:controller" as Did; + expect(delegatedSessionController(sessionWithClaims({ act: { sub: controller } }))).toBe( + controller + ); + }); + + it("rejects sessions without a valid controller claim", () => { + expect(delegatedSessionController(sessionWithClaims({ sub: "did:plc:org" }))).toBeNull(); + expect(delegatedSessionController({ token: { access: "opaque" } } as Session)).toBeNull(); + }); +}); + +describe("createDelegatedSession", () => { + it("performs the delegated OAuth handshake through injected dependencies", async () => { + const delegatedDid = "did:plc:org" as Did; + const requestUri = "urn:ietf:params:oauth:request_uri:request-1"; + const handle = vi.fn(async () => Response.json({ success: true }, { status: 200 })); + const controllerAgent = { + session: { + info: { aud: "https://pds.example" }, + token: { scope: "atproto repo:sh.tangled.repo" } + }, + handle + } as unknown as OAuthUserAgent; + const session = { + info: { sub: delegatedDid, aud: "https://pds.example" }, + token: { access: "access", scope: "atproto" }, + dpopKey: {} + } as Session; + const delegatedAgent = { session } as OAuthUserAgent; + const createAuthorization = vi.fn(async () => { + const url = new URL("https://pds.example/oauth/authorize"); + url.searchParams.set("request_uri", requestUri); + return url; + }); + const fetchFn = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + scopes: [ + { scope: "atproto" }, + { scope: "repo:allowed" }, + { scope: "repo:restricted", restricted: true } + ], + permission_sets: [{ include_scope: "include:example.permissions" }] + }) + ) + .mockResolvedValueOnce( + Response.json({ + redirect_uri: + "https://pds.example/oauth/authorize/redirect?code=code-1&state=state-1" + }) + ); + const finalize = vi.fn(async (_params: URLSearchParams) => ({ session, state: null })); + const createAgent = vi.fn(() => delegatedAgent); + const dependencies = { + createAuthorizationUrl: createAuthorization, + finalizeAuthorization: finalize, + fetch: fetchFn, + createAgent + } as unknown as DelegatedSessionDependencies; + + const agent = await createDelegatedSession(controllerAgent, delegatedDid, dependencies); + + expect(agent).toBe(delegatedAgent); + expect(createAgent).toHaveBeenCalledWith(session); + expect(handle).toHaveBeenCalledWith("/oauth/delegation/auth-token", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request_uri: requestUri, delegated_did: delegatedDid }) + }); + expect(fetchFn).toHaveBeenCalledTimes(2); + const approval = fetchFn.mock.calls[1][1] as RequestInit; + expect(JSON.parse(String(approval.body))).toEqual({ + request_uri: requestUri, + approved_scopes: ["atproto", "repo:allowed", "include:example.permissions"], + remember: false + }); + expect(finalize).toHaveBeenCalledTimes(1); + const params = finalize.mock.calls[0][0]; + expect(Object.fromEntries(params)).toEqual({ + iss: "https://pds.example", + code: "code-1", + state: "state-1" + }); + }); +}); diff --git a/web/src/lib/api/actAs.ts b/web/src/lib/api/actAs.ts new file mode 100644 index 000000000..29ce9c1b9 --- /dev/null +++ b/web/src/lib/api/actAs.ts @@ -0,0 +1,209 @@ +import type { ActorIdentifier, Did } from "@atcute/lexicons/syntax"; +import { + OAuthUserAgent, + TokenRefreshError, + createAuthorizationUrl, + deleteStoredSession, + finalizeAuthorization, + getSession, + type Session +} from "@atcute/oauth-browser-client"; +import { markOrgDid, orgControllerFor } from "$lib/auth/accounts"; + +interface ConsentScope { + scope: string; + restricted?: boolean; +} + +interface ConsentPermissionSet { + include_scope: string; + restricted?: boolean; +} + +interface ConsentData { + scopes: ConsentScope[]; + permission_sets?: ConsentPermissionSet[]; +} + +interface ApprovalData { + redirect_uri?: string; + error?: string; + error_description?: string; +} + +export interface DelegatedSessionDependencies { + createAuthorizationUrl: typeof createAuthorizationUrl; + finalizeAuthorization: typeof finalizeAuthorization; + fetch: typeof globalThis.fetch; + createAgent: (session: Session) => OAuthUserAgent; +} + +const defaultDependencies: DelegatedSessionDependencies = { + createAuthorizationUrl, + finalizeAuthorization, + fetch: globalThis.fetch, + createAgent: (session) => new OAuthUserAgent(session) +}; + +type AccessTokenClaims = { + act?: { sub?: unknown }; +}; + +export const delegatedSessionController = (session: Pick): Did | null => { + try { + const encodedPayload = session.token.access.split(".")[1]; + if (!encodedPayload) return null; + const base64 = encodedPayload.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const claims = JSON.parse(atob(padded)) as AccessTokenClaims; + const controller = claims.act?.sub; + return typeof controller === "string" && controller.startsWith("did:") + ? (controller as Did) + : null; + } catch { + return null; + } +}; + +const approvedScopesOf = (consent: ConsentData): string[] => { + const scopes = new Set([ + ...consent.scopes.filter((s) => !s.restricted).map((s) => s.scope), + ...(consent.permission_sets ?? []).filter((s) => !s.restricted).map((s) => s.include_scope) + ]); + if (consent.scopes.some((s) => s.scope === "atproto")) { + scopes.add("atproto"); + } + return scopes.size > 0 ? [...scopes] : ["atproto"]; +}; + +const createAuthorizationRequest = async ( + controllerAgent: OAuthUserAgent, + delegatedDid: Did, + createUrl: typeof createAuthorizationUrl +): Promise => { + const authorizationUrl = await createUrl({ + target: { type: "account", identifier: delegatedDid as ActorIdentifier }, + scope: controllerAgent.session.token.scope + }); + const requestUri = authorizationUrl.searchParams.get("request_uri"); + if (!requestUri) throw new Error("tranquil did not return a request_uri for the org account."); + return requestUri; +}; + +const bindAuthorizationRequest = async ( + controllerAgent: OAuthUserAgent, + delegatedDid: Did, + requestUri: string +): Promise => { + const response = await controllerAgent.handle("/oauth/delegation/auth-token", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request_uri: requestUri, delegated_did: delegatedDid }) + }); + const data: { success?: boolean; error?: string } = await response.json(); + if (!data.success) { + throw new Error(data.error || "Could not bind the org account's authorization request."); + } +}; + +const consentEndpoint = (controllerAgent: OAuthUserAgent): URL => + new URL("/oauth/authorize/consent", controllerAgent.session.info.aud); + +const loadConsent = async ( + controllerAgent: OAuthUserAgent, + requestUri: string, + fetchFn: typeof globalThis.fetch +): Promise => { + const url = consentEndpoint(controllerAgent); + url.searchParams.set("request_uri", requestUri); + const response = await fetchFn(url); + if (!response.ok) throw new Error("Could not load the org account's authorization request."); + return response.json(); +}; + +const approveConsent = async ( + controllerAgent: OAuthUserAgent, + requestUri: string, + consent: ConsentData, + fetchFn: typeof globalThis.fetch +): Promise => { + const response = await fetchFn(consentEndpoint(controllerAgent), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + request_uri: requestUri, + approved_scopes: approvedScopesOf(consent), + remember: false + }) + }); + const data: ApprovalData = await response.json(); + if (!data.redirect_uri) { + throw new Error( + data.error_description || data.error || "Authorizing the org account failed." + ); + } + return data.redirect_uri; +}; + +const callbackParams = (intermediateRedirect: string, issuer: string): URLSearchParams => { + // Tranquil returns its own intermediate redirect. Its code and state are + // already sufficient to construct the final callback without following it. + const intermediate = new URL(intermediateRedirect); + const code = intermediate.searchParams.get("code"); + if (!code) throw new Error("tranquil did not return an authorization code."); + + const params = new URLSearchParams({ iss: new URL(issuer).origin, code }); + const state = intermediate.searchParams.get("state"); + if (state) params.set("state", state); + return params; +}; + +export const createDelegatedSession = async ( + controllerAgent: OAuthUserAgent, + delegatedDid: Did, + dependencies: DelegatedSessionDependencies = defaultDependencies +): Promise => { + const requestUri = await createAuthorizationRequest( + controllerAgent, + delegatedDid, + dependencies.createAuthorizationUrl + ); + await bindAuthorizationRequest(controllerAgent, delegatedDid, requestUri); + const consent = await loadConsent(controllerAgent, requestUri, dependencies.fetch); + const redirect = await approveConsent(controllerAgent, requestUri, consent, dependencies.fetch); + const params = callbackParams(redirect, controllerAgent.session.info.aud); + const { session } = await dependencies.finalizeAuthorization(params); + return dependencies.createAgent(session); +}; + +// prefer this over createDelegatedSession directly +export const getOrCreateDelegatedSession = async ( + controllerAgent: OAuthUserAgent, + delegatedDid: Did +): Promise => { + const controllerDid = controllerAgent.sub as Did; + const create = async () => { + const agent = await createDelegatedSession(controllerAgent, delegatedDid); + markOrgDid(delegatedDid, controllerDid); + return agent; + }; + + const recordedController = orgControllerFor(delegatedDid); + if (recordedController && recordedController !== controllerDid) { + deleteStoredSession(delegatedDid); + return create(); + } + + try { + const session = await getSession(delegatedDid); + if (delegatedSessionController(session) !== controllerDid) { + deleteStoredSession(delegatedDid); + return create(); + } + markOrgDid(delegatedDid, controllerDid); + return new OAuthUserAgent(session); + } catch (cause) { + if (!(cause instanceof TokenRefreshError)) throw cause; + return create(); + } +}; diff --git a/web/src/lib/api/orgCreate.test.ts b/web/src/lib/api/orgCreate.test.ts new file mode 100644 index 000000000..78d25bb46 --- /dev/null +++ b/web/src/lib/api/orgCreate.test.ts @@ -0,0 +1,180 @@ +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 { + addOrgMembers, + createOrg, + createOrgAccount, + ORG_HANDLE_SUFFIX, + writeOrgProfile +} from "$lib/api/orgCreate"; +import { putProfile } from "$lib/api/profile"; +import { + addController, + createDelegatedAccount, + getScopePresets, + listControlledAccounts +} from "$lib/api/tranquil"; + +vi.mock("$lib/api/actAs", () => ({ getOrCreateDelegatedSession: vi.fn() })); +vi.mock("$lib/api/profile", () => ({ uploadProfileAvatar: vi.fn(), putProfile: vi.fn() })); +vi.mock("$lib/api/tranquil", () => ({ + addController: vi.fn(), + createDelegatedAccount: vi.fn(), + getScopePresets: vi.fn(), + listControlledAccounts: vi.fn() +})); + +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(listControlledAccounts).mockResolvedValue([]); + vi.mocked(createDelegatedAccount).mockResolvedValue({ did: orgDid, handle: fullHandle }); + vi.mocked(getOrCreateDelegatedSession).mockResolvedValue(orgAgent); + vi.mocked(addController).mockResolvedValue({ success: true }); +}); + +describe("createOrgAccount", () => { + it("creates only the delegated account with the owner preset", async () => { + const org = await createOrgAccount(controller, " ACME "); + + expect(createDelegatedAccount).toHaveBeenCalledWith(controller, { + handle: fullHandle, + controllerScopes: ownerScopes + }); + expect(org).toEqual({ did: orgDid, handle: fullHandle }); + expect(putProfile).not.toHaveBeenCalled(); + expect(addController).not.toHaveBeenCalled(); + }); + + it("rejects an existing controlled handle", async () => { + vi.mocked(listControlledAccounts).mockResolvedValue([ + { did: orgDid, handle: fullHandle, grantedScopes: ownerScopes, grantedAt: "now" } + ]); + + await expect(createOrgAccount(controller, "acme")).rejects.toThrow(/already in use/); + expect(createDelegatedAccount).not.toHaveBeenCalled(); + }); +}); + +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, + 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(["member.example: insufficient scope"]); + }); +}); + +describe("createOrg", () => { + it("orchestrates account, session, profile, and members in order", async () => { + const events: string[] = []; + vi.mocked(createDelegatedAccount).mockImplementation(async () => { + events.push("account"); + return { did: orgDid, handle: fullHandle }; + }); + vi.mocked(getOrCreateDelegatedSession).mockImplementation(async () => { + 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, { + handle: "acme", + description: "A team", + members: [{ did: memberDid, handle: "member.example" }] + }); + + expect(events).toEqual(["account", "session", "profile", "member"]); + expect(org).toEqual({ did: orgDid, handle: fullHandle, warnings: [] }); + }); + + 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 org = await createOrg(controller, { + handle: "acme", + members: [{ did: memberDid, handle: "member.example" }] + }); + + expect(addController).toHaveBeenCalled(); + expect(org.warnings).toEqual([ + "Profile: profile unavailable", + "member.example: controller rejected" + ]); + }); + + 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: [] }); + + expect(org.warnings).toEqual(["Authorization: grant unavailable"]); + expect(putProfile).not.toHaveBeenCalled(); + expect(addController).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/api/orgCreate.ts b/web/src/lib/api/orgCreate.ts new file mode 100644 index 000000000..2bd6e5944 --- /dev/null +++ b/web/src/lib/api/orgCreate.ts @@ -0,0 +1,190 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { getOrCreateDelegatedSession } from "$lib/api/actAs"; +import { uploadProfileAvatar, putProfile } from "$lib/api/profile"; +import type { ProfileRecord } from "$lib/api/records"; +import { isValidSubdomain, SUBDOMAIN_MAX_LENGTH, SUBDOMAIN_MIN_LENGTH } from "$lib/subdomain"; +import { + addController, + createDelegatedAccount, + getScopePresets, + listControlledAccounts +} from "$lib/api/tranquil"; + +// 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; +} + +export interface CreatedOrg extends OrgAccount { + warnings: 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; +}; + +type ProfileLink = NonNullable[number]; + +const validateWebsite = (raw: string | undefined): ProfileLink | undefined => { + const website = raw?.trim(); + if (!website) return undefined; + let url: URL; + try { + url = new URL(website); + } catch { + throw new Error("Website must be a valid URL."); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Website must use http or https."); + } + return website as ProfileLink; +}; + +export const createOrgAccount = async ( + agent: OAuthUserAgent, + rawHandle: string +): Promise => { + const handle = validateOrgHandle(rawHandle); + const fullHandle = `${handle}${ORG_HANDLE_SUFFIX}`; + + const presets = await getScopePresets(agent); + const ownerPreset = presets.find((preset) => preset.name === "owner"); + if (!ownerPreset) { + throw new Error('tranquil has no "owner" scope preset available.'); + } + const controlled = await listControlledAccounts(agent); + const existing = controlled.find((account) => account.handle === fullHandle); + if (existing) { + throw new Error(`The organization handle ${fullHandle} is already in use.`); + } + const account = await createDelegatedAccount(agent, { + handle: fullHandle, + controllerScopes: ownerPreset.scopes + }); + return { did: account.did, handle: fullHandle }; +}; + +export const writeOrgProfile = async ( + orgAgent: OAuthUserAgent, + input: OrgProfileInput +): Promise => { + const description = input.description?.trim() || undefined; + const website = validateWebsite(input.website); + const avatar = input.avatarFile + ? await uploadProfileAvatar(orgAgent, input.avatarFile) + : undefined; + + const record: ProfileRecord = { + $type: "sh.tangled.actor.profile", + bluesky: false, + ...(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 []; + + 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 [`${members[i].handle}: ${messageOf(result.reason)}`]; + }); +}; + +export const createOrg = async ( + controllerAgent: OAuthUserAgent, + input: OrgCreationInput +): Promise => { + // Validate the website before creating an account we cannot roll back. + validateWebsite(input.website); + const org = await createOrgAccount(controllerAgent, input.handle); + + let orgAgent: OAuthUserAgent; + try { + orgAgent = await getOrCreateDelegatedSession(controllerAgent, org.did); + } catch (cause) { + return { + ...org, + warnings: [`Authorization: ${messageOf(cause)}`] + }; + } + + const warnings: string[] = []; + try { + await writeOrgProfile(orgAgent, input); + } catch (cause) { + warnings.push(`Profile: ${messageOf(cause)}`); + } + + try { + warnings.push( + ...(await addOrgMembers(orgAgent, { + ownerDid: controllerAgent.sub, + members: input.members + })) + ); + } catch (cause) { + warnings.push(`Members: ${messageOf(cause)}`); + } + + return { ...org, warnings }; +}; diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts index a38430326..86544cd71 100644 --- a/web/src/lib/auth.svelte.ts +++ b/web/src/lib/auth.svelte.ts @@ -26,6 +26,7 @@ import { clearActive, dropAccount, loadAccounts, + loadOrgDids, persistActive, readActiveDid, reconcileAccounts, @@ -161,6 +162,21 @@ const isDeadSessionError = (cause: unknown): boolean => cause.status === 400 && (cause.error === "invalid_grant" || cause.error === "invalid_token")); +export const refreshSessionCandidates = ( + activeDid: Did | null, + currentDid: Did | null, + storedDids: readonly Did[], + orgDids: readonly Did[] +): Did[] => { + const candidates: Did[] = []; + for (const candidate of [activeDid, currentDid, ...storedDids]) { + if (candidate && !orgDids.includes(candidate) && !candidates.includes(candidate)) { + candidates.push(candidate); + } + } + return candidates; +}; + const resolveProfile = async ( identifier: string, bobbinUrl: string @@ -219,7 +235,11 @@ export const createAuth = ( // merge atcute's stored sessions with persisted account metadata. const syncAccounts = () => { - accounts = reconcileAccounts(browser ? listStoredSessions() : [], loadAccounts()); + accounts = reconcileAccounts( + browser ? listStoredSessions() : [], + loadAccounts(), + loadOrgDids() + ); saveAccounts(accounts); }; @@ -258,7 +278,7 @@ export const createAuth = ( addedAt: Math.floor(Date.now() / 1000) }); saveAccounts(meta); - accounts = reconcileAccounts(listStoredSessions(), meta); + accounts = reconcileAccounts(listStoredSessions(), meta, loadOrgDids()); persistActive(did, nextProfile.handle); }; @@ -350,10 +370,12 @@ export const createAuth = ( state = { kind: "loading", did: previousDid, profile: previousProfile }; syncAccounts(); - const candidates: Did[] = []; - for (const candidate of [readActiveDid(), currentDid(), ...listStoredSessions()]) { - if (candidate && !candidates.includes(candidate)) candidates.push(candidate); - } + const candidates = refreshSessionCandidates( + readActiveDid(), + currentDid(), + listStoredSessions(), + loadOrgDids() + ); let lastFailure: string | undefined; for (const candidate of candidates) { @@ -471,7 +493,7 @@ export const createAuth = ( otherAgents.delete(did); saveAccounts(dropAccount(loadAccounts(), did)); - accounts = reconcileAccounts(listStoredSessions(), loadAccounts()); + accounts = reconcileAccounts(listStoredSessions(), loadAccounts(), loadOrgDids()); if (wasActive) { for (const account of accounts) { diff --git a/web/src/lib/auth.test.ts b/web/src/lib/auth.test.ts index 4f4ff08b0..d16ac2a1c 100644 --- a/web/src/lib/auth.test.ts +++ b/web/src/lib/auth.test.ts @@ -10,7 +10,7 @@ import { getSession } from "@atcute/oauth-browser-client"; import { type Mock, beforeEach, describe, expect, it, vi } from "vitest"; -import { createAuth, resumeSignIn } from "$lib/auth.svelte"; +import { createAuth, refreshSessionCandidates, resumeSignIn } from "$lib/auth.svelte"; import { consumePendingCallback } from "$lib/auth/pending"; interface MockAgent { @@ -63,6 +63,21 @@ beforeEach(() => { MockedUserAgent.instances.length = 0; }); +describe("refreshSessionCandidates", () => { + it("excludes delegated organization sessions even when one was active", () => { + const org = "did:plc:org" as Did; + const bob = "did:plc:bob" as Did; + + expect(refreshSessionCandidates(org, org, [org, alice, bob], [org])).toEqual([alice, bob]); + }); + + it("deduplicates personal session candidates in priority order", () => { + const bob = "did:plc:bob" as Did; + + expect(refreshSessionCandidates(alice, bob, [bob, alice], [])).toEqual([alice, bob]); + }); +}); + describe("agentFor", () => { it("returns a live agent and reuses it", async () => { mockedGetSession.mockResolvedValue(liveSession(alice)); diff --git a/web/src/lib/auth/accounts.ts b/web/src/lib/auth/accounts.ts index 730a40d9e..c2f57b218 100644 --- a/web/src/lib/auth/accounts.ts +++ b/web/src/lib/auth/accounts.ts @@ -6,6 +6,8 @@ import type { Did } from "@atcute/lexicons/syntax"; export const CURRENT_DID_KEY = "tangled.currentDid"; export const CURRENT_HANDLE_KEY = "tangled.currentHandle"; const ACCOUNTS_KEY = "tangled.accounts"; +const ORG_DIDS_KEY = "tangled.orgDids"; +const ORG_CONTROLLERS_KEY = "tangled.orgControllers"; // appview account cap parity export const MAX_ACCOUNTS = 20; @@ -48,22 +50,78 @@ export const saveAccounts = (accounts: readonly AuthAccount[]): void => { localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); }; -// stored sessions are authoritative, metadata only supplies order and handle +// stored sessions are authoritative, metadata only supplies order and handle. +// +// org dids are passed in so that they're excluded here +// rather than showing up in the account switcher export const reconcileAccounts = ( stored: readonly Did[], - meta: readonly AuthAccount[] + meta: readonly AuthAccount[], + orgDids: readonly Did[] = [] ): AuthAccount[] => { + const orgSet = new Set(orgDids); const storedSet = new Set(stored); const known = new Set(meta.map((account) => account.did)); - const ordered = meta.filter((account) => storedSet.has(account.did)); + const ordered = meta.filter( + (account) => storedSet.has(account.did) && !orgSet.has(account.did) + ); for (const did of stored) { - if (!known.has(did)) { + if (!known.has(did) && !orgSet.has(did)) { ordered.push({ did, handle: did, addedAt: Math.floor(Date.now() / 1000) }); } } return ordered; }; +const isDidArray = (value: unknown): value is Did[] => Array.isArray(value) && value.every(isDid); + +export const loadOrgDids = (): Did[] => { + if (!browser) return []; + try { + const raw = localStorage.getItem(ORG_DIDS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + return isDidArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +export const loadOrgControllers = (): Record => { + if (!browser) return {}; + try { + const raw = localStorage.getItem(ORG_CONTROLLERS_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const controllers: Record = {}; + for (const [delegatedDid, controllerDid] of Object.entries(parsed)) { + if (isDid(delegatedDid) && isDid(controllerDid)) { + controllers[delegatedDid] = controllerDid; + } + } + return controllers; + } catch { + return {}; + } +}; + +export const orgControllerFor = (did: Did): Did | undefined => loadOrgControllers()[did]; + +export const markOrgDid = (did: Did, controllerDid?: Did): void => { + if (!browser) return; + const current = loadOrgDids(); + if (!current.includes(did)) { + localStorage.setItem(ORG_DIDS_KEY, JSON.stringify([...current, did])); + } + if (controllerDid) { + localStorage.setItem( + ORG_CONTROLLERS_KEY, + JSON.stringify({ ...loadOrgControllers(), [did]: controllerDid }) + ); + } +}; + // dedupe by did, preserve insertion order, and keep the original addedAt. export const upsertAccount = ( accounts: readonly AuthAccount[], diff --git a/web/src/routes/org/new/+page.server.ts b/web/src/routes/org/new/+page.server.ts new file mode 100644 index 000000000..a68ccc6c9 --- /dev/null +++ b/web/src/routes/org/new/+page.server.ts @@ -0,0 +1,6 @@ +import { requireAuth } from "$lib/auth/guards"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = (event) => { + requireAuth(event); +}; diff --git a/web/src/routes/org/new/+page.svelte b/web/src/routes/org/new/+page.svelte new file mode 100644 index 000000000..7ce71b8c5 --- /dev/null +++ b/web/src/routes/org/new/+page.svelte @@ -0,0 +1,43 @@ + + + + New organization · Tangled + + +
+
+

+ Create a new organization +

+

+ Organizations are delegated accounts that let a team manage repositories together. +

+
+ + + + +
-- 2.51.2