diff --git a/extensions/tlon/src/security.test.ts b/extensions/tlon/src/security.test.ts
new file mode 100644
index 00000000000..57194c12cc7
--- /dev/null
+++ b/extensions/tlon/src/security.test.ts
@@ -0,0 +1,436 @@
+/**
+ * Security Tests for Tlon Plugin
+ *
+ * These tests ensure that security-critical behavior cannot regress:
+ * - DM allowlist enforcement
+ * - Channel authorization rules
+ * - Ship normalization consistency
+ * - Bot mention detection boundaries
+ */
+
+import { describe, expect, it } from "vitest";
+import {
+ isDmAllowed,
+ isGroupInviteAllowed,
+ isBotMentioned,
+ extractMessageText,
+} from "./monitor/utils.js";
+import { normalizeShip } from "./targets.js";
+
+describe("Security: DM Allowlist", () => {
+ describe("isDmAllowed", () => {
+ it("rejects DMs when allowlist is empty", () => {
+ expect(isDmAllowed("~zod", [])).toBe(false);
+ expect(isDmAllowed("~sampel-palnet", [])).toBe(false);
+ });
+
+ it("rejects DMs when allowlist is undefined", () => {
+ expect(isDmAllowed("~zod", undefined)).toBe(false);
+ });
+
+ it("allows DMs from ships on the allowlist", () => {
+ const allowlist = ["~zod", "~bus"];
+ expect(isDmAllowed("~zod", allowlist)).toBe(true);
+ expect(isDmAllowed("~bus", allowlist)).toBe(true);
+ });
+
+ it("rejects DMs from ships NOT on the allowlist", () => {
+ const allowlist = ["~zod", "~bus"];
+ expect(isDmAllowed("~nec", allowlist)).toBe(false);
+ expect(isDmAllowed("~sampel-palnet", allowlist)).toBe(false);
+ expect(isDmAllowed("~random-ship", allowlist)).toBe(false);
+ });
+
+ it("normalizes ship names (with/without ~ prefix)", () => {
+ const allowlist = ["~zod"];
+ expect(isDmAllowed("zod", allowlist)).toBe(true);
+ expect(isDmAllowed("~zod", allowlist)).toBe(true);
+
+ const allowlistWithoutTilde = ["zod"];
+ expect(isDmAllowed("~zod", allowlistWithoutTilde)).toBe(true);
+ expect(isDmAllowed("zod", allowlistWithoutTilde)).toBe(true);
+ });
+
+ it("handles galaxy, star, planet, and moon names", () => {
+ const allowlist = [
+ "~zod", // galaxy
+ "~marzod", // star
+ "~sampel-palnet", // planet
+ "~dozzod-dozzod-dozzod-dozzod", // moon
+ ];
+
+ expect(isDmAllowed("~zod", allowlist)).toBe(true);
+ expect(isDmAllowed("~marzod", allowlist)).toBe(true);
+ expect(isDmAllowed("~sampel-palnet", allowlist)).toBe(true);
+ expect(isDmAllowed("~dozzod-dozzod-dozzod-dozzod", allowlist)).toBe(true);
+
+ // Similar but different ships should be rejected
+ expect(isDmAllowed("~nec", allowlist)).toBe(false);
+ expect(isDmAllowed("~wanzod", allowlist)).toBe(false);
+ expect(isDmAllowed("~sampel-palned", allowlist)).toBe(false);
+ });
+
+ // NOTE: Ship names in Urbit are always lowercase by convention.
+ // This test documents current behavior - strict equality after normalization.
+ // If case-insensitivity is desired, normalizeShip should lowercase.
+ it("uses strict equality after normalization (case-sensitive)", () => {
+ const allowlist = ["~zod"];
+ expect(isDmAllowed("~zod", allowlist)).toBe(true);
+ // Different case would NOT match with current implementation
+ expect(isDmAllowed("~Zod", ["~Zod"])).toBe(true); // exact match works
+ });
+
+ it("does not allow partial matches", () => {
+ const allowlist = ["~zod"];
+ expect(isDmAllowed("~zod-extra", allowlist)).toBe(false);
+ expect(isDmAllowed("~extra-zod", allowlist)).toBe(false);
+ });
+
+ it("handles whitespace in ship names (normalized)", () => {
+ // Ships with leading/trailing whitespace are normalized by normalizeShip
+ const allowlist = [" ~zod ", "~bus"];
+ expect(isDmAllowed("~zod", allowlist)).toBe(true);
+ expect(isDmAllowed(" ~zod ", allowlist)).toBe(true);
+ });
+ });
+});
+
+describe("Security: Group Invite Allowlist", () => {
+ describe("isGroupInviteAllowed", () => {
+ it("rejects invites when allowlist is empty (fail-safe)", () => {
+ // CRITICAL: Empty allowlist must DENY, not accept-all
+ expect(isGroupInviteAllowed("~zod", [])).toBe(false);
+ expect(isGroupInviteAllowed("~sampel-palnet", [])).toBe(false);
+ expect(isGroupInviteAllowed("~malicious-actor", [])).toBe(false);
+ });
+
+ it("rejects invites when allowlist is undefined (fail-safe)", () => {
+ // CRITICAL: Undefined allowlist must DENY, not accept-all
+ expect(isGroupInviteAllowed("~zod", undefined)).toBe(false);
+ expect(isGroupInviteAllowed("~sampel-palnet", undefined)).toBe(false);
+ });
+
+ it("accepts invites from ships on the allowlist", () => {
+ const allowlist = ["~nocsyx-lassul", "~malmur-halmex"];
+ expect(isGroupInviteAllowed("~nocsyx-lassul", allowlist)).toBe(true);
+ expect(isGroupInviteAllowed("~malmur-halmex", allowlist)).toBe(true);
+ });
+
+ it("rejects invites from ships NOT on the allowlist", () => {
+ const allowlist = ["~nocsyx-lassul", "~malmur-halmex"];
+ expect(isGroupInviteAllowed("~random-attacker", allowlist)).toBe(false);
+ expect(isGroupInviteAllowed("~malicious-ship", allowlist)).toBe(false);
+ expect(isGroupInviteAllowed("~zod", allowlist)).toBe(false);
+ });
+
+ it("normalizes ship names (with/without ~ prefix)", () => {
+ const allowlist = ["~nocsyx-lassul"];
+ expect(isGroupInviteAllowed("nocsyx-lassul", allowlist)).toBe(true);
+ expect(isGroupInviteAllowed("~nocsyx-lassul", allowlist)).toBe(true);
+
+ const allowlistWithoutTilde = ["nocsyx-lassul"];
+ expect(isGroupInviteAllowed("~nocsyx-lassul", allowlistWithoutTilde)).toBe(true);
+ });
+
+ it("does not allow partial matches", () => {
+ const allowlist = ["~zod"];
+ expect(isGroupInviteAllowed("~zod-moon", allowlist)).toBe(false);
+ expect(isGroupInviteAllowed("~pinser-botter-zod", allowlist)).toBe(false);
+ });
+
+ it("handles whitespace in allowlist entries", () => {
+ const allowlist = [" ~nocsyx-lassul ", "~malmur-halmex"];
+ expect(isGroupInviteAllowed("~nocsyx-lassul", allowlist)).toBe(true);
+ });
+ });
+});
+
+describe("Security: Bot Mention Detection", () => {
+ describe("isBotMentioned", () => {
+ const botShip = "~sampel-palnet";
+ const nickname = "nimbus";
+
+ it("detects direct ship mention", () => {
+ expect(isBotMentioned("hey ~sampel-palnet", botShip)).toBe(true);
+ expect(isBotMentioned("~sampel-palnet can you help?", botShip)).toBe(true);
+ expect(isBotMentioned("hello ~sampel-palnet how are you", botShip)).toBe(true);
+ });
+
+ it("detects @all mention", () => {
+ expect(isBotMentioned("@all please respond", botShip)).toBe(true);
+ expect(isBotMentioned("hey @all", botShip)).toBe(true);
+ expect(isBotMentioned("@ALL uppercase", botShip)).toBe(true);
+ });
+
+ it("detects nickname mention", () => {
+ expect(isBotMentioned("hey nimbus", botShip, nickname)).toBe(true);
+ expect(isBotMentioned("nimbus help me", botShip, nickname)).toBe(true);
+ expect(isBotMentioned("hello NIMBUS", botShip, nickname)).toBe(true);
+ });
+
+ it("does NOT trigger on random messages", () => {
+ expect(isBotMentioned("hello world", botShip)).toBe(false);
+ expect(isBotMentioned("this is a normal message", botShip)).toBe(false);
+ expect(isBotMentioned("hey everyone", botShip)).toBe(false);
+ });
+
+ it("does NOT trigger on partial ship matches", () => {
+ expect(isBotMentioned("~sampel-palnet-extra", botShip)).toBe(false);
+ expect(isBotMentioned("my~sampel-palnetfriend", botShip)).toBe(false);
+ });
+
+ it("does NOT trigger on substring nickname matches", () => {
+ // "nimbus" should not match "nimbusy" or "animbust"
+ expect(isBotMentioned("nimbusy", botShip, nickname)).toBe(false);
+ expect(isBotMentioned("prenimbus", botShip, nickname)).toBe(false);
+ });
+
+ it("handles empty/null inputs safely", () => {
+ expect(isBotMentioned("", botShip)).toBe(false);
+ expect(isBotMentioned("test", "")).toBe(false);
+ // @ts-expect-error testing null input
+ expect(isBotMentioned(null, botShip)).toBe(false);
+ });
+
+ it("requires word boundary for nickname", () => {
+ expect(isBotMentioned("nimbus, hello", botShip, nickname)).toBe(true);
+ expect(isBotMentioned("hello nimbus!", botShip, nickname)).toBe(true);
+ expect(isBotMentioned("nimbus?", botShip, nickname)).toBe(true);
+ });
+ });
+});
+
+describe("Security: Ship Normalization", () => {
+ describe("normalizeShip", () => {
+ it("adds ~ prefix if missing", () => {
+ expect(normalizeShip("zod")).toBe("~zod");
+ expect(normalizeShip("sampel-palnet")).toBe("~sampel-palnet");
+ });
+
+ it("preserves ~ prefix if present", () => {
+ expect(normalizeShip("~zod")).toBe("~zod");
+ expect(normalizeShip("~sampel-palnet")).toBe("~sampel-palnet");
+ });
+
+ it("trims whitespace", () => {
+ expect(normalizeShip(" ~zod ")).toBe("~zod");
+ expect(normalizeShip(" zod ")).toBe("~zod");
+ });
+
+ it("handles empty string", () => {
+ expect(normalizeShip("")).toBe("");
+ expect(normalizeShip(" ")).toBe("");
+ });
+ });
+});
+
+describe("Security: Message Text Extraction", () => {
+ describe("extractMessageText", () => {
+ it("extracts plain text", () => {
+ const content = [{ inline: ["hello world"] }];
+ expect(extractMessageText(content)).toBe("hello world");
+ });
+
+ it("extracts @all mentions from sect null", () => {
+ const content = [{ inline: [{ sect: null }] }];
+ expect(extractMessageText(content)).toContain("@all");
+ });
+
+ it("extracts ship mentions", () => {
+ const content = [{ inline: [{ ship: "~zod" }] }];
+ expect(extractMessageText(content)).toContain("~zod");
+ });
+
+ it("handles malformed input safely", () => {
+ expect(extractMessageText(null)).toBe("");
+ expect(extractMessageText(undefined)).toBe("");
+ expect(extractMessageText([])).toBe("");
+ expect(extractMessageText([{}])).toBe("");
+ expect(extractMessageText("not an array")).toBe("");
+ });
+
+ it("does not execute injected code in inline content", () => {
+ // Ensure malicious content doesn't get executed
+ const maliciousContent = [{ inline: [""] }];
+ const result = extractMessageText(maliciousContent);
+ expect(result).toBe("");
+ // Just a string, not executed
+ });
+ });
+});
+
+describe("Security: Channel Authorization Logic", () => {
+ /**
+ * These tests document the expected behavior of channel authorization.
+ * The actual resolveChannelAuthorization function is internal to monitor/index.ts
+ * but these tests verify the building blocks and expected invariants.
+ */
+
+ it("default mode should be restricted (not open)", () => {
+ // This is a critical security invariant: if no mode is specified,
+ // channels should default to RESTRICTED, not open.
+ // If this test fails, someone may have changed the default unsafely.
+
+ // The logic in resolveChannelAuthorization is:
+ // const mode = rule?.mode ?? "restricted";
+ // We verify this by checking undefined rule gives restricted
+ const rule: { mode?: "restricted" | "open" } | undefined = undefined;
+ const mode = rule?.mode ?? "restricted";
+ expect(mode).toBe("restricted");
+ });
+
+ it("empty allowedShips with restricted mode should block all", () => {
+ // If a channel is restricted but has no allowed ships,
+ // no one should be able to send messages
+ const _mode = "restricted";
+ const allowedShips: string[] = [];
+ const sender = "~random-ship";
+
+ const isAllowed = allowedShips.some((ship) => normalizeShip(ship) === normalizeShip(sender));
+ expect(isAllowed).toBe(false);
+ });
+
+ it("open mode should not check allowedShips", () => {
+ // In open mode, any ship can send regardless of allowedShips
+ const mode = "open";
+ // The check in monitor/index.ts is:
+ // if (mode === "restricted") { /* check ships */ }
+ // So open mode skips the ship check entirely
+ expect(mode === "restricted").toBe(false);
+ });
+
+ it("settings should override file config for channel rules", () => {
+ // Documented behavior: settingsRules[nest] ?? fileRules[nest]
+ // This means settings take precedence
+ const fileRules = { "chat/~zod/test": { mode: "restricted" as const } };
+ const settingsRules = { "chat/~zod/test": { mode: "open" as const } };
+ const nest = "chat/~zod/test";
+
+ const effectiveRule = settingsRules[nest] ?? fileRules[nest];
+ expect(effectiveRule?.mode).toBe("open"); // settings wins
+ });
+});
+
+describe("Security: Authorization Edge Cases", () => {
+ it("empty strings are not valid ships", () => {
+ expect(isDmAllowed("", ["~zod"])).toBe(false);
+ expect(isDmAllowed("~zod", [""])).toBe(false);
+ });
+
+ it("handles very long ship-like strings", () => {
+ const longName = "~" + "a".repeat(1000);
+ expect(isDmAllowed(longName, ["~zod"])).toBe(false);
+ });
+
+ it("handles special characters that could break regex", () => {
+ // These should not cause regex injection
+ const maliciousShip = "~zod.*";
+ expect(isDmAllowed("~zodabc", [maliciousShip])).toBe(false);
+
+ const allowlist = ["~zod"];
+ expect(isDmAllowed("~zod.*", allowlist)).toBe(false);
+ });
+
+ it("protects against prototype pollution-style keys", () => {
+ const suspiciousShip = "__proto__";
+ expect(isDmAllowed(suspiciousShip, ["~zod"])).toBe(false);
+ expect(isDmAllowed("~zod", [suspiciousShip])).toBe(false);
+ });
+});
+
+describe("Security: Sender Role Identification", () => {
+ /**
+ * Tests for sender role identification (owner vs user).
+ * This prevents impersonation attacks where an approved user
+ * tries to claim owner privileges through prompt injection.
+ *
+ * SECURITY.md Section 9: Sender Role Identification
+ */
+
+ // Helper to compute sender role (mirrors logic in monitor/index.ts)
+ function getSenderRole(senderShip: string, ownerShip: string | null): "owner" | "user" {
+ if (!ownerShip) return "user";
+ return normalizeShip(senderShip) === normalizeShip(ownerShip) ? "owner" : "user";
+ }
+
+ describe("owner detection", () => {
+ it("identifies owner when ownerShip matches sender", () => {
+ expect(getSenderRole("~nocsyx-lassul", "~nocsyx-lassul")).toBe("owner");
+ expect(getSenderRole("nocsyx-lassul", "~nocsyx-lassul")).toBe("owner");
+ expect(getSenderRole("~nocsyx-lassul", "nocsyx-lassul")).toBe("owner");
+ });
+
+ it("identifies user when ownerShip does not match sender", () => {
+ expect(getSenderRole("~random-user", "~nocsyx-lassul")).toBe("user");
+ expect(getSenderRole("~malicious-actor", "~nocsyx-lassul")).toBe("user");
+ });
+
+ it("identifies everyone as user when ownerShip is null", () => {
+ expect(getSenderRole("~nocsyx-lassul", null)).toBe("user");
+ expect(getSenderRole("~zod", null)).toBe("user");
+ });
+
+ it("identifies everyone as user when ownerShip is empty string", () => {
+ // Empty string should be treated like null (no owner configured)
+ expect(getSenderRole("~nocsyx-lassul", "")).toBe("user");
+ });
+ });
+
+ describe("label format", () => {
+ // Helper to compute fromLabel (mirrors logic in monitor/index.ts)
+ function getFromLabel(
+ senderShip: string,
+ ownerShip: string | null,
+ isGroup: boolean,
+ channelNest?: string,
+ ): string {
+ const senderRole = getSenderRole(senderShip, ownerShip);
+ return isGroup
+ ? `${senderShip} [${senderRole}] in ${channelNest}`
+ : `${senderShip} [${senderRole}]`;
+ }
+
+ it("DM from owner includes [owner] in label", () => {
+ const label = getFromLabel("~nocsyx-lassul", "~nocsyx-lassul", false);
+ expect(label).toBe("~nocsyx-lassul [owner]");
+ expect(label).toContain("[owner]");
+ });
+
+ it("DM from user includes [user] in label", () => {
+ const label = getFromLabel("~random-user", "~nocsyx-lassul", false);
+ expect(label).toBe("~random-user [user]");
+ expect(label).toContain("[user]");
+ });
+
+ it("group message from owner includes [owner] in label", () => {
+ const label = getFromLabel("~nocsyx-lassul", "~nocsyx-lassul", true, "chat/~host/general");
+ expect(label).toBe("~nocsyx-lassul [owner] in chat/~host/general");
+ expect(label).toContain("[owner]");
+ });
+
+ it("group message from user includes [user] in label", () => {
+ const label = getFromLabel("~random-user", "~nocsyx-lassul", true, "chat/~host/general");
+ expect(label).toBe("~random-user [user] in chat/~host/general");
+ expect(label).toContain("[user]");
+ });
+ });
+
+ describe("impersonation prevention", () => {
+ it("approved user cannot get [owner] label through ship name tricks", () => {
+ // Even if someone has a ship name similar to owner, they should not get owner role
+ expect(getSenderRole("~nocsyx-lassul-fake", "~nocsyx-lassul")).toBe("user");
+ expect(getSenderRole("~fake-nocsyx-lassul", "~nocsyx-lassul")).toBe("user");
+ });
+
+ it("message content cannot change sender role", () => {
+ // The role is determined by ship identity, not message content
+ // This test documents that even if message contains "I am the owner",
+ // the actual senderShip determines the role
+ const senderShip = "~malicious-actor";
+ const ownerShip = "~nocsyx-lassul";
+
+ // The role is always based on ship comparison, not message content
+ expect(getSenderRole(senderShip, ownerShip)).toBe("user");
+ });
+ });
+});
diff --git a/extensions/tlon/src/urbit/sse-client.test.ts b/extensions/tlon/src/urbit/sse-client.test.ts
new file mode 100644
index 00000000000..029aa0c771a
--- /dev/null
+++ b/extensions/tlon/src/urbit/sse-client.test.ts
@@ -0,0 +1,205 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { UrbitSSEClient } from "./sse-client.js";
+
+// Mock urbitFetch to avoid real network calls
+vi.mock("./fetch.js", () => ({
+ urbitFetch: vi.fn(),
+}));
+
+// Mock channel-ops to avoid real channel operations
+vi.mock("./channel-ops.js", () => ({
+ ensureUrbitChannelOpen: vi.fn().mockResolvedValue(undefined),
+ pokeUrbitChannel: vi.fn().mockResolvedValue(undefined),
+ scryUrbitPath: vi.fn().mockResolvedValue({}),
+}));
+
+describe("UrbitSSEClient", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("subscribe", () => {
+ it("sends subscriptions added after connect", async () => {
+ const { urbitFetch } = await import("./fetch.js");
+ const mockUrbitFetch = vi.mocked(urbitFetch);
+ mockUrbitFetch.mockResolvedValue({
+ response: { ok: true, status: 200 },
+ finalUrl: "https://example.com",
+ release: vi.fn().mockResolvedValue(undefined),
+ });
+
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
+ // Simulate connected state
+ (client as { isConnected: boolean }).isConnected = true;
+
+ await client.subscribe({
+ app: "chat",
+ path: "/dm/~zod",
+ event: () => {},
+ });
+
+ expect(mockUrbitFetch).toHaveBeenCalledTimes(1);
+ const callArgs = mockUrbitFetch.mock.calls[0][0];
+ expect(callArgs.path).toContain("/~/channel/");
+ expect(callArgs.init.method).toBe("PUT");
+
+ const body = JSON.parse(callArgs.init.body as string);
+ expect(body).toHaveLength(1);
+ expect(body[0]).toMatchObject({
+ action: "subscribe",
+ app: "chat",
+ path: "/dm/~zod",
+ });
+ });
+
+ it("queues subscriptions before connect", async () => {
+ const { urbitFetch } = await import("./fetch.js");
+ const mockUrbitFetch = vi.mocked(urbitFetch);
+
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
+ // Not connected yet
+
+ await client.subscribe({
+ app: "chat",
+ path: "/dm/~zod",
+ event: () => {},
+ });
+
+ // Should not call urbitFetch since not connected
+ expect(mockUrbitFetch).not.toHaveBeenCalled();
+ // But subscription should be queued
+ expect(client.subscriptions).toHaveLength(1);
+ expect(client.subscriptions[0]).toMatchObject({
+ app: "chat",
+ path: "/dm/~zod",
+ });
+ });
+ });
+
+ describe("updateCookie", () => {
+ it("normalizes cookie when updating", () => {
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
+
+ // Cookie with extra parts that should be stripped
+ client.updateCookie("urbauth-~zod=456; Path=/; HttpOnly");
+
+ expect(client.cookie).toBe("urbauth-~zod=456");
+ });
+
+ it("handles simple cookie values", () => {
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
+
+ client.updateCookie("urbauth-~zod=newvalue");
+
+ expect(client.cookie).toBe("urbauth-~zod=newvalue");
+ });
+ });
+
+ describe("reconnection", () => {
+ it("has autoReconnect enabled by default", () => {
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
+ expect(client.autoReconnect).toBe(true);
+ });
+
+ it("can disable autoReconnect via options", () => {
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
+ autoReconnect: false,
+ });
+ expect(client.autoReconnect).toBe(false);
+ });
+
+ it("stores onReconnect callback", () => {
+ const onReconnect = vi.fn();
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
+ onReconnect,
+ });
+ expect(client.onReconnect).toBe(onReconnect);
+ });
+
+ it("resets reconnect attempts on successful connect", async () => {
+ const { urbitFetch } = await import("./fetch.js");
+ const mockUrbitFetch = vi.mocked(urbitFetch);
+
+ // Mock a response that returns a readable stream
+ const mockStream = new ReadableStream({
+ start(controller) {
+ controller.close();
+ },
+ });
+
+ mockUrbitFetch.mockResolvedValue({
+ response: {
+ ok: true,
+ status: 200,
+ body: mockStream,
+ } as unknown as Response,
+ finalUrl: "https://example.com",
+ release: vi.fn().mockResolvedValue(undefined),
+ });
+
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
+ autoReconnect: false, // Disable to prevent reconnect loop
+ });
+ client.reconnectAttempts = 5;
+
+ await client.connect();
+
+ expect(client.reconnectAttempts).toBe(0);
+ });
+ });
+
+ describe("event acking", () => {
+ it("tracks lastHeardEventId and ackThreshold", () => {
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
+
+ // Access private properties for testing
+ const lastHeardEventId = (client as unknown as { lastHeardEventId: number }).lastHeardEventId;
+ const ackThreshold = (client as unknown as { ackThreshold: number }).ackThreshold;
+
+ expect(lastHeardEventId).toBe(-1);
+ expect(ackThreshold).toBeGreaterThan(0);
+ });
+ });
+
+ describe("constructor", () => {
+ it("generates unique channel ID", () => {
+ const client1 = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
+ const client2 = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
+
+ expect(client1.channelId).not.toBe(client2.channelId);
+ });
+
+ it("normalizes cookie in constructor", () => {
+ const client = new UrbitSSEClient(
+ "https://example.com",
+ "urbauth-~zod=123; Path=/; HttpOnly",
+ );
+
+ expect(client.cookie).toBe("urbauth-~zod=123");
+ });
+
+ it("sets default reconnection parameters", () => {
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
+
+ expect(client.maxReconnectAttempts).toBe(10);
+ expect(client.reconnectDelay).toBe(1000);
+ expect(client.maxReconnectDelay).toBe(30000);
+ });
+
+ it("allows overriding reconnection parameters", () => {
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
+ maxReconnectAttempts: 5,
+ reconnectDelay: 500,
+ maxReconnectDelay: 10000,
+ });
+
+ expect(client.maxReconnectAttempts).toBe(5);
+ expect(client.reconnectDelay).toBe(500);
+ expect(client.maxReconnectDelay).toBe(10000);
+ });
+ });
+});
diff --git a/extensions/tlon/src/urbit/upload.test.ts b/extensions/tlon/src/urbit/upload.test.ts
new file mode 100644
index 00000000000..888929fc54e
--- /dev/null
+++ b/extensions/tlon/src/urbit/upload.test.ts
@@ -0,0 +1,184 @@
+import { describe, expect, it, vi, afterEach, beforeEach } from "vitest";
+
+// Mock urbitFetch
+vi.mock("./fetch.js", () => ({
+ urbitFetch: vi.fn(),
+}));
+
+// Mock @tloncorp/api
+vi.mock("@tloncorp/api", () => ({
+ uploadFile: vi.fn(),
+}));
+
+describe("uploadImageFromUrl", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("fetches image and calls uploadFile, returns uploaded URL", async () => {
+ const { urbitFetch } = await import("./fetch.js");
+ const mockUrbitFetch = vi.mocked(urbitFetch);
+
+ const { uploadFile } = await import("@tloncorp/api");
+ const mockUploadFile = vi.mocked(uploadFile);
+
+ // Mock urbitFetch to return a successful response with a blob
+ const mockBlob = new Blob(["fake-image"], { type: "image/png" });
+ mockUrbitFetch.mockResolvedValue({
+ response: {
+ ok: true,
+ headers: new Headers({ "content-type": "image/png" }),
+ blob: () => Promise.resolve(mockBlob),
+ } as unknown as Response,
+ finalUrl: "https://example.com/image.png",
+ release: vi.fn().mockResolvedValue(undefined),
+ });
+
+ // Mock uploadFile to return a successful upload
+ mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" });
+
+ const { uploadImageFromUrl } = await import("./upload.js");
+ const result = await uploadImageFromUrl("https://example.com/image.png");
+
+ expect(result).toBe("https://memex.tlon.network/uploaded.png");
+ expect(mockUploadFile).toHaveBeenCalledTimes(1);
+ expect(mockUploadFile).toHaveBeenCalledWith(
+ expect.objectContaining({
+ blob: mockBlob,
+ contentType: "image/png",
+ }),
+ );
+ });
+
+ it("returns original URL if fetch fails", async () => {
+ const { urbitFetch } = await import("./fetch.js");
+ const mockUrbitFetch = vi.mocked(urbitFetch);
+
+ // Mock urbitFetch to return a failed response
+ mockUrbitFetch.mockResolvedValue({
+ response: {
+ ok: false,
+ status: 404,
+ } as unknown as Response,
+ finalUrl: "https://example.com/image.png",
+ release: vi.fn().mockResolvedValue(undefined),
+ });
+
+ const { uploadImageFromUrl } = await import("./upload.js");
+ const result = await uploadImageFromUrl("https://example.com/image.png");
+
+ expect(result).toBe("https://example.com/image.png");
+ });
+
+ it("returns original URL if upload fails", async () => {
+ const { urbitFetch } = await import("./fetch.js");
+ const mockUrbitFetch = vi.mocked(urbitFetch);
+
+ const { uploadFile } = await import("@tloncorp/api");
+ const mockUploadFile = vi.mocked(uploadFile);
+
+ // Mock urbitFetch to return a successful response
+ const mockBlob = new Blob(["fake-image"], { type: "image/png" });
+ mockUrbitFetch.mockResolvedValue({
+ response: {
+ ok: true,
+ headers: new Headers({ "content-type": "image/png" }),
+ blob: () => Promise.resolve(mockBlob),
+ } as unknown as Response,
+ finalUrl: "https://example.com/image.png",
+ release: vi.fn().mockResolvedValue(undefined),
+ });
+
+ // Mock uploadFile to throw an error
+ mockUploadFile.mockRejectedValue(new Error("Upload failed"));
+
+ const { uploadImageFromUrl } = await import("./upload.js");
+ const result = await uploadImageFromUrl("https://example.com/image.png");
+
+ expect(result).toBe("https://example.com/image.png");
+ });
+
+ it("rejects non-http(s) URLs", async () => {
+ const { uploadImageFromUrl } = await import("./upload.js");
+
+ // file:// URL should be rejected
+ const result = await uploadImageFromUrl("file:///etc/passwd");
+ expect(result).toBe("file:///etc/passwd");
+
+ // ftp:// URL should be rejected
+ const result2 = await uploadImageFromUrl("ftp://example.com/image.png");
+ expect(result2).toBe("ftp://example.com/image.png");
+ });
+
+ it("handles invalid URLs gracefully", async () => {
+ const { uploadImageFromUrl } = await import("./upload.js");
+
+ // Invalid URL should return original
+ const result = await uploadImageFromUrl("not-a-valid-url");
+ expect(result).toBe("not-a-valid-url");
+ });
+
+ it("extracts filename from URL path", async () => {
+ const { urbitFetch } = await import("./fetch.js");
+ const mockUrbitFetch = vi.mocked(urbitFetch);
+
+ const { uploadFile } = await import("@tloncorp/api");
+ const mockUploadFile = vi.mocked(uploadFile);
+
+ const mockBlob = new Blob(["fake-image"], { type: "image/jpeg" });
+ mockUrbitFetch.mockResolvedValue({
+ response: {
+ ok: true,
+ headers: new Headers({ "content-type": "image/jpeg" }),
+ blob: () => Promise.resolve(mockBlob),
+ } as unknown as Response,
+ finalUrl: "https://example.com/path/to/my-image.jpg",
+ release: vi.fn().mockResolvedValue(undefined),
+ });
+
+ mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.jpg" });
+
+ const { uploadImageFromUrl } = await import("./upload.js");
+ await uploadImageFromUrl("https://example.com/path/to/my-image.jpg");
+
+ expect(mockUploadFile).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fileName: "my-image.jpg",
+ }),
+ );
+ });
+
+ it("uses default filename when URL has no path", async () => {
+ const { urbitFetch } = await import("./fetch.js");
+ const mockUrbitFetch = vi.mocked(urbitFetch);
+
+ const { uploadFile } = await import("@tloncorp/api");
+ const mockUploadFile = vi.mocked(uploadFile);
+
+ const mockBlob = new Blob(["fake-image"], { type: "image/png" });
+ mockUrbitFetch.mockResolvedValue({
+ response: {
+ ok: true,
+ headers: new Headers({ "content-type": "image/png" }),
+ blob: () => Promise.resolve(mockBlob),
+ } as unknown as Response,
+ finalUrl: "https://example.com/",
+ release: vi.fn().mockResolvedValue(undefined),
+ });
+
+ mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" });
+
+ const { uploadImageFromUrl } = await import("./upload.js");
+ await uploadImageFromUrl("https://example.com/");
+
+ expect(mockUploadFile).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fileName: expect.stringMatching(/^upload-\d+\.png$/),
+ }),
+ );
+ });
+});