From d98f0a935ab8240c3e14c5bcd97c6cca11621eb1 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 14 Oct 2026 20:27:52 +0800 Subject: [PATCH 1/2] feat(gateway): reserve members' handles when a terminal nickname names or imitates one Each SSH nickname that joins is checked against m1ngsama ID's look-alike lookup, and a match is reserved so TNT renames the unverified holder. History takes the verified flag from message log v3 instead of guessing from today's reservations. --- gateway/src/guard.ts | 45 ++++++++++++++++++++++++++ gateway/src/history.ts | 23 +++---------- gateway/src/main.ts | 25 ++++++++------- gateway/test/guard.test.ts | 62 ++++++++++++++++++++++++++++++++++++ gateway/test/history.test.ts | 24 ++++---------- 5 files changed, 131 insertions(+), 48 deletions(-) create mode 100644 gateway/src/guard.ts create mode 100644 gateway/test/guard.test.ts diff --git a/gateway/src/guard.ts b/gateway/src/guard.ts new file mode 100644 index 0000000..c566e3c --- /dev/null +++ b/gateway/src/guard.ts @@ -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(); + const reserved = new Set(); + let inFlight = 0; + + const reserve = (handle: string) => { + if (reserved.has(handle)) return; + reserved.add(handle); + options.reserve(handle); + }; + + async function check(nickname: string): Promise { + 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 }; +} diff --git a/gateway/src/history.ts b/gateway/src/history.ts index d597519..0b6b764 100644 --- a/gateway/src/history.ts +++ b/gateway/src/history.ts @@ -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; @@ -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> { - 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): Entry { - return { ...entry, verified: reserved.has(classify(entry).sender.toLowerCase()) }; -} - diff --git a/gateway/src/main.ts b/gateway/src/main.ts index fc63256..6f0673f 100644 --- a/gateway/src/main.ts +++ b/gateway/src/main.ts @@ -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"; @@ -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(); +const guard = createGuard({ + issuer, + reserve: (handle) => { + if (identity) write(encodeRecord({ type: "identity.reserve", handle })); + }, +}); const gateway = createGateway({ ledger, @@ -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); @@ -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); } } diff --git a/gateway/test/guard.test.ts b/gateway/test/guard.test.ts new file mode 100644 index 0000000..f1f465c --- /dev/null +++ b/gateway/test/guard.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { createGuard } from "../src/guard"; + +function setup(answers: Record) { + 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); +}); diff --git a/gateway/test/history.test.ts b/gateway/test/history.test.ts index 1dbe928..f8da563 100644 --- a/gateway/test/history.test.ts +++ b/gateway/test/history.test.ts @@ -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"); @@ -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|", @@ -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(); }); }); From e7d3b7d762d8e24cb8778b5529733bb4eea4f8a6 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 14 Oct 2026 21:04:37 +0800 Subject: [PATCH 2/2] test(gateway): expect the verified flag on web posts in message log v3 --- gateway/e2e/chat.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/e2e/chat.test.ts b/gateway/e2e/chat.test.ts index 4f652e8..c6f07fc 100644 --- a/gateway/e2e/chat.test.ts +++ b/gateway/e2e/chat.test.ts @@ -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);