import * as v from "@atcute/lexicons/validations";
import type { Did, Handle } from "@atcute/lexicons/syntax";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import { buildUrl, toResponseError } from "#lib/api/_request.ts";
import { nsidOf, proxyPathOf, type XrpcPath } from "#lib/api/syntax.ts";
import type { QueryValue, XrpcRequestInit } from "#lib/api/client.ts";
export const CREATE_DELEGATE_NSID = nsidOf("farm.tranquil.delegation.createAccount");
export const LIST_DELEGATES_NSID = nsidOf("farm.tranquil.delegation.listControlledAccounts");
export const LIST_DELEGATES_ENDPOINT = proxyPathOf("_delegation.listControlledAccounts");
export const CREATE_DELEGATE_ENDPOINT = proxyPathOf("_delegation.createDelegatedAccount");
const delegatedAccountSchema = v.object({
did: v.didString(),
grantedAt: v.datetimeString(),
grantedScopes: v.string(),
handle: v.optional(v.handleString())
});
export const listDelegatesSchema = v.object({
get accounts() {
return v.array(delegatedAccountSchema);
}
});
export type DelegatedAccountInfo = v.InferInput<typeof delegatedAccountSchema>;
export interface CreateDelegatedAccountInput {
/** Full handle or local handle prefix. */
handle: string;
/** Scopes granted to the initial controller. */
controllerScopes: string;
email?: string;
}
export interface CreateDelegatedAccountOutput {
did: Did;
handle: Handle;
}
// agent.handle resolves a relative path against the session's PDS, so only
// the path and query of the built URL are kept; the origin is a placeholder.
const pathFor = (nsid: XrpcPath, params?: Record<string, QueryValue>): string => {
const url = buildUrl("http://pds.invalid", nsid, params);
return `${url.pathname}${url.search}`;
};
const tranquilGet = async <T>(
agent: OAuthUserAgent,
nsid: XrpcPath,
params?: Record<string, QueryValue>,
init?: XrpcRequestInit
): Promise<T> => {
const headers = new Headers(init?.headers);
if (!headers.has("accept")) headers.set("accept", "application/json");
const response = await agent.handle(pathFor(nsid, params), {
headers,
signal: init?.signal
});
if (!response.ok) throw await toResponseError(response);
return (await response.json()) as T;
};
const tranquilPost = async <T>(
agent: OAuthUserAgent,
nsid: XrpcPath,
body?: unknown,
init?: XrpcRequestInit
): Promise<T> => {
const headers = new Headers(init?.headers);
if (!headers.has("content-type")) headers.set("content-type", "application/json");
if (!headers.has("accept")) headers.set("accept", "application/json");
const response = await agent.handle(pathFor(nsid), {
method: "POST",
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: init?.signal
});
if (!response.ok) throw await toResponseError(response);
return (await response.json()) as T;
};
// ---- shared types, mirrored from tranquil-pds's delegation api ----
export type DelegationScope = string;
export interface ScopePreset {
name: string;
label: string;
description: string;
scopes: DelegationScope;
}
export interface ControllerInfo {
did: Did;
handle?: Handle;
grantedScopes: DelegationScope;
grantedAt: string;
isActive: boolean;
isLocal: boolean;
}
export type DelegationActionType =
| "GrantCreated"
| "GrantRevoked"
| "ScopesModified"
| "TokenIssued"
| "RepoWrite"
| "BlobUpload"
| "AccountAction";
export interface AuditLogEntry {
id: string;
delegatedDid: Did;
actorDid: Did;
controllerDid?: Did;
actionType: DelegationActionType;
actionDetails?: unknown;
createdAt: string;
}
export interface ResolvedIdentity {
did: Did;
handle?: Handle;
pdsUrl?: string;
isLocal: boolean;
}
// ---- controllers on *this* account ----
export const listControllers = (
agent: OAuthUserAgent,
init?: XrpcRequestInit
): Promise<ControllerInfo[]> =>
tranquilGet<{ controllers: ControllerInfo[] }>(
agent,
proxyPathOf("_delegation.listControllers"),
undefined,
init
).then((r) => r.controllers);
export const addController = (
agent: OAuthUserAgent,
input: { controllerDid: Did; grantedScopes: DelegationScope },
init?: XrpcRequestInit
): Promise<{ success: boolean }> =>
tranquilPost(
agent,
proxyPathOf("_delegation.addController"),
{ controller_did: input.controllerDid, granted_scopes: input.grantedScopes },
init
);
export const removeController = (
agent: OAuthUserAgent,
controllerDid: Did,
init?: XrpcRequestInit
): Promise<{ success: boolean }> =>
tranquilPost(
agent,
proxyPathOf("_delegation.removeController"),
{ controller_did: controllerDid },
init
);
export const updateControllerScopes = (
agent: OAuthUserAgent,
input: { controllerDid: Did; grantedScopes: DelegationScope },
init?: XrpcRequestInit
): Promise<{ success: boolean }> =>
tranquilPost(
agent,
proxyPathOf("_delegation.updateControllerScopes"),
{ controller_did: input.controllerDid, granted_scopes: input.grantedScopes },
init
);
// ---- accounts this account controls ----
export const listControlledAccounts = (
agent: OAuthUserAgent,
init?: XrpcRequestInit
): Promise<DelegatedAccountInfo[]> =>
tranquilGet<{ accounts: DelegatedAccountInfo[] }>(
agent,
LIST_DELEGATES_ENDPOINT,
undefined,
init
).then((r) => r.accounts);
export const createDelegatedAccount = (
agent: OAuthUserAgent,
input: CreateDelegatedAccountInput,
init?: XrpcRequestInit
): Promise<CreateDelegatedAccountOutput> =>
tranquilPost(agent, CREATE_DELEGATE_ENDPOINT, input, init);
// ---- audit log, scope presets, identity resolution ----
export const getAuditLog = (
agent: OAuthUserAgent,
params?: { limit?: number; offset?: number },
init?: XrpcRequestInit
): Promise<{ entries: AuditLogEntry[]; total: number }> =>
tranquilGet(
agent,
proxyPathOf("_delegation.getAuditLog"),
params as Record<string, QueryValue>,
init
);
export const getScopePresets = (
agent: OAuthUserAgent,
init?: XrpcRequestInit
): Promise<ScopePreset[]> =>
tranquilGet<{ presets: ScopePreset[] }>(
agent,
proxyPathOf("_delegation.getScopePresets"),
undefined,
init
).then((r) => r.presets);
export const resolveController = (
agent: OAuthUserAgent,
identifier: string,
init?: XrpcRequestInit
): Promise<ResolvedIdentity> =>
tranquilGet(agent, proxyPathOf("_delegation.resolveController"), { identifier }, init);