Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gateway/e2e/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ close
expect(exitCode).toBe(0);
await alice.next((f) => f.type === "presence" && !f.online.includes("carol"), 10_000);

expect(readFileSync(join(stateDir, "messages.log"), "utf8")).toContain("|alice|hello from the web\n");
expect(readFileSync(join(stateDir, "messages.log"), "utf8")).toContain("|alice|hello from the web|v\n");

const bob = connect(url, await token("bob"));
const ready = await bob.next((f) => f.type === "ready", 10_000);
Expand Down
45 changes: 45 additions & 0 deletions gateway/src/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const RECHECK_MS = 10 * 60_000;
const MAX_IN_FLIGHT = 4;
const ANONYMOUS = /^anonymous\d*$/i;

export type GuardOptions = {
issuer: string;
/** Reserves a member's handle in TNT, which renames anyone unverified using it or a look-alike. */
reserve: (handle: string) => void;
fetch?: typeof fetch;
now?: () => number;
};

/** Asks m1ngsama ID whether a terminal nickname is, or looks like, a member's handle. */
export function createGuard(options: GuardOptions) {
const request = options.fetch ?? fetch;
const now = options.now ?? Date.now;
const checked = new Map<string, number>();
const reserved = new Set<string>();
let inFlight = 0;

const reserve = (handle: string) => {
if (reserved.has(handle)) return;
reserved.add(handle);
options.reserve(handle);
};

async function check(nickname: string): Promise<void> {
const key = nickname.toLowerCase();
if (ANONYMOUS.test(nickname) || now() - (checked.get(key) ?? -Infinity) < RECHECK_MS || inFlight >= MAX_IN_FLIGHT) return;
checked.set(key, now());
inFlight++;
try {
const response = await request(`${options.issuer}/people/similar?${new URLSearchParams({ name: nickname })}`);
if (!response.ok) return;
const { handle } = (await response.json()) as { handle?: unknown };
if (typeof handle === "string") reserve(handle);
} catch {
checked.delete(key);
} finally {
inFlight--;
}
}

return { check, reserve };
}
23 changes: 4 additions & 19 deletions gateway/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,15 @@ export function decodeContent(stored: string): string | null {

export function parseRecord(line: string, now: number): Entry | null {
const fields = line.split("|");
if (fields.length !== 3) return null;
const [timestamp, sender, stored] = fields as [string, string, string];
// Version 3 adds a flags field; "v" marks a sender who was signed in.
if (fields.length !== 3 && fields.length !== 4) return null;
const [timestamp, sender, stored, flags = ""] = fields as [string, string, string, string?];
if (!TIMESTAMP.test(timestamp) || sender === "" || stored === "") return null;
if (byteLength(sender) > 63 || byteLength(stored) > 1023) return null;
if (CONTROL.test(sender) || CONTROL.test(stored)) return null;
const text = decodeContent(stored);
if (text === null || !inWindow(timestamp, now)) return null;
return { timestamp, sender, text };
return flags.includes("v") ? { timestamp, sender, text, verified: true } : { timestamp, sender, text };
}

const MENTION = /@([A-Za-z0-9_]+)/g;
Expand Down Expand Up @@ -183,19 +184,3 @@ export class Ledger {
return { messages: this.#messages.slice(start, index), more: start > 0 };
}
}

const HANDLE = /^[a-z0-9_]{1,20}$/;

/** Handles TNT reserves for signed-in members, from the first word of each line of its identities file. */
export async function readReserved(stateDir: string): Promise<Set<string>> {
const file = Bun.file(`${stateDir}/identities`);
if (!(await file.exists())) return new Set();
const handles = (await file.text()).split("\n").map((line) => line.split(" ")[0]!);
return new Set(handles.filter((handle) => HANDLE.test(handle)));
}

/** History predates the live metadata, so a past message counts as verified when its sender is reserved now. */
export function markReserved(entry: Entry, reserved: Set<string>): Entry {
return { ...entry, verified: reserved.has(classify(entry).sender.toLowerCase()) };
}

25 changes: 13 additions & 12 deletions gateway/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createVerifier, remoteKeys } from "./auth";
import { createGateway } from "./gateway";
import { Ledger, markReserved, readHistory, readReserved } from "./history";
import { createGuard } from "./guard";
import { Ledger, readHistory } from "./history";
import { signIn } from "./identity";
import { encodePost, encodeRecord, parseTntLine, supportsIdentity, supportsPrivate } from "./module";

Expand All @@ -14,14 +15,19 @@ const write = (line: string) => {
stdout.flush();
};

const reserved = await readReserved(stateDir);
const ledger = new Ledger(HISTORY_LIMIT);
for (const entry of await readHistory(stateDir, HISTORY_LIMIT)) ledger.add(markReserved(entry, reserved));
for (const entry of await readHistory(stateDir, HISTORY_LIMIT)) ledger.add(entry);

const verify = createVerifier(issuer, remoteKeys(env.TNT_GATEWAY_JWKS_URL || `${issuer}/oidc/jwks`));
let identity = false;
let direct = false;
const signingIn = new Set<string>();
const guard = createGuard({
issuer,
reserve: (handle) => {
if (identity) write(encodeRecord({ type: "identity.reserve", handle }));
},
});

