fix(security): harden tlon Urbit requests against SSRF

This commit is contained in:
Peter Steinberger
2026-02-14 18:41:23 +01:00
parent 5a313c83b7
commit bfa7d21e99
18 changed files with 735 additions and 191 deletions

View File

@@ -0,0 +1,42 @@
import { SsrFBlockedError } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { authenticate } from "./auth.js";
describe("tlon urbit auth ssrf", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("blocks private IPs by default", async () => {
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
await expect(authenticate("http://127.0.0.1:8080", "code")).rejects.toBeInstanceOf(
SsrFBlockedError,
);
expect(mockFetch).not.toHaveBeenCalled();
});
it("allows private IPs when allowPrivateNetwork is enabled", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => "ok",
headers: new Headers({
"set-cookie": "urbauth-~zod=123; Path=/; HttpOnly",
}),
});
vi.stubGlobal("fetch", mockFetch);
const cookie = await authenticate("http://127.0.0.1:8080", "code", {
ssrfPolicy: { allowPrivateNetwork: true },
lookupFn: async () => [{ address: "127.0.0.1", family: 4 }],
});
expect(cookie).toContain("urbauth-~zod=123");
expect(mockFetch).toHaveBeenCalled();
});
});

View File

@@ -1,18 +1,47 @@
export async function authenticate(url: string, code: string): Promise<string> {
const resp = await fetch(`${url}/~/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `password=${code}`,
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { urbitFetch } from "./fetch.js";
export type UrbitAuthenticateOptions = {
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
timeoutMs?: number;
};
export async function authenticate(
url: string,
code: string,
options: UrbitAuthenticateOptions = {},
): Promise<string> {
const { response, release } = await urbitFetch({
baseUrl: url,
path: "/~/login",
init: {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ password: code }).toString(),
},
ssrfPolicy: options.ssrfPolicy,
lookupFn: options.lookupFn,
fetchImpl: options.fetchImpl,
timeoutMs: options.timeoutMs ?? 15_000,
maxRedirects: 3,
auditContext: "tlon-urbit-login",
});
if (!resp.ok) {
throw new Error(`Login failed with status ${resp.status}`);
}
try {
if (!response.ok) {
throw new Error(`Login failed with status ${response.status}`);
}
await resp.text();
const cookie = resp.headers.get("set-cookie");
if (!cookie) {
throw new Error("No authentication cookie received");
// Some Urbit setups require the response body to be read before cookie headers finalize.
await response.text().catch(() => {});
const cookie = response.headers.get("set-cookie");
if (!cookie) {
throw new Error("No authentication cookie received");
}
return cookie;
} finally {
await release();
}
return cookie;
}

View File

@@ -0,0 +1,49 @@
import { isBlockedHostname, isPrivateIpAddress } from "openclaw/plugin-sdk";
export type UrbitBaseUrlValidation =
| { ok: true; baseUrl: string; hostname: string }
| { ok: false; error: string };
function hasScheme(value: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value);
}
export function validateUrbitBaseUrl(raw: string): UrbitBaseUrlValidation {
const trimmed = String(raw ?? "").trim();
if (!trimmed) {
return { ok: false, error: "Required" };
}
const candidate = hasScheme(trimmed) ? trimmed : `https://${trimmed}`;
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return { ok: false, error: "Invalid URL" };
}
if (!["http:", "https:"].includes(parsed.protocol)) {
return { ok: false, error: "URL must use http:// or https://" };
}
if (parsed.username || parsed.password) {
return { ok: false, error: "URL must not include credentials" };
}
const hostname = parsed.hostname.trim().toLowerCase().replace(/\.$/, "");
if (!hostname) {
return { ok: false, error: "Invalid hostname" };
}
// Normalize to origin so callers can't smuggle paths/query fragments into the base URL.
return { ok: true, baseUrl: parsed.origin, hostname };
}
export function isBlockedUrbitHostname(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
if (!normalized) {
return false;
}
return isBlockedHostname(normalized) || isPrivateIpAddress(normalized);
}

View File

