diff --git a/consts/consts.go b/consts/consts.go index 218754333..94a7f6698 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -11,7 +11,8 @@ const ( type Capability string const ( - CapKnotACL Capability = "knot-acl" - CapRepoDidInput Capability = "repo-did-input" - CapKeepCommit Capability = "knot-keepcommit" + CapKnotACL Capability = "knot-acl" + CapRepoDidInput Capability = "repo-did-input" + CapKeepCommit Capability = "knot-keepcommit" + CapKnotSocialWrites Capability = "knot-social-writes" ) diff --git a/crates/knot-capability/src/lib.rs b/crates/knot-capability/src/lib.rs index 54ae56956..bde51a23e 100644 --- a/crates/knot-capability/src/lib.rs +++ b/crates/knot-capability/src/lib.rs @@ -5,6 +5,7 @@ pub enum Capability { RepoDidInput, KeepCommit, AtprotoFirehose, + SocialWrites, } impl Capability { @@ -14,6 +15,7 @@ impl Capability { Capability::RepoDidInput, Capability::KeepCommit, Capability::AtprotoFirehose, + Capability::SocialWrites, ]; pub const SERVED: &'static [Capability] = &[ @@ -21,6 +23,7 @@ impl Capability { Capability::KnotAcl, Capability::RepoDidInput, Capability::AtprotoFirehose, + Capability::SocialWrites, ]; pub const fn token(self) -> &'static str { @@ -30,6 +33,7 @@ impl Capability { Capability::RepoDidInput => "repo-did-input", Capability::KeepCommit => "knot-keepcommit", Capability::AtprotoFirehose => "atproto-firehose", + Capability::SocialWrites => "knot-social-writes", } } diff --git a/knot2/crates/knot-index/tests/projections.rs b/knot2/crates/knot-index/tests/projections.rs index ed097de3e..81512cca6 100644 --- a/knot2/crates/knot-index/tests/projections.rs +++ b/knot2/crates/knot-index/tests/projections.rs @@ -2202,6 +2202,41 @@ fn incremental_label_refresh_matches_a_cold_fold() { ); } +#[test] +fn edit_omitting_tangled_labels_clears_existing_set() { + let world = World::new(); + let repo = repo_did("squid"); + let object = world.labeled_ticket( + &repo, + "3lubrptx57d22", + &acc("nel"), + 1, + label_set(&["wontfix", "reviewer"]), + "kelp", + 10, + ); + let subject = ticket_at("3lubrptx57d22"); + + let index = world.index(); + index.refresh_social(&repo).unwrap(); + assert_eq!( + index.social_labels_of(&repo, &subject), + Resolved::Ready(label_set(&["wontfix", "reviewer"])), + "The indexed ticket serves its labels." + ); + + world.edit_ticket(&repo, object, &acc("nel"), note_record("kelp").1, "kelp forest", None, 20); + + let edited = world.index(); + edited.refresh_social(&repo).unwrap(); + assert_eq!( + edited.social_labels_of(&repo, &subject), + Resolved::Ready(Vec::new()), + "An edit that omits tangledLabels clears the existing set, so a client \ + must send the full live set on every ticket put." + ); +} + #[test] fn deleted_subject_stops_serving_its_labels() { let world = World::new(); diff --git a/knot2/crates/knot-xrpc/tests/reads.rs b/knot2/crates/knot-xrpc/tests/reads.rs index 34e722ccd..2a0d0e0c5 100644 --- a/knot2/crates/knot-xrpc/tests/reads.rs +++ b/knot2/crates/knot-xrpc/tests/reads.rs @@ -2021,6 +2021,7 @@ async fn service_metadata_endpoints_answer() { Capability::KnotAcl, Capability::RepoDidInput, Capability::AtprotoFirehose, + Capability::SocialWrites, ] .map(Capability::token); assert_eq!(wire["capabilities"], serde_json::json!(served)); diff --git a/web/src/lib/api/issue.test.ts b/web/src/lib/api/issue.test.ts new file mode 100644 index 000000000..23a2ebe45 --- /dev/null +++ b/web/src/lib/api/issue.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { cidOf, didOf, knotOf, recordKeyOf } from "$lib/api/syntax"; +import type { AttestationSubject } from "$lib/api/attestation"; + +vi.mock("$app/environment", () => ({ browser: false })); + +const agent = { sub: "did:plc:writer" } as unknown as OAuthUserAgent; +const repoDid = didOf("did:web:knot.test:repo"); +const knot = knotOf("knot.test"); +const rkey = recordKeyOf("3labc"); +const liveCid = cidOf("bafyreilive" + "a".repeat(48)); + +const load = async () => { + vi.resetModules(); + const knotWrites: { record?: Record }[] = []; + const legacy: { record: Record; swap?: string }[] = []; + const attested: AttestationSubject[] = []; + const awaited: string[] = []; + vi.doMock("$lib/api/repo", () => ({ resolveRepoKnot: vi.fn(async () => knot) })); + vi.doMock("$lib/auth/agent", () => ({ mintServiceAuth: vi.fn(async () => "jwt") })); + vi.doMock("$lib/api/cache", () => ({ clear: () => {} })); + vi.doMock("$lib/api/attestation", () => ({ + attest: vi.fn((_a: unknown, written: AttestationSubject) => { + attested.push({ uri: written.uri, cid: written.cid }); + }) + })); + vi.doMock("$lib/api/awaitRecord", () => ({ + awaitReadable: vi.fn(async (_b: string | undefined, saved: { uri: string }) => { + awaited.push(saved.uri); + }), + awaitRemoved: vi.fn(async () => {}) + })); + vi.doMock("$lib/api/write", () => ({ + putRecord: vi.fn( + async (_a: unknown, _c: string, _r: string, record: unknown, swap?: string) => { + legacy.push({ record: record as Record, swap }); + return { uri: `at://${agent.sub}/sh.tangled.repo.issue/3lnew`, cid: "bafylegacy" }; + } + ), + deleteRecord: vi.fn(async () => {}) + })); + vi.doMock("$lib/api/cob", () => ({ + withRkey: (saved: { uri: string; cid: string }) => ({ + uri: saved.uri, + cid: saved.cid, + rkey: recordKeyOf("3lnew") + }), + createCob: vi.fn( + async (_a: unknown, _k: unknown, _d: unknown, _c: string, record: unknown) => { + knotWrites.push({ record: record as Record }); + return { uri: `at://${repoDid}/org.tangled.track.ticket/${rkey}`, cid: liveCid }; + } + ), + updateCob: vi.fn( + async ( + _a: unknown, + _k: unknown, + _uri: string, + mutate: (current: Record) => Record + ) => { + const record = mutate({ + $type: "org.tangled.track.ticket", + title: { $type: "org.tangled.markup.markdown#inline", text: "old" }, + state: "open", + tangledLabels: [ + { $type: "org.tangled.label.defs#labelToken", key: "reviewer" } + ], + targetBranch: "release", + createdAt: "2026-10-01T00:00:00Z" + }); + knotWrites.push({ record }); + return { + uri: `at://${repoDid}/org.tangled.track.ticket/${rkey}`, + cid: liveCid, + value: record + }; + } + ), + eraseCob: vi.fn(async () => {}) + })); + const issue = await import("./issue"); + return { issue, knotWrites, legacy, attested, awaited }; +}; + +const capabilities = (list: string[]) => + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ capabilities: list }) }) as Response) + ); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("ticket write targets", () => { + it("writes lexicon ticket records on knots, attests creates only, and restates live labels and branch on edits", async () => { + capabilities(["knot-social-writes"]); + const { issue, knotWrites, attested } = await load(); + const created = await issue.openTicket(agent, "https://bobbin.test", repoDid, { + title: "new", + body: "prose" + }); + expect(knotWrites[0]?.record).toMatchObject({ + $type: "org.tangled.track.ticket", + title: { $type: "org.tangled.markup.markdown#inline", text: "new" }, + body: { $type: "org.tangled.markup.markdown", text: "prose" }, + state: "open" + }); + expect(attested).toEqual([{ uri: created.uri, cid: created.cid }]); + + await issue.editTicket(agent, "https://bobbin.test", repoDid, { + rkey, + swapRecord: liveCid, + title: "newer" + }); + expect(knotWrites[1]?.record).toMatchObject({ + title: { text: "newer" }, + state: "open", + tangledLabels: [{ key: "reviewer" }], + targetBranch: "release" + }); + expect(attested).toHaveLength(1); + }); + + it("writes legacy issue collections when knots don't advertise social writes", async () => { + capabilities(["knot-acl"]); + const { issue, knotWrites, legacy, awaited } = await load(); + await issue.openTicket(agent, "https://bobbin.test", repoDid, { title: "new" }); + expect(knotWrites).toEqual([]); + expect(legacy[0]?.record).toEqual({ + $type: "sh.tangled.repo.issue", + repo: repoDid, + title: "new", + body: "", + createdAt: expect.any(String) + }); + expect(awaited).toHaveLength(1); + }); +}); diff --git a/web/src/lib/api/issue.ts b/web/src/lib/api/issue.ts index d3c1d60f4..4523a6433 100644 --- a/web/src/lib/api/issue.ts +++ b/web/src/lib/api/issue.ts @@ -1,3 +1,26 @@ +import { + atUriOf, + nsidOf, + recordKeyOf, + type AtUri, + type Cid, + type Did, + type LabelKey, + type Rkey +} from "$lib/api/syntax"; +import { now as tidNow } from "@atcute/tid"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { deleteRecord, putRecord } from "$lib/api/write"; +import { attest } from "$lib/api/attestation"; +import { awaitReadable, awaitRemoved } from "$lib/api/awaitRecord"; +import { writeTargetFor } from "$lib/api/knot"; +import { eraseCob, withRkey, type Written } from "$lib/api/cob"; +import { + createTicket, + editTicket as restateOnKnot, + TICKET_COLLECTION, + type TicketProse +} from "$lib/api/ticket"; import type { BobbinContext, XrpcRequestInit } from "$lib/api/client"; import { jsonGet } from "$lib/api/_request"; import type { RecordList, RecordView, TicketRecord, TicketState } from "$lib/api/records"; @@ -7,10 +30,95 @@ import type * as ShTangledRepoIssueState from "$lib/api/lexicons/types/sh/tangle export type IssueRecord = ShTangledRepoIssue.Main; export type IssueStateRecord = ShTangledRepoIssueState.Main; -export const getIssue = (ctx: BobbinContext, issue: string, init?: XrpcRequestInit) => +const ISSUE_COLLECTION = nsidOf("sh.tangled.repo.issue"); + +export interface TicketInput { + title: string; + body?: string; + targetBranch?: string; + tangledLabels?: LabelKey[]; +} + +const legacyIssue = (repoDid: Did, { title, body }: TicketInput): IssueRecord => + ({ + $type: "sh.tangled.repo.issue" as IssueRecord["$type"], + repo: repoDid as IssueRecord["repo"], + title, + body: body ?? "", + createdAt: new Date().toISOString() + }) as IssueRecord; + +const proseOf = ({ title, body, targetBranch, tangledLabels }: TicketInput): TicketProse => ({ + title, + body: body ?? "", + targetBranch, + tangledLabels +}); + +export const openTicket = async ( + agent: OAuthUserAgent, + bobbinUrl: string | undefined, + repoDid: Did, + input: TicketInput +): Promise => { + const target = await writeTargetFor(bobbinUrl, repoDid); + if (target.kind === "knot") { + const written = withRkey(await createTicket(agent, target.knot, repoDid, proseOf(input))); + attest(agent, written, target.knot); + return written; + } + const saved = await putRecord( + agent, + ISSUE_COLLECTION, + recordKeyOf(tidNow()), + legacyIssue(repoDid, input) + ); + await awaitReadable(bobbinUrl, saved); + return withRkey(saved); +}; + +export const editTicket = async ( + agent: OAuthUserAgent, + bobbinUrl: string | undefined, + repoDid: Did, + input: TicketInput & { rkey: Rkey; swapRecord: Cid } +): Promise => { + const target = await writeTargetFor(bobbinUrl, repoDid); + if (target.kind === "knot") { + const uri = atUriOf(`at://${repoDid}/${TICKET_COLLECTION}/${input.rkey}`); + return withRkey(await restateOnKnot(agent, target.knot, uri, proseOf(input))); + } + const saved = await putRecord( + agent, + ISSUE_COLLECTION, + input.rkey, + legacyIssue(repoDid, input), + input.swapRecord + ); + await awaitReadable(bobbinUrl, saved); + return withRkey(saved); +}; + +export const removeTicket = async ( + agent: OAuthUserAgent, + bobbinUrl: string | undefined, + repoDid: Did, + input: { rkey: Rkey; swapRecord: Cid } +): Promise<{ uri: AtUri }> => { + const target = await writeTargetFor(bobbinUrl, repoDid); + const uri = atUriOf(`at://${repoDid}/${TICKET_COLLECTION}/${input.rkey}`); + if (target.kind === "knot") await eraseCob(agent, target.knot, { uri, cid: input.swapRecord }); + else { + await deleteRecord(agent, ISSUE_COLLECTION, input.rkey, input.swapRecord); + await awaitRemoved(bobbinUrl, uri); + } + return { uri }; +}; + +export const getIssue = (ctx: BobbinContext, issue: AtUri, init?: XrpcRequestInit) => jsonGet>(ctx, "sh.tangled.repo.getIssue", { issue }, init); -export const getIssues = (ctx: BobbinContext, issues: readonly string[], init?: XrpcRequestInit) => +export const getIssues = (ctx: BobbinContext, issues: readonly AtUri[], init?: XrpcRequestInit) => jsonGet>(ctx, "sh.tangled.repo.getIssues", { issues }, init); export type IssueState = "open" | "closed"; @@ -41,7 +149,7 @@ export interface ListIssuesFilter { export const listIssues = ( ctx: BobbinContext, - subject: string, + subject: Did, filter: ListIssuesFilter = {}, init?: XrpcRequestInit ) => jsonGet(ctx, "sh.tangled.repo.listIssues", { subject, ...filter }, init); @@ -53,7 +161,7 @@ export interface IssueStateList { export const listIssueStates = ( ctx: BobbinContext, - subject: string, + subject: AtUri, filter: { limit?: number; cursor?: string; order?: "asc" | "desc" } = {}, init?: XrpcRequestInit ) => jsonGet(ctx, "sh.tangled.repo.issue.listStates", { subject, ...filter }, init); diff --git a/web/src/lib/api/knot.test.ts b/web/src/lib/api/knot.test.ts new file mode 100644 index 000000000..f70cf3226 --- /dev/null +++ b/web/src/lib/api/knot.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { BobbinContext } from "$lib/api/client"; +import { didOf, knotOf } from "$lib/api/syntax"; + +const ctx = { xrpc: {}, serviceUrl: "https://bobbin.test" } as unknown as BobbinContext; +const repoDid = didOf("did:web:knot.test:repo"); + +vi.mock("$app/environment", () => ({ browser: true })); + +const knotPage = (capabilities?: string[]) => + ({ ok: true, json: async () => ({ capabilities }) }) as unknown as Response; + +const load = (knot: string | undefined) => { + vi.resetModules(); + vi.doMock("$lib/api/repo", () => ({ resolveRepoKnot: vi.fn(async () => knot) })); + return import("./knot"); +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("knotCapabilities", () => { + it("calls describeKnot once, caches responses, and shares one probe between concurrent callers", async () => { + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + urls.push(String(input)); + return knotPage(["knot-social-writes"]); + }) + ); + const { knotCapabilities } = await load("knot.test"); + const [first, second] = await Promise.all([ + knotCapabilities(knotOf("knot.test")), + knotCapabilities(knotOf("knot.test")) + ]); + await knotCapabilities(knotOf("knot.test")); + expect(urls).toEqual(["https://knot.test/xrpc/org.tangled.knot.describeKnot"]); + expect(first).toEqual(["knot-social-writes"]); + expect(second).toEqual(first); + }); + + it("returns null for malformed probe bodies instead of crashing, whatever alierns send", async () => { + for (const body of [null, { capabilities: 42 }, { capabilities: ["ok", 7] }, "nope"]) { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => body }) as unknown as Response) + ); + const served = await load("knot.test"); + expect(await served.knotCapabilities(knotOf("knot.test"))).toBe(null); + expect(await served.writeTarget(ctx, repoDid)).toEqual({ kind: "pds" }); + } + }); +}); + +describe("writeTarget", () => { + it("routes writes to knots only when repos resolve, probes succeed, and social writes are served", async () => { + const served = await load("knot.test"); + vi.stubGlobal( + "fetch", + vi.fn(async () => knotPage(["knot-social-writes"])) + ); + expect(await served.writeTarget(ctx, repoDid)).toEqual({ + kind: "knot", + knot: "knot.test", + repoDid + }); + + vi.stubGlobal( + "fetch", + vi.fn(async () => knotPage(["knot-acl"])) + ); + const legacy = await load("knot.test"); + expect(await legacy.writeTarget(ctx, repoDid)).toEqual({ kind: "pds" }); + + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false }) as unknown as Response) + ); + const offline = await load(undefined); + expect(await offline.writeTarget(ctx, repoDid)).toEqual({ kind: "pds" }); + }); +}); diff --git a/web/src/lib/api/knot.ts b/web/src/lib/api/knot.ts new file mode 100644 index 000000000..2c9cf6b9d --- /dev/null +++ b/web/src/lib/api/knot.ts @@ -0,0 +1,80 @@ +import { createBobbinClient, type BobbinContext, type XrpcRequestInit } from "$lib/api/client"; +import { resolveRepoKnot } from "$lib/api/repo"; +import { + capabilityOf, + didOf, + knotOf, + type Capability, + type Did, + type KnotOrigin +} from "$lib/api/syntax"; + +export const SOCIAL_WRITES_CAPABILITY = capabilityOf("knot-social-writes"); + +const DESCRIBE_KNOT_NSID = "org.tangled.knot.describeKnot"; +const CAPABILITY_TTL_MS = 5 * 60_000; +const FAILED_PROBE_TTL_MS = 30_000; +const PROBE_TIMEOUT_MS = 10_000; + +const probes = new Map(); +const asking = new Map>(); + +const capabilitiesOf = (body: unknown): readonly Capability[] | null => { + if (typeof body !== "object" || body === null) return null; + const { capabilities } = body as Record; + if (!Array.isArray(capabilities)) return null; + return capabilities.every((token) => typeof token === "string") + ? capabilities.map(capabilityOf) + : null; +}; + +const describeKnot = async (knot: KnotOrigin): Promise => { + const response = await fetch(`https://${knot}/xrpc/${DESCRIBE_KNOT_NSID}`, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) + }); + if (!response.ok) return null; + return capabilitiesOf(await response.json().catch(() => null)); +}; + +export const knotCapabilities = async ( + knot: KnotOrigin, + now: () => number = Date.now +): Promise => { + const cached = probes.get(knot); + const ttl = cached?.capabilities === null ? FAILED_PROBE_TTL_MS : CAPABILITY_TTL_MS; + if (cached && now() - cached.at < ttl) return cached.capabilities; + const pending = asking.get(knot); + if (pending) return pending; + const probe = describeKnot(knot) + .catch(() => null) + .finally(() => asking.delete(knot)); + asking.set(knot, probe); + const capabilities = await probe; + probes.set(knot, { at: now(), capabilities }); + return capabilities; +}; + +export type WriteTarget = { kind: "pds" } | { kind: "knot"; knot: KnotOrigin; repoDid: Did }; + +export const writeTarget = async ( + ctx: BobbinContext, + repoDid: Did, + init?: XrpcRequestInit +): Promise => { + const host = await resolveRepoKnot(ctx, repoDid, init); + if (!host) return { kind: "pds" }; + const knot = knotOf(host); + const capabilities = await knotCapabilities(knot); + return capabilities?.includes(SOCIAL_WRITES_CAPABILITY) + ? { kind: "knot", knot, repoDid } + : { kind: "pds" }; +}; + +export const writeTargetFor = ( + bobbinUrl: string | undefined, + repoDid: Did +): Promise => + bobbinUrl + ? writeTarget(createBobbinClient({ serviceUrl: bobbinUrl }), repoDid) + : Promise.resolve({ kind: "pds" }); diff --git a/web/src/lib/api/lexicons/types/org/tangled/knot/describeKnot.ts b/web/src/lib/api/lexicons/types/org/tangled/knot/describeKnot.ts new file mode 100644 index 000000000..e303541a3 --- /dev/null +++ b/web/src/lib/api/lexicons/types/org/tangled/knot/describeKnot.ts @@ -0,0 +1,71 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _contactSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("org.tangled.knot.describeKnot#contact"), + ), + email: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), +}); +const _linksSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("org.tangled.knot.describeKnot#links"), + ), + privacyPolicy: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + termsOfService: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), +}); +const _mainSchema = /*#__PURE__*/ v.query("org.tangled.knot.describeKnot", { + params: null, + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * Protocol capability tokens this knot implements. Knots that omit this field are treated as legacy. + */ + capabilities: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.array( + /*#__PURE__*/ v.string<"tangled1.0" | (string & {})>(), + ), + ), + /** + * Contact information + */ + get contact() { + return /*#__PURE__*/ v.optional(contactSchema); + }, + did: /*#__PURE__*/ v.didString(), + /** + * URLs of service policy documents. + */ + get links() { + return /*#__PURE__*/ v.optional(linksSchema); + }, + version: /*#__PURE__*/ v.string(), + }), + }, +}); + +type contact$schematype = typeof _contactSchema; +type links$schematype = typeof _linksSchema; +type main$schematype = typeof _mainSchema; + +export interface contactSchema extends contact$schematype {} +export interface linksSchema extends links$schematype {} +export interface mainSchema extends main$schematype {} + +export const contactSchema = _contactSchema as contactSchema; +export const linksSchema = _linksSchema as linksSchema; +export const mainSchema = _mainSchema as mainSchema; + +export interface Contact extends v.InferInput {} +export interface Links extends v.InferInput {} + +export interface $params {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "org.tangled.knot.describeKnot": mainSchema; + } +} diff --git a/web/src/lib/api/ticket.test.ts b/web/src/lib/api/ticket.test.ts index 55bb4c6b4..97b90f2ab 100644 --- a/web/src/lib/api/ticket.test.ts +++ b/web/src/lib/api/ticket.test.ts @@ -1,11 +1,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; -import type { CanonicalResourceUri } from "@atcute/lexicons/syntax"; +import { atUriOf, knotOf, labelKeyOf } from "$lib/api/syntax"; import type * as Ticket from "$lib/api/ticket"; const agent = { sub: "did:plc:limpet" } as unknown as OAuthUserAgent; -const knot = new URL("https://knot.oyster.cafe"); -const uri = "at://did:plc:barnacle/org.tangled.track.ticket/3lk" as CanonicalResourceUri; +const knot = knotOf("knot.oyster.cafe"); +const uri = atUriOf("at://did:plc:barnacle/org.tangled.track.ticket/3lk"); +const storedCid = "bafyreistored" + "a".repeat(46); +const nextCid = "bafyreinext" + "a".repeat(48); const stored = { $type: "org.tangled.track.ticket", @@ -26,9 +28,9 @@ const load = async () => { const ticket = (await import("$lib/api/ticket")) as typeof Ticket; const sent: Record[] = []; vi.stubGlobal("fetch", async (_input: URL | RequestInfo, init?: RequestInit) => { - if (!init?.body) return Response.json({ uri, cid: "bafystored", value: stored }); + if (!init?.body) return Response.json({ uri, cid: storedCid, value: stored }); sent.push(JSON.parse(String(init.body))); - return Response.json({ uri, cid: "bafynext" }); + return Response.json({ uri, cid: nextCid }); }); return { ticket, sent }; }; @@ -45,7 +47,7 @@ describe("ticket writes on the knot", () => { await ticket.editTicket(agent, knot, uri, { title: "kelp bed", body: "deeper forest" }); - expect(sent[0]).toMatchObject({ swapRecord: "bafystored" }); + expect(sent[0]).toMatchObject({ swapRecord: storedCid }); expect(sent[0].record).toEqual({ ...kept, title: { ...kept.title, text: "kelp bed" }, @@ -79,13 +81,13 @@ describe("ticket writes on the knot", () => { const written = await ticket.setTicketState(agent, knot, uri, "closed"); expect(sent[0].record).toEqual({ ...kept, state: "closed" }); - expect(written.cid).toBe("bafynext"); + expect(written.cid).toBe(nextCid); }); it("replaces the label set and drops it when it ends up empty", async () => { const { ticket, sent } = await load(); - await ticket.setTicketLabels(agent, knot, uri, ["docs", "wontfix"]); + await ticket.setTicketLabels(agent, knot, uri, [labelKeyOf("docs"), labelKeyOf("wontfix")]); await ticket.setTicketLabels(agent, knot, uri, []); expect(sent[0].record).toMatchObject({ diff --git a/web/src/lib/api/ticket.ts b/web/src/lib/api/ticket.ts index 01fe699ef..c7bd94bde 100644 --- a/web/src/lib/api/ticket.ts +++ b/web/src/lib/api/ticket.ts @@ -1,14 +1,23 @@ -import type { CanonicalResourceUri, Did, Nsid } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { createCob, eraseCob, updateCob, type CobRecord, type CobRef } from "$lib/api/cob"; +import { + datetimeOf, + nsidOf, + type AtUri, + type Did, + type KnotOrigin, + type LabelKey +} from "$lib/api/syntax"; import type { TicketRecord, TicketState } from "$lib/api/records"; // a pull is a ticket with a target branch -const TICKET_COLLECTION = "org.tangled.track.ticket" as Nsid; +export const TICKET_COLLECTION = nsidOf("org.tangled.track.ticket"); export interface TicketProse { title: string; body: string; + targetBranch?: string; + tangledLabels?: readonly LabelKey[]; } const titleOf = (text: string): TicketRecord["title"] => ({ @@ -20,12 +29,16 @@ const titleOf = (text: string): TicketRecord["title"] => ({ const bodyOf = (text: string, current?: TicketRecord["body"]): TicketRecord["body"] => text.trim() ? { ...current, $type: "org.tangled.markup.markdown", text } : undefined; +const labelsOf = (keys: readonly LabelKey[]) => + keys.length + ? keys.map((key) => ({ $type: "org.tangled.label.defs#labelToken" as const, key })) + : undefined; + export const createTicket = async ( agent: OAuthUserAgent, - knot: URL, + knot: KnotOrigin, repo: Did, - { title, body }: TicketProse, - targetBranch?: string + { title, body, targetBranch, tangledLabels }: TicketProse ): Promise> => { const record: TicketRecord = { $type: "org.tangled.track.ticket", @@ -33,7 +46,8 @@ export const createTicket = async ( body: bodyOf(body), state: "open", targetBranch, - createdAt: new Date().toISOString() + ...(tangledLabels ? { tangledLabels: labelsOf(tangledLabels) } : {}), + createdAt: datetimeOf(new Date().toISOString()) }; return { ...(await createCob(agent, knot, repo, TICKET_COLLECTION, record)), value: record }; }; @@ -41,37 +55,36 @@ export const createTicket = async ( // a put replaces everything, so start from the stored record export const editTicket = ( agent: OAuthUserAgent, - knot: URL, - uri: CanonicalResourceUri, - { title, body, targetBranch }: TicketProse & { targetBranch?: string } + knot: KnotOrigin, + uri: AtUri, + { title, body, targetBranch, tangledLabels }: TicketProse ): Promise> => updateCob(agent, knot, uri, (current) => ({ ...current, title: titleOf(title), body: bodyOf(body, current.body), - targetBranch: targetBranch ?? current.targetBranch + targetBranch: targetBranch ?? current.targetBranch, + ...(tangledLabels ? { tangledLabels: labelsOf(tangledLabels) } : {}) })); export const setTicketState = ( agent: OAuthUserAgent, - knot: URL, - uri: CanonicalResourceUri, + knot: KnotOrigin, + uri: AtUri, state: Exclude ): Promise> => updateCob(agent, knot, uri, (current) => ({ ...current, state })); export const setTicketLabels = ( agent: OAuthUserAgent, - knot: URL, - uri: CanonicalResourceUri, - keys: readonly string[] + knot: KnotOrigin, + uri: AtUri, + keys: readonly LabelKey[] ): Promise> => updateCob(agent, knot, uri, (current) => ({ ...current, - tangledLabels: keys.length - ? keys.map((key) => ({ $type: "org.tangled.label.defs#labelToken", key })) - : undefined + tangledLabels: labelsOf(keys) })); -export const eraseTicket = (agent: OAuthUserAgent, knot: URL, from: CobRef): Promise => +export const eraseTicket = (agent: OAuthUserAgent, knot: KnotOrigin, from: CobRef): Promise => eraseCob(agent, knot, from); diff --git a/web/src/lib/oauth-client-metadata.test.ts b/web/src/lib/oauth-client-metadata.test.ts index 1c9e82c0a..981f952ec 100644 --- a/web/src/lib/oauth-client-metadata.test.ts +++ b/web/src/lib/oauth-client-metadata.test.ts @@ -1,8 +1,32 @@ import { describe, expect, it } from "vitest"; -import { loopbackClientId, loopbackScope } from "$lib/oauth-client-metadata"; +import metadata, { loopbackClientId, loopbackScope } from "$lib/oauth-client-metadata"; + +const KNOT_WRITE_LXMS = [ + "com.atproto.repo.createRecord", + "com.atproto.repo.putRecord", + "com.atproto.repo.deleteRecord", + "com.atproto.repo.uploadBlob", + "sh.tangled.repo.pull.merge" +] as const; + +describe("oauth client scope", () => { + it("grants any-audience RPC scopes for every lxm used in knot writes", () => { + const granted = new Set(metadata.scope.split(" ")); + for (const lxm of KNOT_WRITE_LXMS) { + expect( + granted.has(`rpc:${lxm}?aud=*`), + `The grant must include rpc:${lxm}?aud=* or the PDS rejects tokens minted for it.` + ).toBe(true); + } + }); + + it("grants repo scope over org.tangled.graph.attestation", () => { + expect(metadata.scope.split(" ")).toContain("repo:org.tangled.graph.attestation"); + }); +}); describe("loopbackClientId", () => { - it("keeps the authorize url within chromium's referrer length limit", () => { + it("keeps authorize URLs within chromium's referrer length limit", () => { const url = new URL("https://tngl.sh/oauth/authorize"); url.searchParams.set( "client_id",