const gateway = createGateway({
ledger,
Expand All @@ -34,11 +40,7 @@ const gateway = createGateway({
remotePresence: (nicknames) => {
if (direct) write(encodeRecord({ type: "presence.remote", nicknames }));
},
signedIn: (handle) => {
if (!identity || reserved.has(handle)) return;
reserved.add(handle);
write(encodeRecord({ type: "identity.reserve", handle }));
},
signedIn: (handle) => guard.reserve(handle),
});

setInterval(() => gateway.sweep(), 60_000);
Expand Down Expand Up @@ -68,12 +70,11 @@ for await (const line of console) {
issuer,
verify,
notice: (plain_text) => write(encodeRecord({ type: "identity.notice", session, plain_text })),
verified: (handle) => {
reserved.add(handle);
write(encodeRecord({ type: "identity.verified", session, handle }));
},
verified: (handle) => write(encodeRecord({ type: "identity.verified", session, handle })),
}).finally(() => signingIn.delete(session));
} else {
if (identity && event.type === "presence.joined") void guard.check(event.nickname);
if (identity && event.type === "presence.snapshot") for (const nickname of event.nicknames) void guard.check(nickname);
gateway.handleEvent(event);
}
}
Expand Down
62 changes: 62 additions & 0 deletions gateway/test/guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { expect, test } from "bun:test";
import { createGuard } from "../src/guard";

function setup(answers: Record<string, string>) {
const reserved: string[] = [];
const asked: string[] = [];
let clock = 0;
const guard = createGuard({
issuer: "https://id.test",
reserve: (handle) => reserved.push(handle),
fetch: (async (url: string) => {
const name = new URL(url).searchParams.get("name")!;
asked.push(name);
const handle = answers[name];
return handle ? Response.json({ handle }) : Response.json({ error: "not_found" }, { status: 404 });
}) as unknown as typeof fetch,
now: () => clock,
});
return { guard, reserved, asked, tick: (ms: number) => (clock += ms) };
}

test("reserves the member a nickname names or imitates, once", async () => {
const { guard, reserved, asked } = setup({ m1ngsarna: "m1ngsama", m1ngsama: "m1ngsama" });
await guard.check("m1ngsarna");
await guard.check("m1ngsama");
await guard.check("stranger");
expect(asked).toEqual(["m1ngsarna", "m1ngsama", "stranger"]);
expect(reserved).toEqual(["m1ngsama"]);
});

test("skips anonymous names and asks again only after ten minutes", async () => {
const { guard, asked, tick } = setup({});
await guard.check("anonymous");
await guard.check("anonymous12");
await guard.check("carol");
await guard.check("Carol");
tick(10 * 60_000);
await guard.check("carol");
expect(asked).toEqual(["carol", "carol"]);
});

test("a handle reserved for a signed-in app user is not sent twice", () => {
const { guard, reserved } = setup({});
guard.reserve("lune");
guard.reserve("lune");
expect(reserved).toEqual(["lune"]);
});

test("an unreachable identity provider leaves the name to be checked again", async () => {
let calls = 0;
const guard = createGuard({
issuer: "https://id.test",
reserve: () => {},
fetch: (async () => {
calls++;
throw new Error("offline");
}) as unknown as typeof fetch,
});
await guard.check("carol");
await guard.check("carol");
expect(calls).toBe(2);
});
24 changes: 7 additions & 17 deletions gateway/test/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Ledger, decodeContent, markReserved, messageId, parseRecord, readHistory, readReserved } from "../src/history";
import { Ledger, decodeContent, messageId, parseRecord, readHistory } from "../src/history";

const HELLO = { timestamp: "2026-09-22T10:00:00Z", sender: "alice", text: "hello" };
const NOW = Date.parse("2026-09-22T12:00:00Z");
Expand All @@ -20,7 +20,7 @@ describe("parseRecord", () => {
for (const line of [
"#tnt-message-log v2",
"2026-09-22T10:00:00Z|alice",
"2026-09-22T10:00:00Z|alice|a|b",
"2026-09-22T10:00:00Z|alice|a|v|b",
"2026-09-22 10:00:00|alice|hi",
"2026-09-22T10:00:00Z||hi",
"2026-09-22T10:00:00Z|alice|",
Expand Down Expand Up @@ -158,20 +158,10 @@ describe("readHistory whole files", () => {
});
});

describe("reservations", () => {
test("marks past messages from reserved handles, actions included", async () => {
const dir = mkdtempSync(join(tmpdir(), "tnt-reserved-"));
try {
writeFileSync(join(dir, "identities"), "lune SHA256:abc\nm1ngsama\nNot Valid\n");
const reserved = await readReserved(dir);
expect([...reserved]).toEqual(["lune", "m1ngsama"]);
const ledger = new Ledger(10);
expect(ledger.add(markReserved({ timestamp: "2026-09-22T10:00:00Z", sender: "Lune", text: "hi" }, reserved)).verified).toBe(true);
expect(ledger.add(markReserved({ timestamp: "2026-09-22T10:00:01Z", sender: "*", text: "m1ngsama waves" }, reserved)).verified).toBe(true);
expect(ledger.add(markReserved({ timestamp: "2026-09-22T10:00:02Z", sender: "carol", text: "hi" }, reserved)).verified).toBe(false);
expect(await readReserved(join(dir, "missing"))).toEqual(new Set());
} finally {
rmSync(dir, { recursive: true, force: true });
}
describe("log v3", () => {
test("reads the verified flag and treats v2 records as unverified", () => {
expect(parseRecord("2026-09-22T10:00:00Z|lune|hi|v", NOW)).toEqual({ timestamp: "2026-09-22T10:00:00Z", sender: "lune", text: "hi", verified: true });
expect(parseRecord("2026-09-22T10:00:00Z|lune|hi|", NOW)?.verified).toBeUndefined();
expect(parseRecord("2026-09-22T10:00:00Z|lune|hi", NOW)?.verified).toBeUndefined();
});
});
Loading