@@ -0,0 +1,248 @@
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { validateUrbitBaseUrl } from "./base-url.js";
import { urbitFetch } from "./fetch.js";
export type UrbitChannelClientOptions = {
ship?: string;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
};
export class UrbitChannelClient {
readonly baseUrl: string;
readonly cookie: string;
readonly ship: string;
readonly ssrfPolicy?: SsrFPolicy;
readonly lookupFn?: LookupFn;
readonly fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
private channelId: string | null = null;
constructor(url: string, cookie: string, options: UrbitChannelClientOptions = {}) {
const validated = validateUrbitBaseUrl(url);
if (!validated.ok) {
throw new Error(validated.error);
}
this.baseUrl = validated.baseUrl;
this.cookie = cookie.split(";")[0];
this.ship = (
options.ship?.replace(/^~/, "") ?? this.resolveShipFromHostname(validated.hostname)
).trim();
this.ssrfPolicy = options.ssrfPolicy;
this.lookupFn = options.lookupFn;
this.fetchImpl = options.fetchImpl;
}
private resolveShipFromHostname(hostname: string): string {
if (hostname.includes(".")) {
return hostname.split(".")[0] ?? hostname;
}
return hostname;
}
private get channelPath(): string {
const id = this.channelId;
if (!id) {
throw new Error("Channel not opened");
}
return `/~/channel/${id}`;
}
async open(): Promise<void> {
if (this.channelId) {
return;
}
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
// Create the channel.
{
const { response, release } = await urbitFetch({
baseUrl: this.baseUrl,
path: this.channelPath,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([]),
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-channel-open",
});
try {
if (!response.ok && response.status !== 204) {
throw new Error(`Channel creation failed: ${response.status}`);
}
} finally {
await release();
}
}
// Wake the channel (matches urbit/http-api behavior).
{
const { response, release } = await urbitFetch({
baseUrl: this.baseUrl,
path: this.channelPath,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([
{
id: Date.now(),
action: "poke",
ship: this.ship,
app: "hood",
mark: "helm-hi",
json: "Opening API channel",
},
]),
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-channel-wake",
});
try {
if (!response.ok && response.status !== 204) {
throw new Error(`Channel activation failed: ${response.status}`);
}
} finally {
await release();
}
}
}
async poke(params: { app: string; mark: string; json: unknown }): Promise<number> {
await this.open();
const pokeId = Date.now();
const pokeData = {
id: pokeId,
action: "poke",
ship: this.ship,
app: params.app,
mark: params.mark,
json: params.json,
};
const { response, release } = await urbitFetch({
baseUrl: this.baseUrl,
path: this.channelPath,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([pokeData]),
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-poke",
});
try {
if (!response.ok && response.status !== 204) {
const errorText = await response.text().catch(() => "");
throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
}
return pokeId;
} finally {
await release();
}
}
async scry(path: string): Promise<unknown> {
const scryPath = `/~/scry${path}`;
const { response, release } = await urbitFetch({
baseUrl: this.baseUrl,
path: scryPath,
init: {
method: "GET",
headers: { Cookie: this.cookie },
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-scry",
});
try {
if (!response.ok) {
throw new Error(`Scry failed: ${response.status} for path ${path}`);
}
return await response.json();
} finally {
await release();
}
}
async getOurName(): Promise<string> {
const { response, release } = await urbitFetch({
baseUrl: this.baseUrl,
path: "/~/name",
init: {
method: "GET",
headers: { Cookie: this.cookie },
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-name",
});
try {
if (!response.ok) {
throw new Error(`Name request failed: ${response.status}`);
}
const text = await response.text();
return text.trim();
} finally {
await release();
}
}
async close(): Promise<void> {
if (!this.channelId) {
return;
}
const channelPath = this.channelPath;
this.channelId = null;
try {
const { response, release } = await urbitFetch({
baseUrl: this.baseUrl,
path: channelPath,
init: { method: "DELETE", headers: { Cookie: this.cookie } },
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-channel-close",
});
try {
void response.body?.cancel();
} finally {
await release();
}
} catch {
// ignore cleanup errors
}
}
}

View File

@@ -0,0 +1,38 @@
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
import { validateUrbitBaseUrl } from "./base-url.js";
export type UrbitFetchOptions = {
baseUrl: string;
path: string;
init?: RequestInit;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
timeoutMs?: number;
maxRedirects?: number;
signal?: AbortSignal;
auditContext?: string;
pinDns?: boolean;
};
export async function urbitFetch(params: UrbitFetchOptions) {
const validated = validateUrbitBaseUrl(params.baseUrl);
if (!validated.ok) {
throw new Error(validated.error);
}
const url = new URL(params.path, validated.baseUrl).toString();
return await fetchWithSsrFGuard({
url,
fetchImpl: params.fetchImpl,
init: params.init,
timeoutMs: params.timeoutMs,
maxRedirects: params.maxRedirects,
signal: params.signal,
policy: params.ssrfPolicy,
lookupFn: params.lookupFn,
auditContext: params.auditContext,
pinDns: params.pinDns,
});
}

View File

@@ -1,38 +0,0 @@
import { Urbit } from "@urbit/http-api";
let patched = false;
export function ensureUrbitConnectPatched() {
if (patched) {
return;
}
patched = true;
Urbit.prototype.connect = async function patchedConnect() {
const resp = await fetch(`${this.url}/~/login`, {
method: "POST",
body: `password=${this.code}`,
credentials: "include",
});
if (resp.status >= 400) {
throw new Error(`Login failed with status ${resp.status}`);
}
const cookie = resp.headers.get("set-cookie");
if (cookie) {
const match = /urbauth-~([\w-]+)/.exec(cookie);
if (match) {
if (!(this as unknown as { ship?: string | null }).ship) {
(this as unknown as { ship?: string | null }).ship = match[1];
}
(this as unknown as { nodeId?: string }).nodeId = match[1];
}
(this as unknown as { cookie?: string }).cookie = cookie;
}
await (this as typeof Urbit.prototype).getShipName();
await (this as typeof Urbit.prototype).getOurName();
};
}
export { Urbit };

View File

@@ -16,7 +16,9 @@ describe("UrbitSSEClient", () => {
it("sends subscriptions added after connect", async () => {
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => "" });
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
lookupFn: async () => [{ address: "1.1.1.1", family: 4 }],
});
(client as { isConnected: boolean }).isConnected = true;
await client.subscribe({

View File

@@ -1,4 +1,7 @@
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { Readable } from "node:stream";
import { validateUrbitBaseUrl } from "./base-url.js";
import { urbitFetch } from "./fetch.js";
export type UrbitSseLogger = {
log?: (message: string) => void;
@@ -7,6 +10,9 @@ export type UrbitSseLogger = {
type UrbitSseOptions = {
ship?: string;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
onReconnect?: (client: UrbitSSEClient) => Promise<void> | void;
autoReconnect?: boolean;
maxReconnectAttempts?: number;
@@ -42,32 +48,38 @@ export class UrbitSSEClient {
maxReconnectDelay: number;
isConnected = false;
logger: UrbitSseLogger;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
streamRelease: (() => Promise<void>) | null = null;
constructor(url: string, cookie: string, options: UrbitSseOptions = {}) {
this.url = url;
const validated = validateUrbitBaseUrl(url);
if (!validated.ok) {
throw new Error(validated.error);
}
this.url = validated.baseUrl;
this.cookie = cookie.split(";")[0];
this.ship = options.ship?.replace(/^~/, "") ?? this.resolveShipFromUrl(url);
this.ship = options.ship?.replace(/^~/, "") ?? this.resolveShipFromHostname(validated.hostname);
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
this.channelUrl = `${url}/~/channel/${this.channelId}`;
this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString();
this.onReconnect = options.onReconnect ?? null;
this.autoReconnect = options.autoReconnect !== false;
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
this.reconnectDelay = options.reconnectDelay ?? 1000;
this.maxReconnectDelay = options.maxReconnectDelay ?? 30000;
this.logger = options.logger ?? {};
this.ssrfPolicy = options.ssrfPolicy;
this.lookupFn = options.lookupFn;
this.fetchImpl = options.fetchImpl;
}
private resolveShipFromUrl(url: string): string {
try {
const parsed = new URL(url);
const host = parsed.hostname;
if (host.includes(".")) {
return host.split(".")[0] ?? host;
}
return host;
} catch {
return "";
private resolveShipFromHostname(hostname: string): string {
if (hostname.includes(".")) {
return hostname.split(".")[0] ?? hostname;
}
return hostname;
}
async subscribe(params: {
@@ -107,58 +119,100 @@ export class UrbitSSEClient {
app: string;
path: string;
}) {
const response = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([subscription]),
},
body: JSON.stringify([subscription]),
signal: AbortSignal.timeout(30_000),
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-subscribe",
});
if (!response.ok && response.status !== 204) {
const errorText = await response.text();
throw new Error(`Subscribe failed: ${response.status} - ${errorText}`);
try {
if (!response.ok && response.status !== 204) {
const errorText = await response.text().catch(() => "");
throw new Error(
`Subscribe failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`,
);
}
} finally {
await release();
}
}
async connect() {
const createResp = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify(this.subscriptions),
signal: AbortSignal.timeout(30_000),
});
{
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify(this.subscriptions),
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-channel-create",
});
if (!createResp.ok && createResp.status !== 204) {
throw new Error(`Channel creation failed: ${createResp.status}`);
try {
if (!response.ok && response.status !== 204) {
throw new Error(`Channel creation failed: ${response.status}`);
}
} finally {
await release();
}
}
const pokeResp = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([
{
id: Date.now(),
action: "poke",
ship: this.ship,
app: "hood",
mark: "helm-hi",
json: "Opening API channel",
{
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([
{
id: Date.now(),
action: "poke",
ship: this.ship,
app: "hood",
mark: "helm-hi",
json: "Opening API channel",
},
]),
},
]),
signal: AbortSignal.timeout(30_000),
});
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-channel-wake",
});
if (!pokeResp.ok && pokeResp.status !== 204) {
throw new Error(`Channel activation failed: ${pokeResp.status}`);
try {
if (!response.ok && response.status !== 204) {
throw new Error(`Channel activation failed: ${response.status}`);
}
} finally {
await release();
}
}
await this.openStream();
@@ -172,19 +226,33 @@ export class UrbitSSEClient {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60_000);
const response = await fetch(this.channelUrl, {
method: "GET",
headers: {
Accept: "text/event-stream",
Cookie: this.cookie,
this.streamController = controller;
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "GET",
headers: {
Accept: "text/event-stream",
Cookie: this.cookie,
},
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
signal: controller.signal,
auditContext: "tlon-urbit-sse-stream",
});
// Clear timeout once connection established (headers received)
this.streamRelease = release;
// Clear timeout once connection established (headers received).
clearTimeout(timeoutId);
if (!response.ok) {
await release();
this.streamRelease = null;
throw new Error(`Stream connection failed: ${response.status}`);
}
@@ -222,6 +290,12 @@ export class UrbitSSEClient {
}
}
} finally {
if (this.streamRelease) {
const release = this.streamRelease;
this.streamRelease = null;
await release();
}
this.streamController = null;
if (!this.aborted && this.autoReconnect) {
this.isConnected = false;
this.logger.log?.("[SSE] Stream ended, attempting reconnection...");
@@ -285,39 +359,61 @@ export class UrbitSSEClient {
json: params.json,
};
const response = await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([pokeData]),
},
body: JSON.stringify([pokeData]),
signal: AbortSignal.timeout(30_000),
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-poke",
});
if (!response.ok && response.status !== 204) {
const errorText = await response.text();
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
try {
if (!response.ok && response.status !== 204) {
const errorText = await response.text().catch(() => "");
throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
}
} finally {
await release();
}
return pokeId;
}
async scry(path: string) {
const scryUrl = `${this.url}/~/scry${path}`;
const response = await fetch(scryUrl, {
method: "GET",
headers: {
Cookie: this.cookie,
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/scry${path}`,
init: {
method: "GET",
headers: {
Cookie: this.cookie,
},
},
signal: AbortSignal.timeout(30_000),
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-scry",
});
if (!response.ok) {
throw new Error(`Scry failed: ${response.status} for path ${path}`);
try {
if (!response.ok) {
throw new Error(`Scry failed: ${response.status} for path ${path}`);
}
return await response.json();
} finally {
await release();
}
return await response.json();
}
async attemptReconnect() {
@@ -347,7 +443,7 @@ export class UrbitSSEClient {
try {
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
this.channelUrl = `${this.url}/~/channel/${this.channelId}`;
this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString();
if (this.onReconnect) {
await this.onReconnect(this);
@@ -364,6 +460,7 @@ export class UrbitSSEClient {
async close() {
this.aborted = true;
this.isConnected = false;
this.streamController?.abort();
try {
const unsubscribes = this.subscriptions.map((sub) => ({
@@ -372,25 +469,61 @@ export class UrbitSSEClient {
subscription: sub.id,
}));
await fetch(this.channelUrl, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify(unsubscribes),
signal: AbortSignal.timeout(30_000),
});
{
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify(unsubscribes),
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-unsubscribe",
});
try {
void response.body?.cancel();
} finally {
await release();
}
}
await fetch(this.channelUrl, {
method: "DELETE",
headers: {
Cookie: this.cookie,
},
signal: AbortSignal.timeout(30_000),
});
{
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "DELETE",
headers: {
Cookie: this.cookie,
},
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-channel-close",
});
try {
void response.body?.cancel();
} finally {
await release();
}
}
} catch (error) {
this.logger.error?.(`Error closing channel: ${String(error)}`);
}
if (this.streamRelease) {
const release = this.streamRelease;
this.streamRelease = null;
await release();
}
}
}