From 5bc1cc068ff2a7ae4ee797a8f87f759564c85471 Mon Sep 17 00:00:00 2001 From: dawn Date: Thu, 24 Sep 2026 18:36:21 +0300 Subject: [PATCH] web: restore repo creation targets dropped in the migrator rebase Signed-off-by: dawn --- web/src/lib/api/repoCreationOptions.ts | 24 ++++ web/src/lib/api/repoCreationTargets.test.ts | 109 +++++++++++++++++ web/src/lib/api/repoCreationTargets.ts | 128 ++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 web/src/lib/api/repoCreationOptions.ts create mode 100644 web/src/lib/api/repoCreationTargets.test.ts create mode 100644 web/src/lib/api/repoCreationTargets.ts diff --git a/web/src/lib/api/repoCreationOptions.ts b/web/src/lib/api/repoCreationOptions.ts new file mode 100644 index 000000000..f99557fa3 --- /dev/null +++ b/web/src/lib/api/repoCreationOptions.ts @@ -0,0 +1,24 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import { createBobbinClient } from "$lib/api/client"; +import { stream } from "$lib/api/load"; +import { availableKnots, availableSpindles } from "$lib/api/repoCreationTargets"; + +interface RepoCreationParent { + auth?: { did: string } | null; + publicConfig: { bobbinUrl: string }; +} + +export const repoCreationOptions = ( + parent: RepoCreationParent, + fetch: typeof globalThis.fetch +): { knots: Promise; spindles: Promise } => { + if (!parent.auth?.did) { + return { knots: Promise.resolve([]), spindles: Promise.resolve([]) }; + } + const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch }); + const did = parent.auth.did as Did; + return { + knots: stream(availableKnots(ctx, did).catch(() => [])), + spindles: stream(availableSpindles(ctx, did).catch(() => [])) + }; +}; diff --git a/web/src/lib/api/repoCreationTargets.test.ts b/web/src/lib/api/repoCreationTargets.test.ts new file mode 100644 index 000000000..8cff13a3d --- /dev/null +++ b/web/src/lib/api/repoCreationTargets.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Did } from "@atcute/lexicons/syntax"; +import { createBobbinClient, type BobbinContext } from "$lib/api/client"; +import { availableKnots, availableSpindles } from "$lib/api/repoCreationTargets"; + +const did = "did:plc:alice" as Did; + +describe("availableSpindles", () => { + it("merges owned spindles and memberships across pages", async () => { + const responses = [ + { + items: [ + { + uri: "at://did:plc:alice/sh.tangled.spindle/owned.example", + value: {} + } + ], + cursor: "next" + }, + { + items: [ + { + uri: "at://did:plc:owner/sh.tangled.spindle.member/one", + value: { instance: "member.example" } + }, + { + uri: "at://did:plc:owner/sh.tangled.spindle.member/two", + value: { instance: "owned.example" } + }, + { uri: "at://did:plc:owner/sh.tangled.spindle.member/bad", value: {} } + ] + }, + { + items: [ + { + uri: "at://did:plc:alice/sh.tangled.spindle/another.example", + value: {} + } + ] + } + ]; + const call = vi.fn(async (_schema: unknown, _options: unknown) => ({ + ok: true, + data: responses.shift() + })); + const ctx = { xrpc: { call } } as unknown as BobbinContext; + + await expect(availableSpindles(ctx, did)).resolves.toEqual([ + "another.example", + "member.example", + "owned.example" + ]); + expect(call).toHaveBeenCalledTimes(3); + expect(call.mock.calls[0][1]).toMatchObject({ params: { subject: did, limit: 1_000 } }); + expect(call.mock.calls[2][1]).toMatchObject({ params: { cursor: "next" } }); + }); +}); + +describe("availableKnots", () => { + it("merges owned knots and memberships", async () => { + const pages: Record = { + "/xrpc/sh.tangled.knot.listKnots": { + items: [{ uri: "at://did:plc:alice/sh.tangled.knot/owned.example", value: {} }] + }, + "/xrpc/sh.tangled.knot.listMembers": { + items: [ + { + uri: "at://did:plc:owner/sh.tangled.knot.member/one", + value: { domain: "member.example" } + }, + { + uri: "at://did:web:member.example/sh.tangled.bobbin.knotMember/did:plc:alice", + value: { + subject: "did:plc:alice", + addedBy: "did:plc:owner", + createdAt: "2026-06-01T00:00:00.000Z" + } + }, + { + uri: "at://did:plc:owner/sh.tangled.knot.member/two", + value: { domain: "owned.example" } + }, + { uri: "at://did:plc:owner/sh.tangled.knot.member/bad", value: {} } + ] + } + }; + const fetch = vi.fn(async (input) => { + const asked = new URL(String(input)); + const page = pages[asked.pathname]; + return new Response(JSON.stringify(page ?? { error: "MethodNotImplemented" }), { + status: page ? 200 : 501, + headers: { "content-type": "application/json" } + }); + }); + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.oyster.cafe", fetch }); + + await expect(availableKnots(ctx, did)).resolves.toEqual([ + "member.example", + "owned.example" + ]); + expect(fetch.mock.calls.map(([input]) => new URL(String(input)).pathname)).toEqual([ + "/xrpc/sh.tangled.knot.listKnots", + "/xrpc/sh.tangled.knot.listMembers" + ]); + expect(String(fetch.mock.calls[1][0])).toBe( + "https://bobbin.oyster.cafe/xrpc/sh.tangled.knot.listMembers?subject=did%3Aplc%3Aalice&limit=1000" + ); + }); +}); diff --git a/web/src/lib/api/repoCreationTargets.ts b/web/src/lib/api/repoCreationTargets.ts new file mode 100644 index 000000000..0e5207d02 --- /dev/null +++ b/web/src/lib/api/repoCreationTargets.ts @@ -0,0 +1,128 @@ +import { ok } from "@atcute/client"; +import type { Did } from "@atcute/lexicons/syntax"; +import type { BobbinContext } from "$lib/api/client"; +import { jsonGet } from "$lib/api/_request"; +import { mainSchema as listKnotsSchema } from "$lib/api/lexicons/types/sh/tangled/knot/listKnots"; +import { mainSchema as listSpindleMembersSchema } from "$lib/api/lexicons/types/sh/tangled/spindle/listMembers"; +import { mainSchema as listSpindlesSchema } from "$lib/api/lexicons/types/sh/tangled/spindle/listSpindles"; +import { parseResourceUri } from "@atcute/lexicons/syntax"; +import { hostForServiceDid } from "$lib/auth/agent"; + +interface Page { + items: T[]; + cursor?: string; +} + +interface RecordItem { + uri: string; + value: unknown; +} + +const allPages = async (load: (cursor?: string) => Promise>): Promise => { + const items: T[] = []; + let cursor: string | undefined; + do { + const page = await load(cursor); + items.push(...page.items); + cursor = page.cursor || undefined; + } while (cursor); + return items; +}; + +const knotDomain = (value: unknown): string | null => { + if (typeof value !== "object" || value === null || !("domain" in value)) return null; + return typeof value.domain === "string" && value.domain ? value.domain : null; +}; + +const repoFromUri = (uri: unknown): string | null => { + if (typeof uri !== "string") return null; + try { + return parseResourceUri(uri).repo; + } catch { + return null; + } +}; + +const recordName = (uri: unknown): string | null => { + if (typeof uri !== "string") return null; + try { + return parseResourceUri(uri).rkey ?? null; + } catch { + return null; + } +}; + +const knotMembershipHost = (item: unknown): string | null => { + if (typeof item !== "object" || item === null) return null; + const record = item as { uri?: unknown; value?: unknown }; + const repo = repoFromUri(record.uri); + const host = repo ? hostForServiceDid(repo) : null; + if (host) return host; + if (record.value !== undefined) { + const domain = knotDomain(record.value); + if (domain) return domain; + } + return null; +}; + +const spindleInstance = (value: unknown): string | null => { + if (typeof value !== "object" || value === null || !("instance" in value)) return null; + return typeof value.instance === "string" && value.instance ? value.instance : null; +}; + +const targetNames = ( + owned: RecordItem[], + memberships: RecordItem[], + memberName: (item: RecordItem) => string | null +): string[] => { + const names = new Set(); + for (const item of owned) { + const name = recordName(item.uri); + if (name) names.add(name); + } + for (const item of memberships) { + const name = memberName(item); + if (name) names.add(name); + } + return [...names].sort((a, b) => a.localeCompare(b)); +}; + +export const availableKnots = async (ctx: BobbinContext, did: string): Promise => { + const [owned, memberships] = await Promise.all([ + allPages((cursor) => + ok( + ctx.xrpc.call(listKnotsSchema, { + params: { subject: did as Did, limit: 1_000, cursor } + }) + ) + ), + allPages((cursor) => + jsonGet>(ctx, "sh.tangled.knot.listMembers", { + subject: did, + limit: 1_000, + cursor + }) + ) + ]); + return targetNames(owned, memberships, knotMembershipHost); +}; + +export const availableSpindles = async (ctx: BobbinContext, did: string): Promise => { + const [owned, memberships] = await Promise.all([ + allPages((cursor) => + ok( + ctx.xrpc.call(listSpindlesSchema, { + params: { subject: did as Did, limit: 1_000, cursor } + }) + ) + ), + allPages((cursor) => + ok( + ctx.xrpc.call(listSpindleMembersSchema, { + params: { subject: did as Did, limit: 1_000, cursor } + }) + ) + ) + ]); + return targetNames(owned, memberships, (item) => spindleInstance(item.value)); +}; -- 2.51.2