diff --git a/knot2/crates/knot-server/src/main.rs b/knot2/crates/knot-server/src/main.rs index 221bd1fbc..af0150367 100644 --- a/knot2/crates/knot-server/src/main.rs +++ b/knot2/crates/knot-server/src/main.rs @@ -18,7 +18,6 @@ use tokio_util::sync::CancellationToken; use anyhow::Context; use axum::Json; -use axum::http::{Method, header}; use axum::response::Html; use axum::routing::get; use base64::Engine; @@ -29,7 +28,6 @@ use knot_runtime::{Clock, HttpTransport, OsEntropy, ReqwestHttp, SystemClock}; use knot_secrets::{MasterKey, SealedStore}; use knot_types::{ActorId, AuthorName, BranchName, CiLogsAddr, Email, KnotHostname, ObjectCount}; use knot_xrpc::XrpcState; -use tower_http::cors::{Any, CorsLayer}; use tower_http::services::ServeFile; const MAINTENANCE_SHUTDOWN_DRAIN: Duration = Duration::from_secs(30); @@ -675,12 +673,7 @@ async fn main() -> anyhow::Result<()> { HomepageSource::Default => base_router.route("/", get(|| async { Html(DEFAULT_HOMEPAGE) })), HomepageSource::File(path) => base_router.route_service("/", ServeFile::new(path)), }; - let base_router = base_router.layer( - CorsLayer::new() - .allow_origin(Any) - .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) - .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]), - ); + let base_router = base_router.layer(knot_xrpc::browser_cors()); let app = knot_edge::RequiresFullHandshake::new(base_router); let scheme = if tls_setup.is_some() { "https" } else { "http" }; let edge_config = knot_edge::EdgeConfig { diff --git a/knot2/crates/knot-xrpc/Cargo.toml b/knot2/crates/knot-xrpc/Cargo.toml index 965fd9fe5..13bc01539 100644 --- a/knot2/crates/knot-xrpc/Cargo.toml +++ b/knot2/crates/knot-xrpc/Cargo.toml @@ -38,7 +38,7 @@ jacquard-axum = { workspace = true } tracing = { workspace = true } axum = { workspace = true, features = ["ws"] } tower = { workspace = true } -tower-http = { workspace = true, features = ["fs"] } +tower-http = { workspace = true, features = ["fs", "cors"] } http-body = { workspace = true } tokio-util = { workspace = true, features = ["io-util"] } tokio = { workspace = true } diff --git a/knot2/crates/knot-xrpc/src/lib.rs b/knot2/crates/knot-xrpc/src/lib.rs index 4bedd6841..e2d183ab4 100644 --- a/knot2/crates/knot-xrpc/src/lib.rs +++ b/knot2/crates/knot-xrpc/src/lib.rs @@ -58,9 +58,13 @@ use axum::middleware::{Next, from_fn_with_state}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use http::request::Parts; -use http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION}; +use http::{ + HeaderMap, HeaderValue, StatusCode, + header::{AUTHORIZATION, CONTENT_TYPE}, +}; use serde::de::DeserializeOwned; use serde_json::json; +use tower_http::cors::{Any, CorsLayer}; use knot_atproto::{Atproto, AtprotoError, ServiceJwt}; use knot_events::{EventLog, SubscriberGate}; @@ -285,6 +289,13 @@ impl FromRequestParts for Method { } } +pub fn browser_cors() -> CorsLayer { + CorsLayer::new() + .allow_origin(Any) + .allow_methods([http::Method::GET, http::Method::POST, http::Method::OPTIONS]) + .allow_headers([AUTHORIZATION, CONTENT_TYPE]) +} + pub fn router(state: Arc>) -> Router { let merge_routes = Router::new() .route(merge::MERGE_ROUTE, post(merge::merge::)) diff --git a/knot2/crates/knot-xrpc/src/tests.rs b/knot2/crates/knot-xrpc/src/tests.rs index f81ae9b04..4ebb9078b 100644 --- a/knot2/crates/knot-xrpc/src/tests.rs +++ b/knot2/crates/knot-xrpc/src/tests.rs @@ -3690,6 +3690,16 @@ mod rosters { "{from}: each route takes the did of its own subject as the token audience, \ service fragment and all" ); + + let swapped = match offer.repo() { + None => elsewhere.to_string(), + Some(_) => format!("did:web:{KNOT_HOST}"), + }; + assert_eq!( + offer.accept_to(&swapped, offer.own()).await, + StatusCode::UNAUTHORIZED, + "{from}: a token audienced at any did but this subject's buys nothing here" + ); }) .await; } @@ -3827,6 +3837,89 @@ mod rosters { ); } + #[tokio::test] + async fn a_browser_can_preflight_the_acceptance_route_and_read_what_it_answers() { + use axum::body::Body; + use axum::extract::ConnectInfo; + use std::net::SocketAddr; + use tower::ServiceExt; + + let offer = Offer::opened(Roster::Knot, Seen::Projected).await; + offer.answered(StatusCode::OK); + let app = crate::router(Arc::clone(&offer.world.state)).layer(crate::browser_cors()); + let peer = SocketAddr::from(([203, 0, 113, 42], 5555)); + let origin = "https://tangled.org"; + + let mut preflight = http::Request::builder() + .method("OPTIONS") + .uri(crate::members::ACCEPT_ROUTE) + .header(http::header::ORIGIN, origin) + .header(http::header::ACCESS_CONTROL_REQUEST_METHOD, "POST") + .header( + http::header::ACCESS_CONTROL_REQUEST_HEADERS, + "authorization,content-type", + ) + .body(Body::empty()) + .unwrap(); + preflight.extensions_mut().insert(ConnectInfo(peer)); + + let answer = app.clone().oneshot(preflight).await.unwrap(); + assert!( + answer.status().is_success(), + "the frontend sends a preflight before every accept, and it must pass: {:?}", + answer.status() + ); + assert_eq!( + answer + .headers() + .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN), + Some(&HeaderValue::from_static("*")), + "frontend and knot sit on different origins" + ); + let allowed = answer + .headers() + .get(http::header::ACCESS_CONTROL_ALLOW_HEADERS) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + for wanted in ["authorization", "content-type"] { + assert!( + allowed.contains(wanted), + "the accept call sends {wanted}, and the preflight allowed only {allowed}" + ); + } + + let mut refused = http::Request::builder() + .method("POST") + .uri(crate::members::ACCEPT_ROUTE) + .header(http::header::ORIGIN, origin) + .header( + http::header::AUTHORIZATION, + format!("Bearer {}", mint(&offer.world.admin, ACCEPT_MEMBERSHIP)), + ) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&acceptance_uri( + &offer.world.admin.did, + None, + &offer.named(), + )) + .unwrap(), + )) + .unwrap(); + refused.extensions_mut().insert(ConnectInfo(peer)); + + let answer = app.oneshot(refused).await.unwrap(); + assert_eq!(answer.status(), StatusCode::FORBIDDEN); + assert_eq!( + answer + .headers() + .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN), + Some(&HeaderValue::from_static("*")), + "the row prints the knot's own sentence, so the browser needs to read it" + ); + } + mod materialization { use super::*; use knot_cob::{ChangePayload, CobStore}; diff --git a/web/.storybook/MockAuth.svelte b/web/.storybook/MockAuth.svelte index ffb281ec1..1788afcb8 100644 --- a/web/.storybook/MockAuth.svelte +++ b/web/.storybook/MockAuth.svelte @@ -13,6 +13,7 @@ expired?: string[]; // answer xrpc calls instead of failing them, so a story can reach a success state respond?: (pathname: string, init: RequestInit) => Response | Promise; + scope?: string; } // absent: no auth context at all, like a story with no provider. @@ -27,6 +28,7 @@ // land in the form's catch block by default. import { setContext, type Snippet } from "svelte"; import { AUTH_KEY, type Auth } from "$lib/auth.svelte"; + import oauthMetadata from "$lib/oauth-client-metadata.json"; interface Props { auth?: MockAuthParam; @@ -52,6 +54,7 @@ const agent = { sub: knownAccounts[0]?.did, + session: { token: { scope: config.scope ?? oauthMetadata.scope } }, handle: (pathname: string, init: RequestInit) => config.respond ? Promise.resolve(config.respond(pathname, init)) diff --git a/web/src/lib/api/accept.test.ts b/web/src/lib/api/accept.test.ts new file mode 100644 index 000000000..fffd549b7 --- /dev/null +++ b/web/src/lib/api/accept.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { ClientResponseError } from "@atcute/client"; +import oauthMetadata from "$lib/oauth-client-metadata.json"; +import { missingPermissions } from "$lib/auth/scopes"; +import type { DidRkey, NotificationOffer } from "$lib/components/notifications/types"; +import type * as Accept from "./accept"; + +const agent = { sub: "did:plc:limpet" } as unknown as OAuthUserAgent; +const KNOT = "knot.oyster.cafe"; +const KNOT_DID = "did:web:knot.oyster.cafe"; +const REPO_DID = "did:plc:scallop"; + +const offers: Record<"membership" | "collaboration", NotificationOffer> = { + membership: { + kind: "membership", + knot: new URL(`https://${KNOT}`), + subject: KNOT_DID as DidRkey + }, + collaboration: { + kind: "collaboration", + knot: new URL(`https://${KNOT}`), + subject: REPO_DID as DidRkey + } +}; + +const WRITES = { + membership: { + collection: "sh.tangled.knot.memberAcceptance", + procedure: "sh.tangled.knot.acceptMembership", + key: KNOT_DID + }, + collaboration: { + collection: "sh.tangled.repo.collaboratorAcceptance", + procedure: "sh.tangled.repo.acceptCollaboration", + key: REPO_DID + } +}; + +interface Write { + repo: string; + collection: string; + rkey: string; + record: { $type: string; createdAt: string }; +} + +interface Faults { + record?: unknown; + token?: unknown; +} + +const load = async (faults: Faults = {}) => { + vi.resetModules(); + const writes: Write[] = []; + const minted: { aud: string; lxm: string; signal?: AbortSignal }[] = []; + const steps: string[] = []; + vi.doMock("$lib/auth/agent", () => ({ + createClient: () => ({ + call: async (_schema: unknown, { input }: { input: Write }) => { + steps.push("record"); + if (faults.record) throw faults.record; + writes.push(input); + return { + ok: true, + data: { + uri: `at://${input.repo}/${input.collection}/${input.rkey}`, + cid: "bafy" + } + }; + } + }), + mintServiceAuth: async ( + _agent: unknown, + { aud, lxm, signal }: { aud: string; lxm: string; signal?: AbortSignal } + ) => { + steps.push("token"); + if (faults.token) throw faults.token; + minted.push({ aud, lxm, signal }); + return `token-for-${aud}`; + } + })); + const calls: { url: string; init: RequestInit }[] = []; + const knot = vi.fn(async (input, init) => { + steps.push("call"); + calls.push({ url: String(input), init: init ?? {} }); + return new Response(null, { status: 200 }); + }); + return { accept: await import("./accept"), writes, minted, steps, calls, knot }; +}; + +const refusing = async (faults: Faults = {}, knot?: typeof globalThis.fetch) => { + const harness = await load(faults); + const thrown = await harness.accept + .acceptOffer(agent, offers.membership, knot ?? harness.knot) + .catch((cause: unknown) => cause); + expect(thrown).toBeInstanceOf(harness.accept.OfferRefused); + return { refused: thrown as Accept.OfferRefused, ...harness }; +}; + +describe("accepting an offer", () => { + it.each(["membership", "collaboration"] as const)( + "a %s acceptance lands under its own subject, and the knot takes the uri under a fresh token", + async (kind) => { + const { accept, writes, minted, calls, steps, knot } = await load(); + const { collection, procedure, key } = WRITES[kind]; + + await accept.acceptOffer(agent, offers[kind], knot); + + expect(steps).toEqual(["record", "token", "call"]); + expect(writes[0]).toMatchObject({ + repo: agent.sub, + collection, + rkey: key, + record: { $type: collection } + }); + expect(Number.isFinite(Date.parse(writes[0].record.createdAt))).toBe(true); + expect(minted[0]).toMatchObject({ aud: key, lxm: procedure }); + expect(minted[0].signal).toBeInstanceOf(AbortSignal); + expect(calls[0].url).toBe(`https://${KNOT}/xrpc/${procedure}`); + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + acceptance: `at://${agent.sub}/${collection}/${key}` + }); + expect(new Headers(calls[0].init.headers).get("authorization")).toBe( + `Bearer token-for-${key}` + ); + expect(calls[0].init.signal).toBeInstanceOf(AbortSignal); + } + ); + + it("xrpc url keeps the knot's port but drops its path", async () => { + const { accept, calls, knot } = await load(); + + await accept.acceptOffer( + agent, + { ...offers.membership, knot: new URL(`https://${KNOT}:8443/ignored`) }, + knot + ); + + expect(calls[0].url).toBe(`https://${KNOT}:8443/xrpc/sh.tangled.knot.acceptMembership`); + }); + + it("a second accept rewrites the same record key", async () => { + const { accept, writes, knot } = await load(); + + await accept.acceptOffer(agent, offers.membership, knot); + await accept.acceptOffer(agent, offers.membership, knot); + + expect(writes.map((write) => write.rkey)).toEqual([KNOT_DID, KNOT_DID]); + }); +}); + +describe("OfferRefused owns the step that failed", () => { + it("record write fails first, knot never hears about it", async () => { + const { refused, steps } = await refusing({ record: new Error("pds is down") }); + + expect(refused.stage).toBe("record"); + expect(steps).toEqual(["record"]); + }); + + it("token mint fails with the acceptance already published", async () => { + const { refused, writes, steps } = await refusing({ token: new Error("no such scope") }); + + expect(refused.stage).toBe("token"); + expect(writes).toHaveLength(1); + expect(steps).toEqual(["record", "token"]); + }); + + it("the knot's 403 arrives as the cause when the call fails", async () => { + const message = "no membership offer for you on this knot"; + const knot = vi.fn( + async () => + new Response(JSON.stringify({ error: "Forbidden", message }), { + status: 403, + headers: { "content-type": "application/json" } + }) + ); + + const { refused } = await refusing({}, knot); + + expect(refused.stage).toBe("call"); + expect(refused.cause).toBeInstanceOf(ClientResponseError); + expect((refused.cause as ClientResponseError).status).toBe(403); + expect((refused.cause as ClientResponseError).description).toBe(message); + }); + + it("a silent knot fails the call with no description", async () => { + const offline = new TypeError("Failed to fetch"); + const knot = vi.fn(() => Promise.reject(offline)); + + const { refused } = await refusing({}, knot); + + expect(refused.stage).toBe("call"); + expect(refused.cause).toBe(offline); + expect(refused.description).toBeNull(); + }); + + it("error code stands in where the service answers no sentence", async () => { + const { refused } = await refusing({ + record: new ClientResponseError({ status: 400, data: { error: "InvalidRequest" } }) + }); + + expect(refused.description).toBe("InvalidRequest"); + }); +}); + +describe("OfferRefused's sentence, stage by stage", () => { + it.each([ + ["record", null, "Your account wouldn't store this acceptance. Nothing was granted."], + [ + "record", + "Account is over quota", + "Your account stored nothing, so nothing was granted: Account is over quota" + ], + ["token", null, `Your acceptance is stored, but signing the call to ${KNOT} failed.`], + [ + "token", + "Account is over quota", + `Your acceptance is stored, but your account wouldn't sign the call to ${KNOT}: Account is over quota` + ], + ["call", null, `${KNOT} didn't answer. Your acceptance is stored, so try again.`], + [ + "call", + "no membership offer for you on this knot", + "no membership offer for you on this knot" + ] + ] as const)("%s stage against %s", async (stage, description, expected) => { + const { accept } = await load(); + const cause = + description === null + ? new Error("silent") + : new ClientResponseError({ + status: 403, + data: { error: "Forbidden", message: description } + }); + + expect(new accept.OfferRefused(stage, cause).sentence(offers.membership.knot)).toBe( + expected + ); + }); +}); +describe("permissions an offer needs", () => { + it("all live in the client metadata, so fresh logins can accept either kind", async () => { + const { accept } = await load(); + + expect( + missingPermissions(oauthMetadata.scope, accept.permissionsFor(offers.membership)) + ).toEqual([]); + expect( + missingPermissions(oauthMetadata.scope, accept.permissionsFor(offers.collaboration)) + ).toEqual([]); + }); + + it.each(["membership", "collaboration"] as const)( + "a %s writes one collection and calls one procedure", + async (kind) => { + const { accept } = await load(); + const { collection, procedure, key } = WRITES[kind]; + + expect(accept.permissionsFor(offers[kind])).toEqual([ + { resource: "repo", collection, actions: ["create", "update"] }, + { resource: "rpc", lxm: procedure, aud: key } + ]); + } + ); +}); diff --git a/web/src/lib/api/accept.ts b/web/src/lib/api/accept.ts new file mode 100644 index 000000000..c1773504f --- /dev/null +++ b/web/src/lib/api/accept.ts @@ -0,0 +1,133 @@ +import { ClientResponseError } from "@atcute/client"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { mintServiceAuth } from "$lib/auth/agent"; +import type { Permission } from "$lib/auth/scopes"; +import type { NotificationOffer } from "$lib/components/notifications/types"; +import { + mainSchema as acceptCollaborationSchema, + type $input as CollaborationInput +} from "./lexicons/types/sh/tangled/repo/acceptCollaboration"; +import { + mainSchema as acceptMembershipSchema, + type $input as MembershipInput +} from "./lexicons/types/sh/tangled/knot/acceptMembership"; +import type * as CollaboratorAcceptance from "./lexicons/types/sh/tangled/repo/collaboratorAcceptance"; +import type * as MemberAcceptance from "./lexicons/types/sh/tangled/knot/memberAcceptance"; +import { toResponseError } from "./_request"; +import { putRecord } from "./write"; + +const ACCEPTANCES: { + membership: { + collection: MemberAcceptance.Main["$type"]; + procedure: typeof acceptMembershipSchema.nsid; + }; + collaboration: { + collection: CollaboratorAcceptance.Main["$type"]; + procedure: typeof acceptCollaborationSchema.nsid; + }; +} = { + membership: { + collection: "sh.tangled.knot.memberAcceptance", + procedure: acceptMembershipSchema.nsid + }, + collaboration: { + collection: "sh.tangled.repo.collaboratorAcceptance", + procedure: acceptCollaborationSchema.nsid + } +}; + +export const permissionsFor = (offer: NotificationOffer): readonly Permission[] => { + const { collection, procedure } = ACCEPTANCES[offer.kind]; + return [ + { resource: "repo", collection, actions: ["create", "update"] }, + { resource: "rpc", lxm: procedure, aud: offer.subject } + ]; +}; + +export type AcceptStage = "record" | "token" | "call"; + +const MINT_DEADLINE_MS = 15_000; +const CALL_DEADLINE_MS = 30_000; + +const SENTENCES: Record< + AcceptStage, + { silent: (host: string) => string; answered: (host: string, description: string) => string } +> = { + record: { + silent: () => "Your account wouldn't store this acceptance. Nothing was granted.", + answered: (_host, description) => + `Your account stored nothing, so nothing was granted: ${description}` + }, + token: { + silent: (host) => `Your acceptance is stored, but signing the call to ${host} failed.`, + answered: (host, description) => + `Your acceptance is stored, but your account wouldn't sign the call to ${host}: ${description}` + }, + call: { + silent: (host) => `${host} didn't answer. Your acceptance is stored, so try again.`, + answered: (_host, description) => description + } +}; + +export class OfferRefused extends Error { + constructor( + readonly stage: AcceptStage, + cause: unknown + ) { + super(`accepting failed at ${stage}`, { cause }); + } + + get description(): string | null { + return this.cause instanceof ClientResponseError + ? (this.cause.description ?? this.cause.error) + : null; + } + sentence(knot: URL): string { + const { silent, answered } = SENTENCES[this.stage]; + const host = knot.host; + return this.description === null ? silent(host) : answered(host, this.description); + } +} + +const refusing = async (stage: AcceptStage, act: () => Promise): Promise => { + try { + return await act(); + } catch (cause) { + throw new OfferRefused(stage, cause); + } +}; + +export const acceptOffer = async ( + agent: OAuthUserAgent, + offer: NotificationOffer, + fetch: typeof globalThis.fetch = globalThis.fetch +): Promise => { + const { collection, procedure } = ACCEPTANCES[offer.kind]; + const { uri } = await refusing("record", () => + putRecord(agent, collection, offer.subject, { + $type: collection, + createdAt: new Date().toISOString() + }) + ); + const token = await refusing("token", () => + mintServiceAuth(agent, { + aud: offer.subject, + lxm: procedure, + signal: AbortSignal.timeout(MINT_DEADLINE_MS) + }) + ); + const body: MembershipInput & CollaborationInput = { acceptance: uri }; + const response = await refusing("call", () => + fetch(new URL(`/xrpc/${procedure}`, offer.knot), { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + authorization: `Bearer ${token}` + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(CALL_DEADLINE_MS) + }) + ); + if (!response.ok) throw new OfferRefused("call", await toResponseError(response)); +}; diff --git a/web/src/lib/api/notifications.test.ts b/web/src/lib/api/notifications.test.ts index 253c266dc..8357b319c 100644 --- a/web/src/lib/api/notifications.test.ts +++ b/web/src/lib/api/notifications.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { hydrateNotifications, type NotificationItem } from "./notifications"; -import type { BobbinContext } from "./client"; +import { createBobbinClient, type BobbinContext } from "./client"; const knotOffer: NotificationItem = { uri: "at://did:web:knot.oyster.cafe/sh.tangled.knot.memberInvite/did:plc:limpet", @@ -12,6 +12,38 @@ const knotOffer: NotificationItem = { knotDid: "did:web:knot.oyster.cafe" }; +const collaborationOffer: NotificationItem = { + uri: "at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet", + type: "collaborator_invited", + category: "work", + actorDid: "did:plc:akshay", + read: false, + createdAt: "2026-06-01T00:00:00.000Z", + repoDid: "did:plc:scallop" +}; + +const repoRecord = (knot: string) => ({ + $type: "sh.tangled.repo", + name: "shoal", + knot, + repoDid: "did:plc:scallop", + createdAt: "2026-05-01T00:00:00.000Z" +}); + +const ctxWithRepo = (value: Record) => + createBobbinClient({ + serviceUrl: "https://bobbin.oyster.cafe", + fetch: vi.fn(async (input) => + String(input).includes("sh.tangled.repo.getReposByRepoDids") + ? Response.json({ + items: [ + { uri: "at://did:plc:akshay/sh.tangled.repo/3lk", cid: "bafy", value } + ] + }) + : new Response(null, { status: 404 }) + ) + }); + const ctxWithoutBobbin = () => ({ xrpc: { call: vi.fn() } }) as unknown as BobbinContext; describe("hydrateNotifications", () => { @@ -32,3 +64,63 @@ describe("hydrateNotifications", () => { expect(rows[0].knot).toBeUndefined(); }); }); + +describe("an offer the row can act on", () => { + it("membership offers take their key from the knot that made them", async () => { + const rows = await hydrateNotifications(ctxWithoutBobbin(), [knotOffer]); + + expect(rows[0].offer).toEqual({ + kind: "membership", + knot: new URL("https://knot.oyster.cafe"), + subject: "did:web:knot.oyster.cafe" + }); + }); + + it.each([ + "did:web:knot.oyster.cafe%3A8443", + "did:web:knot.oyster.cafe%zz", + "did:web:knot.oyster.cafe/path", + "knot.oyster.cafe", + "..", + "" + ])("offers nothing to accept where %s can't be an acceptance record key", async (knotDid) => { + const rows = await hydrateNotifications(ctxWithoutBobbin(), [{ ...knotOffer, knotDid }]); + + expect(rows[0].offer).toBeUndefined(); + }); + + it("a collaboration offer takes its knot from the repo record, never from the row", async () => { + const rows = await hydrateNotifications(ctxWithRepo(repoRecord("knot.nel.pet")), [ + collaborationOffer + ]); + + expect(rows[0].offer).toEqual({ + kind: "collaboration", + knot: new URL("https://knot.nel.pet"), + subject: "did:plc:scallop" + }); + }); + + it("offers nothing where the repo record's host won't parse into a url", async () => { + const rows = await hydrateNotifications( + ctxWithRepo(repoRecord("knot.nel.pet:not-a-port")), + [collaborationOffer] + ); + + expect(rows[0].offer).toBeUndefined(); + }); + + it("offers nothing when bobbin can't serve the repo record", async () => { + const rows = await hydrateNotifications(ctxWithoutBobbin(), [collaborationOffer]); + + expect(rows[0].offer).toBeUndefined(); + }); + + it("leaves every other notification type alone", async () => { + const rows = await hydrateNotifications(ctxWithoutBobbin(), [ + { ...knotOffer, type: "followed" } + ]); + + expect(rows[0].offer).toBeUndefined(); + }); +}); diff --git a/web/src/lib/api/notifications.ts b/web/src/lib/api/notifications.ts index b3572475f..528d4c058 100644 --- a/web/src/lib/api/notifications.ts +++ b/web/src/lib/api/notifications.ts @@ -6,6 +6,8 @@ import { getIssues } from "./issue"; import { getPulls } from "./records"; import { didFromUri, rkeyFromUri } from "./uri"; import { hostForServiceDid } from "$lib/auth/agent"; +import { isDid, type Did } from "@atcute/lexicons/syntax"; +import { parseDidRkey, type NotificationOffer } from "$lib/components/notifications/types"; import type { NotificationActor, NotificationKnot, @@ -111,6 +113,27 @@ const subjectFromUri = ( title: title ?? rkeyFromUri(uri) }); +const asKnot = (host: string | null | undefined): URL | null => + host ? URL.parse(`https://${host}`) : null; + +const OFFERS: Partial< + Record< + NotificationType, + (item: NotificationItem, knotOfRepo: ReadonlyMap) => NotificationOffer | undefined + > +> = { + knot_invited: (item) => { + const subject = parseDidRkey(item.knotDid); + const knot = subject ? asKnot(hostForServiceDid(subject)) : null; + return subject && knot ? { kind: "membership", knot, subject } : undefined; + }, + collaborator_invited: (item, knotOfRepo) => { + const subject = parseDidRkey(item.repoDid); + const knot = subject ? knotOfRepo.get(subject) : undefined; + return subject && knot ? { kind: "collaboration", knot, subject } : undefined; + } +}; + /** resolve rows best-effort: missing actor, repo, or entity falls back to the raw value */ export const hydrateNotifications = async ( ctx: BobbinContext, @@ -152,6 +175,7 @@ export const hydrateNotifications = async ( } const repoMap = new Map(); + const knotOfRepo = new Map(); for (const record of repoList.items) { const repoDid = record.value.repoDid; if (!repoDid) continue; @@ -161,6 +185,10 @@ export const hydrateNotifications = async ( ownerHandle, name: record.value.name ?? repoDid }); + if (isDid(repoDid)) { + const knot = asKnot(record.value.knot); + if (knot) knotOfRepo.set(repoDid, knot); + } } for (const repoDid of repoDids) { if (!repoMap.has(repoDid)) { @@ -200,7 +228,8 @@ export const hydrateNotifications = async ( actor, repo, knot, - subject + subject, + offer: OFFERS[item.type as NotificationType]?.(item, knotOfRepo) }; }); }; diff --git a/web/src/lib/api/repoCreationTargets.test.ts b/web/src/lib/api/repoCreationTargets.test.ts index d962b40fb..610c5952e 100644 --- a/web/src/lib/api/repoCreationTargets.test.ts +++ b/web/src/lib/api/repoCreationTargets.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { Did } from "@atcute/lexicons/syntax"; -import type { BobbinContext } from "./client"; +import { createBobbinClient, type BobbinContext } from "./client"; import { availableKnots, availableSpindles } from "./repoCreationTargets"; const did = "did:plc:alice" as Did; @@ -58,50 +58,44 @@ describe("availableSpindles", () => { describe("availableKnots", () => { it("merges owned knots and memberships", async () => { - const call = vi.fn(async (_schema: unknown, _options: unknown) => ({ - ok: true, - data: { + 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:alice/sh.tangled.knot/owned.example", - value: {} - } + uri: "at://did:plc:owner/sh.tangled.knot.member/one", + value: { domain: "member.example" } + }, + { + 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 () => - new Response( - JSON.stringify({ - items: [ - { - uri: "at://did:plc:owner/sh.tangled.knot.member/one", - value: { domain: "member.example" } - }, - { - uri: "at://did:plc:owner/sh.tangled.knot.member/two", - value: { domain: "owned.example" } - } - ] - }), - { status: 200, headers: { "content-type": "application/json" } } - ) - ); - const ctx = { - xrpc: { call }, - serviceUrl: "https://bobbin.example", - fetch - } as unknown as BobbinContext; + }; + 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(call).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledTimes(1); - const [url] = fetch.mock.calls[0]; - expect(String(url)).toBe( - "https://bobbin.example/xrpc/sh.tangled.knot.listMembers?subject=did%3Aplc%3Aalice&limit=1000" + 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/auth/agent.ts b/web/src/lib/auth/agent.ts index cd08fa614..58db4b43d 100644 --- a/web/src/lib/auth/agent.ts +++ b/web/src/lib/auth/agent.ts @@ -12,6 +12,7 @@ export interface ServiceAuthOptions { lxm: string; // clamped to at least 60s, matching appview's service client. expiresInSeconds?: number; + signal?: AbortSignal; } // did:web service id, with ports percent-encoded like serviceauth didweb. @@ -31,13 +32,14 @@ export const hostForServiceDid = (did: string): string | null => { // mint a service-auth jwt for knot/spindle xrpc calls. export const mintServiceAuth = async ( agent: OAuthUserAgent, - { aud, lxm, expiresInSeconds = 60 }: ServiceAuthOptions + { aud, lxm, expiresInSeconds = 60, signal }: ServiceAuthOptions ): Promise => { const client = createClient(agent); const exp = Math.floor(Date.now() / 1000) + Math.max(expiresInSeconds, 60); const { token } = await ok( client.call(getServiceAuthSchema, { - params: { aud, exp, lxm: lxm as Nsid } + params: { aud, exp, lxm: lxm as Nsid }, + signal }) ); return token; diff --git a/web/src/lib/auth/scopes.test.ts b/web/src/lib/auth/scopes.test.ts new file mode 100644 index 000000000..4a7b78c53 --- /dev/null +++ b/web/src/lib/auth/scopes.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { missingPermissions, type Permission } from "./scopes"; + +const KNOT = "did:web:knot.oyster.cafe"; + +const write: Permission = { + resource: "repo", + collection: "sh.tangled.knot.memberAcceptance", + actions: ["create", "update"] +}; + +const call: Permission = { + resource: "rpc", + lxm: "sh.tangled.knot.acceptMembership", + aud: KNOT +}; + +const wanted = [write, call]; + +const covering: [string, readonly Permission[]][] = [ + ["repo:sh.tangled.knot.memberAcceptance", [write]], + ["repo?collection=sh.tangled.knot.memberAcceptance", [write]], + ["repo:*", [write]], + ["repo:sh.tangled.knot.memberAcceptance?action=create&action=update&action=delete", [write]], + ["rpc:sh.tangled.knot.acceptMembership?aud=*", [call]], + [`rpc?lxm=sh.tangled.knot.acceptMembership&aud=${KNOT}`, [call]], + [ + `rpc:sh.tangled.knot.acceptMembership?aud=${KNOT}%23tangled_knot`, + [{ ...call, aud: `${KNOT}#tangled_knot` }] + ], + [`rpc:*?aud=${KNOT}`, [call]], + ["atproto include:sh.tangled.authKnot", wanted], + ["atproto transition:generic", wanted] +]; + +const coveringNothing: [string, readonly Permission[]][] = [ + ["repo", [write]], + ["repo:", [write]], + ["repo?action=create", [write]], + ["repo:sh.tangled.knot.member", [write]], + ["repo:sh.tangled.knot.memberAcceptance?action=create", [write]], + ["rpc:sh.tangled.knot.acceptMembership", [call]], + ["rpc?lxm=sh.tangled.knot.acceptMembership", [call]], + ["rpc:sh.tangled.knot.*?aud=*", [call]], + ["rpc:sh.tangled.knot.addMember?aud=*", wanted], + ["", wanted], + ["atproto repo:sh.tangled.knot repo:sh.tangled.knot.member blob:*/*", wanted] +]; + +describe("a granted scope", () => { + it.each(covering)("covers %s", (granted, permissions) => { + expect(missingPermissions(granted, permissions)).toEqual([]); + }); + + it.each(coveringNothing)("covers nothing under %s", (granted, missing) => { + expect(missingPermissions(granted, missing)).toEqual(missing); + }); + + it("covers its own audience and nobody else's", () => { + const mine = `rpc?lxm=sh.tangled.knot.acceptMembership&aud=${KNOT}`; + const theirs = "rpc?lxm=sh.tangled.knot.acceptMembership&aud=did:web:other.example"; + + expect(missingPermissions(mine, [call])).toEqual([]); + expect(missingPermissions(theirs, [call])).toEqual([call]); + }); +}); diff --git a/web/src/lib/auth/scopes.ts b/web/src/lib/auth/scopes.ts new file mode 100644 index 000000000..770d736d0 --- /dev/null +++ b/web/src/lib/auth/scopes.ts @@ -0,0 +1,75 @@ +import type { Did, Nsid } from "@atcute/lexicons/syntax"; + +type RepoAction = "create" | "update" | "delete"; + +export type Permission = + | { + readonly resource: "repo"; + readonly collection: Nsid; + readonly actions: readonly RepoAction[]; + } + | { readonly resource: "rpc"; readonly lxm: Nsid; readonly aud: Did }; + +const ANY = "*"; + +const ALL_ACTIONS: readonly RepoAction[] = ["create", "update", "delete"]; + +interface Grant { + readonly resource: string; + readonly positional: string | null; + readonly params: URLSearchParams; +} + +const grantFrom = (scope: string): Grant => { + const query = scope.indexOf("?"); + const head = query < 0 ? scope : scope.slice(0, query); + const params = new URLSearchParams(query < 0 ? "" : scope.slice(query + 1)); + const colon = head.indexOf(":"); + return colon < 0 + ? { resource: head, positional: null, params } + : { resource: head.slice(0, colon), positional: head.slice(colon + 1), params }; +}; + +const listOf = (grant: Grant, name: string): readonly string[] => + grant.positional === null + ? grant.params.getAll(name) + : grant.positional === "" + ? [] + : [grant.positional]; + +const isRepoAction = (value: string): value is RepoAction => + (ALL_ACTIONS as readonly string[]).includes(value); + +const nameMatches = (granted: string, wanted: Nsid): boolean => + granted === ANY || granted === wanted; + +const covers = (grant: Grant, wanted: Permission): boolean => { + if (grant.resource !== wanted.resource) return false; + if (wanted.resource === "repo") { + const asked = grant.params.getAll("action"); + const held = asked.length === 0 ? ALL_ACTIONS : asked.filter(isRepoAction); + return ( + listOf(grant, "collection").some((granted) => + nameMatches(granted, wanted.collection) + ) && wanted.actions.every((action) => held.includes(action)) + ); + } + const auds = grant.params.getAll("aud"); + return ( + auds.some((aud) => aud === ANY || aud === wanted.aud) && + listOf(grant, "lxm").some((granted) => nameMatches(granted, wanted.lxm)) + ); +}; + +export const missingPermissions = ( + granted: string, + wanted: readonly Permission[] +): readonly Permission[] => { + const scopes = granted.split(/\s+/).filter((scope) => scope !== ""); + const unreadable = scopes.some( + (scope) => scope === "transition:generic" || scope.startsWith("include:") + ); + if (unreadable) return []; + const grants = scopes.map(grantFrom); + return wanted.filter((permission) => !grants.some((grant) => covers(grant, permission))); +}; diff --git a/web/src/lib/components/notifications/NotificationItem.stories.svelte b/web/src/lib/components/notifications/NotificationItem.stories.svelte index 76e77c4a6..aba405774 100644 --- a/web/src/lib/components/notifications/NotificationItem.stories.svelte +++ b/web/src/lib/components/notifications/NotificationItem.stories.svelte @@ -1,5 +1,6 @@ @@ -55,19 +95,84 @@ {/snippet} - + + {#snippet template(args)} +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ {/snippet} +
+ + withKnot(new Response(null, { status: 200 }), async (calls) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Accept" })); + await waitFor(() => expect(canvas.getByText("Accepted")).toBeVisible()); + expect(calls).toEqual([ + "https://knot.tangled.sh/xrpc/sh.tangled.knot.acceptMembership" + ]); + })} +> {#snippet template(args)}
{/snippet}
- + + withKnot( + new Response(JSON.stringify({ error: "Forbidden", message: withdrawn }), { + status: 403, + headers: { "content-type": "application/json" } + }), + async () => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Accept" })); + await waitFor(() => expect(canvas.getByText(withdrawn)).toBeVisible()); + expect(canvas.getByRole("button", { name: "Try again" })).toBeVisible(); + } + )} +> {#snippet template(args)}
{/snippet}
- + {#snippet template()}
{#each notifications as notification (notification.uri)} diff --git a/web/src/lib/components/notifications/NotificationItem.svelte b/web/src/lib/components/notifications/NotificationItem.svelte index f307ff16d..35188519d 100644 --- a/web/src/lib/components/notifications/NotificationItem.svelte +++ b/web/src/lib/components/notifications/NotificationItem.svelte @@ -6,16 +6,17 @@ // the row subscribes to NotificationList's columns, so every row shares one // lead column width and the descriptions line up down the whole list. it // only lays out correctly inside that grid. - root: "group/notification col-span-full grid grid-cols-subgrid items-center gap-y-1 px-2 py-4 no-underline transition-colors hover:border-border-disabled hover:no-underline md:px-4", + root: "group/notification relative isolate col-span-full grid grid-cols-subgrid items-center gap-y-1 px-2 py-4 transition-colors hover:border-border-disabled md:px-4", icon: "row-start-1 size-3.5 shrink-0", header: "row-start-1 flex min-w-0 items-center gap-1.5 overflow-hidden typography-paragraph-small", - phrase: "truncate text-foreground-muted", + phrase: "truncate text-foreground-muted no-underline after:absolute after:inset-0 after:z-1 hover:no-underline", target: "text-foreground-default", meta: "relative row-start-1 flex items-center justify-end", time: "typography-paragraph-small whitespace-nowrap text-foreground-subtle", - toggle: "absolute inset-0 hidden items-center justify-end text-foreground-placeholder hover:cursor-pointer hover:text-foreground-muted md:group-hover/notification:flex", + toggle: "absolute inset-0 z-10 hidden items-center justify-end text-foreground-placeholder hover:cursor-pointer hover:text-foreground-muted md:group-hover/notification:flex", number: "col-start-1 row-start-2 text-left typography-paragraph-small text-foreground-muted", - title: "col-start-2 row-start-2 truncate typography-paragraph-small text-foreground-muted" + title: "col-start-2 row-start-2 truncate typography-paragraph-small text-foreground-muted", + action: "col-start-2 row-start-2" }, variants: { read: { @@ -39,6 +40,7 @@ import ColorBall from "$lib/components/ui/ColorBall.svelte"; import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; import User from "$lib/components/ui/User.svelte"; + import NotificationOfferAction from "./NotificationOfferAction.svelte"; import { notificationHref, notificationIcon, @@ -61,12 +63,7 @@ const href = $derived(notificationHref(notification)); const target = $derived(notificationTarget(notification)); - // the row is one big link, so the read toggle inside it must not navigate - const toggle = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onToggleRead?.(notification.uri, !notification.read); - }; + const toggle = () => onToggleRead?.(notification.uri, !notification.read); // client-side navigation leaves a popover open, so a row inside the topbar // dropdown has to dismiss it on the way out @@ -77,9 +74,7 @@ }; - - - +
@@ -123,8 +120,10 @@ {/if}
- {#if notification.subject} + {#if notification.offer} + + {:else if notification.subject} #{notification.subject.number}
{notification.subject.title}
{/if} - +
diff --git a/web/src/lib/components/notifications/NotificationItem.test.ts b/web/src/lib/components/notifications/NotificationItem.test.ts new file mode 100644 index 000000000..6b4474d7f --- /dev/null +++ b/web/src/lib/components/notifications/NotificationItem.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { render } from "svelte/server"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import NotificationItem from "./NotificationItem.svelte"; +import { notifications } from "./mock"; +import type { NotificationSummary } from "./types"; +import { AUTH_KEY, type Auth } from "$lib/auth.svelte"; +import oauthMetadata from "$lib/oauth-client-metadata.json"; + +const rowOf = (type: NotificationSummary["type"]): NotificationSummary => { + const found = notifications.find((notification) => notification.type === type); + if (!found) throw new Error(`no ${type} fixture to render`); + return found; +}; + +const signedIn = (scope: string): Map => + new Map([ + [ + AUTH_KEY, + { + agent: { session: { token: { scope } } } as unknown as OAuthUserAgent, + currentDid: "did:plc:limpet" + } as unknown as Auth + ] + ]); + +const markup = (notification: NotificationSummary, context?: Map): string => + render(NotificationItem, { + props: { notification, onToggleRead: () => {} }, + context + }).body; + +const insideTheLink = (html: string): string => { + const open = html.indexOf("", open); + if (open < 0 || close < 0) throw new Error("the row rendered no link"); + return html.slice(open, close); +}; + +describe("a notification row", () => { + it("no control sits inside the link, since anchors can't wrap buttons", () => { + const html = markup(rowOf("knot_invited"), signedIn(oauthMetadata.scope)); + + expect(insideTheLink(html)).not.toContain(" { + expect(insideTheLink(markup(rowOf("user_mentioned")))).toContain("after:inset-0"); + }); + + it("either invite kind gets an accept button", () => { + expect(markup(rowOf("knot_invited"), signedIn(oauthMetadata.scope))).toContain("Accept"); + expect(markup(rowOf("collaborator_invited"), signedIn(oauthMetadata.scope))).toContain( + "Accept" + ); + }); + + it("a session minted before the scopes goes back through authorization", () => { + const html = markup(rowOf("knot_invited"), signedIn("atproto repo:sh.tangled.knot")); + + expect(html).toContain("Sign in again to accept"); + expect(html).not.toContain(">Accept<"); + }); + + it("no session, no auth context, no accept button", () => { + const signedOut = markup( + rowOf("knot_invited"), + new Map([[AUTH_KEY, { agent: null, currentDid: null } as unknown as Auth]]) + ); + + expect(signedOut).not.toContain("Accept"); + expect(signedOut.split(" { + const html = markup(rowOf("pull_merged"), signedIn(oauthMetadata.scope)); + + expect(html).not.toContain("Accept"); + expect(html.split(" diff --git a/web/src/lib/components/notifications/NotificationList.svelte b/web/src/lib/components/notifications/NotificationList.svelte index 83af8eaec..8d03ff2df 100644 --- a/web/src/lib/components/notifications/NotificationList.svelte +++ b/web/src/lib/components/notifications/NotificationList.svelte @@ -50,10 +50,10 @@ divide-y hangs the separator off the bottom of each child, so the line above a row belongs to the row before it. a hovered row has to reach back one sibling to - recolor that line, and the rows are the only links in here. + recolor that line, and data-notification is what tells a row from a date heading. -->
*:has(+[data-notification]:hover)]:border-border-disabled {border ? 'rounded-sm border border-border-default' : ''}" > diff --git a/web/src/lib/components/notifications/NotificationOfferAction.svelte b/web/src/lib/components/notifications/NotificationOfferAction.svelte new file mode 100644 index 000000000..6dc534211 --- /dev/null +++ b/web/src/lib/components/notifications/NotificationOfferAction.svelte @@ -0,0 +1,102 @@ + + +{#if agent} +
+ {#if progress.kind === "accepted"} + + + {:else} + {#if ungranted.length > 0} + + {:else} + + {/if} + {#if progress.kind === "refused"} + {progress.message} + {:else if ungranted.length > 0} + + Accepting arrived after you signed in. + + {/if} + {/if} +
+{/if} diff --git a/web/src/lib/components/notifications/mock.ts b/web/src/lib/components/notifications/mock.ts index 859df3142..6db1f7058 100644 --- a/web/src/lib/components/notifications/mock.ts +++ b/web/src/lib/components/notifications/mock.ts @@ -2,7 +2,7 @@ // this page hands back at-uris rather than repo names and titles, so nothing // reads from it yet. -import type { NotificationSummary } from "./types"; +import type { DidRkey, NotificationSummary } from "./types"; const minutes = (n: number) => n * 60_000; @@ -18,6 +18,7 @@ const dave = { did: "did:plc:kx4mzvhqrjpwqmvlbdxdlbnn", handle: "dave.tngl.sh" } const core = { ownerHandle: "tangled.org", name: "core" }; const knot = { ownerHandle: "tangled.org", name: "knot" }; const knotHost = { did: "did:web:knot.tangled.sh", domain: "knot.tangled.sh" }; +const knotUrl = new URL(`https://${knotHost.domain}`); const subjectUri = (kind: "issue" | "pull", number: string) => `at://did:plc:mock/sh.tangled.repo.${kind}/${number}`; @@ -122,7 +123,8 @@ export const notifications: NotificationSummary[] = [ read: false, createdAt: ago(60 * 33), actor: alice, - knot: knotHost + knot: knotHost, + offer: { kind: "membership", knot: knotUrl, subject: "did:web:knot.tangled.sh" as DidRkey } }, { uri: `at://mock/notif/${17}`, @@ -131,7 +133,12 @@ export const notifications: NotificationSummary[] = [ read: false, createdAt: ago(60 * 36), actor: bob, - repo: core + repo: core, + offer: { + kind: "collaboration", + knot: knotUrl, + subject: "did:plc:2r7kkzr5xtnrxdi7fdcpbxem" as DidRkey + } }, { uri: `at://mock/notif/${8}`, diff --git a/web/src/lib/components/notifications/types.ts b/web/src/lib/components/notifications/types.ts index 555066dac..ca497ff46 100644 --- a/web/src/lib/components/notifications/types.ts +++ b/web/src/lib/components/notifications/types.ts @@ -1,3 +1,12 @@ +import { isDid, isRecordKey, type Did } from "@atcute/lexicons/syntax"; + +declare const didRkey: unique symbol; + +export type DidRkey = Did & { readonly [didRkey]: true }; + +export const parseDidRkey = (did: string | undefined): DidRkey | undefined => + did !== undefined && isDid(did) && isRecordKey(did) ? (did as DidRkey) : undefined; + // mirrors org.tangled.temp.notification.listNotifications#notification, plus the // repo and issue/pull details the appview page renders. the xrpc method only // hands back at-uris for those, so a real loader has to hydrate them from bobbin @@ -43,6 +52,12 @@ export interface NotificationKnot { domain: string; } +export interface NotificationOffer { + kind: "membership" | "collaboration"; + knot: URL; + subject: DidRkey; +} + export interface NotificationSubject { kind: "issue" | "pull"; uri: string; @@ -60,6 +75,7 @@ export interface NotificationSummary { repo?: NotificationRepo; knot?: NotificationKnot; subject?: NotificationSubject; + offer?: NotificationOffer; } export interface NotificationGroups { diff --git a/web/src/lib/oauth-client-metadata.json b/web/src/lib/oauth-client-metadata.json index 0d1dcf66a..9e321c354 100644 --- a/web/src/lib/oauth-client-metadata.json +++ b/web/src/lib/oauth-client-metadata.json @@ -3,7 +3,7 @@ "client_name": "Tangled", "client_uri": "https://tangled.org", "redirect_uris": ["https://tangled.org/oauth/callback"], - "scope": "atproto repo:sh.tangled.actor.profile repo:org.tangled.feed.subscription repo:sh.tangled.feed.comment repo:sh.tangled.feed.reaction repo:sh.tangled.feed.star repo:sh.tangled.graph.follow repo:sh.tangled.graph.vouch repo:sh.tangled.knot repo:sh.tangled.knot.member repo:sh.tangled.label.definition repo:sh.tangled.label.op repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.artifact repo:sh.tangled.repo.collaborator repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status repo:sh.tangled.spindle repo:sh.tangled.spindle.member repo:sh.tangled.string blob:*/* rpc:sh.tangled.graph.listNetworkVouches?aud=* rpc:sh.tangled.knot.addMember?aud=* rpc:sh.tangled.knot.removeMember?aud=* rpc:sh.tangled.ci.triggerPipeline?aud=* rpc:sh.tangled.ci.cancelPipeline?aud=* rpc:sh.tangled.repo.addCollaborator?aud=* rpc:sh.tangled.repo.addSecret?aud=* rpc:sh.tangled.repo.create?aud=* rpc:sh.tangled.repo.delete?aud=* rpc:sh.tangled.repo.deleteBranch?aud=* rpc:sh.tangled.repo.forkStatus?aud=* rpc:sh.tangled.repo.forkSync?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.listSecrets?aud=* rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.mergeCheck?aud=* rpc:sh.tangled.repo.removeCollaborator?aud=* rpc:sh.tangled.repo.removeSecret?aud=* rpc:sh.tangled.repo.setDefaultBranch?aud=* rpc:org.tangled.temp.notification.getPreferences?aud=* rpc:org.tangled.temp.notification.updatePreferences?aud=* rpc:org.tangled.temp.notification.getUnreadCount?aud=* rpc:org.tangled.temp.notification.listNotifications?aud=* rpc:org.tangled.temp.notification.markAllRead?aud=* rpc:org.tangled.temp.notification.markEntityRead?aud=* rpc:org.tangled.temp.notification.updateSeen?aud=* rpc:org.tangled.temp.account.listEmails?aud=* rpc:org.tangled.temp.account.deleteEmail?aud=* rpc:org.tangled.temp.account.setPrimaryEmail?aud=* rpc:org.tangled.temp.account.addEmail?aud=* rpc:org.tangled.temp.account.verifyEmail?aud=* rpc:org.tangled.temp.site.getDomainClaim?aud=* rpc:org.tangled.temp.site.claimDomain?aud=* rpc:org.tangled.temp.site.releaseDomain?aud=* rpc:org.tangled.temp.search.searchCode?aud=* rpc:sh.tangled.git.keepCommit?aud=* rpc:sh.tangled.git.mergeCommit?aud=* rpc:sh.tangled.actor.getTrending?aud=* rpc:sh.tangled.feed.getTimeline?aud=* rpc:com.atproto.moderation.createReport?aud=*", + "scope": "atproto repo:sh.tangled.actor.profile repo:org.tangled.feed.subscription repo:sh.tangled.feed.comment repo:sh.tangled.feed.reaction repo:sh.tangled.feed.star repo:sh.tangled.graph.follow repo:sh.tangled.graph.vouch repo:sh.tangled.knot repo:sh.tangled.knot.member repo:sh.tangled.knot.memberAcceptance repo:sh.tangled.label.definition repo:sh.tangled.label.op repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.artifact repo:sh.tangled.repo.collaborator repo:sh.tangled.repo.collaboratorAcceptance repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status repo:sh.tangled.spindle repo:sh.tangled.spindle.member repo:sh.tangled.string blob:*/* rpc:sh.tangled.graph.listNetworkVouches?aud=* rpc:sh.tangled.knot.acceptMembership?aud=* rpc:sh.tangled.knot.addMember?aud=* rpc:sh.tangled.knot.removeMember?aud=* rpc:sh.tangled.ci.triggerPipeline?aud=* rpc:sh.tangled.ci.cancelPipeline?aud=* rpc:sh.tangled.repo.acceptCollaboration?aud=* rpc:sh.tangled.repo.addCollaborator?aud=* rpc:sh.tangled.repo.addSecret?aud=* rpc:sh.tangled.repo.create?aud=* rpc:sh.tangled.repo.delete?aud=* rpc:sh.tangled.repo.deleteBranch?aud=* rpc:sh.tangled.repo.forkStatus?aud=* rpc:sh.tangled.repo.forkSync?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.listSecrets?aud=* rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.mergeCheck?aud=* rpc:sh.tangled.repo.removeCollaborator?aud=* rpc:sh.tangled.repo.removeSecret?aud=* rpc:sh.tangled.repo.setDefaultBranch?aud=* rpc:org.tangled.temp.notification.getPreferences?aud=* rpc:org.tangled.temp.notification.updatePreferences?aud=* rpc:org.tangled.temp.notification.getUnreadCount?aud=* rpc:org.tangled.temp.notification.listNotifications?aud=* rpc:org.tangled.temp.notification.markAllRead?aud=* rpc:org.tangled.temp.notification.markEntityRead?aud=* rpc:org.tangled.temp.notification.updateSeen?aud=* rpc:org.tangled.temp.account.listEmails?aud=* rpc:org.tangled.temp.account.deleteEmail?aud=* rpc:org.tangled.temp.account.setPrimaryEmail?aud=* rpc:org.tangled.temp.account.addEmail?aud=* rpc:org.tangled.temp.account.verifyEmail?aud=* rpc:org.tangled.temp.site.getDomainClaim?aud=* rpc:org.tangled.temp.site.claimDomain?aud=* rpc:org.tangled.temp.site.releaseDomain?aud=* rpc:org.tangled.temp.search.searchCode?aud=* rpc:sh.tangled.git.keepCommit?aud=* rpc:sh.tangled.git.mergeCommit?aud=* rpc:sh.tangled.actor.getTrending?aud=* rpc:sh.tangled.feed.getTimeline?aud=* rpc:com.atproto.moderation.createReport?aud=*", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none",