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
12 changes: 7 additions & 5 deletions gateway/src/gateway.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Server, ServerWebSocket } from "bun";
import type { Verify } from "./auth";
import { type Ledger, type Message, ROOM } from "./history";
import type { TntEvent } from "./module";
import type { RoomEvent } from "./module";
import { Presence } from "./presence";
import { RateLimiter } from "./ratelimit";
import { normalizeText, validateText } from "./text";
Expand All @@ -28,6 +28,7 @@ export type GatewayOptions = {
ledger: Ledger;
verify: Verify;
post: (sender: string, text: string) => void;
signedIn?: (handle: string) => void;
now?: () => Date;
authTimeoutMs?: number;
};
Expand Down Expand Up @@ -76,6 +77,7 @@ export function createGateway(options: GatewayOptions) {
clearTimeout(ws.data.timer);
ws.data.state = "ready";
ws.data.handle = handle;
options.signedIn?.(handle);
presence.webJoined(handle, ws.data.id);
syncPresence();
ws.send(
Expand Down Expand Up @@ -117,7 +119,7 @@ export function createGateway(options: GatewayOptions) {
if (!limiter.take(sender)) return fail(ws, "rate_limited");
options.post(sender, text);
const timestamp = now().toISOString().replace(/\.\d{3}Z$/, "Z");
const message = options.ledger.add({ timestamp, sender, text });
const message = options.ledger.add({ timestamp, sender, text, verified: true });
if (key !== undefined) sent.set(key, { message, at: Date.now() });
deliver(message, nonce);
}
Expand Down Expand Up @@ -151,10 +153,10 @@ export function createGateway(options: GatewayOptions) {
for (const [key, entry] of sent) if ((at ?? Date.now()) - entry.at > NONCE_TTL_MS) sent.delete(key);
for (const [handle, time] of typingAt) if ((at ?? Date.now()) - time > TYPING_INTERVAL_MS) typingAt.delete(handle);
},
handleEvent(event: TntEvent) {
handleEvent(event: RoomEvent) {
if (event.type === "message.created") {
const { timestamp, sender, plain_text } = event.message;
deliver(options.ledger.add({ timestamp, sender, text: normalizeText(plain_text) }));
const { timestamp, sender, plain_text, verified } = event.message;
deliver(options.ledger.add({ timestamp, sender, text: normalizeText(plain_text), verified }));
return;
}
if (event.type === "presence.snapshot") presence.sshSnapshot(event.nicknames);
Expand Down
20 changes: 19 additions & 1 deletion gateway/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { byteLength } from "./text";

export const ROOM = "lobby";

export type Entry = { timestamp: string; sender: string; text: string };
export type Entry = { timestamp: string; sender: string; text: string; verified?: boolean };
export type Kind = "text" | "action" | "system" | "module";
export type Message = {
id: string;
Expand All @@ -12,6 +12,7 @@ export type Message = {
timestamp: string;
kind: Kind;
mentions: string[];
verified: boolean;
};

const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
Expand Down Expand Up @@ -148,6 +149,7 @@ export class Ledger {
timestamp: entry.timestamp,
kind: shown.kind,
mentions: mentions(shown.text),
verified: entry.verified === true && (shown.kind === "text" || shown.kind === "action"),
};
this.#messages.push(message);
this.#entries.push(entry);
Expand All @@ -169,3 +171,19 @@ 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()) };
}

60 changes: 60 additions & 0 deletions gateway/src/identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Verify } from "./auth";

const CLIENT_ID = "tnt";
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";

export type SignInOptions = {
issuer: string;
verify: Verify;
notice: (text: string) => void;
verified: (handle: string) => void;
fetch?: typeof fetch;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
};

type Grant = { device_code: string; user_code: string; verification_uri_complete: string; expires_in: number; interval: number };

const UNAVAILABLE = "Sign-in is unavailable now. 登录暂时不可用,请稍后再试。";

/** Runs an RFC 8628 device authorization grant against m1ngsama ID for one terminal session. */
export async function signIn(options: SignInOptions): Promise<void> {
const request = options.fetch ?? fetch;
const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms));
const now = options.now ?? Date.now;
const form = (fields: Record<string, string>) => ({ method: "POST", body: new URLSearchParams(fields) });
try {
const started = await request(`${options.issuer}/device/code`, form({ client_id: CLIENT_ID }));
if (!started.ok) return options.notice(UNAVAILABLE);
const grant = (await started.json()) as Grant;
const minutes = Math.round(grant.expires_in / 60);
// TNT cuts a notice at the terminal width, so each line goes on its own.
options.notice("Open this link to sign in / 在浏览器打开链接登录:");
options.notice(grant.verification_uri_complete);
options.notice(`Code / 验证码 ${grant.user_code}, ${minutes} min`);
const deadline = now() + grant.expires_in * 1000;
let interval = grant.interval * 1000;
while (now() < deadline) {
await sleep(interval);
const polled = await request(
`${options.issuer}/device/token`,
form({ grant_type: DEVICE_GRANT, device_code: grant.device_code, client_id: CLIENT_ID }),
);
const body = (await polled.json()) as { access_token?: string; error?: string };
if (polled.ok && body.access_token) {
const handle = await options.verify(body.access_token);
return handle ? options.verified(handle) : options.notice(UNAVAILABLE);
}
if (body.error === "authorization_pending") continue;
if (body.error === "slow_down") {
interval += 5000;
continue;
}
if (body.error === "access_denied") return options.notice("Sign-in was cancelled. 已取消登录。");
break;
}
options.notice("The code expired. Run :login again. 验证码已过期。");
} catch {
options.notice(UNAVAILABLE);
}
}
40 changes: 35 additions & 5 deletions gateway/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,36 @@
import { createVerifier, remoteKeys } from "./auth";
import { createGateway } from "./gateway";
import { Ledger, readHistory } from "./history";
import { encodePost, parseTntLine } from "./module";
import { Ledger, markReserved, readHistory, readReserved } from "./history";
import { signIn } from "./identity";
import { encodePost, encodeRecord, parseTntLine, supportsIdentity } from "./module";

const HISTORY_LIMIT = 5000;
const env = process.env;
const stateDir = env.TNT_STATE_DIR || ".";
const issuer = env.TNT_GATEWAY_ISSUER || "https://auth.m1ng.space";
const stdout = Bun.stdout.writer();
const write = (line: string) => {
stdout.write(line);
stdout.flush();
};

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

const verify = createVerifier(issuer, remoteKeys(env.TNT_GATEWAY_JWKS_URL || `${issuer}/oidc/jwks`));
let identity = false;
const signingIn = new Set<string>();

const gateway = createGateway({
ledger,
verify: createVerifier(issuer, remoteKeys(env.TNT_GATEWAY_JWKS_URL || `${issuer}/oidc/jwks`)),
verify,
post: (sender, text) => write(encodePost(sender, text)),
signedIn: (handle) => {
if (!identity || reserved.has(handle)) return;
reserved.add(handle);
write(encodeRecord({ type: "identity.reserve", handle }));
},
});

setInterval(() => gateway.sweep(), 60_000);
Expand All @@ -34,6 +46,24 @@ for await (const line of console) {
if (line === "") continue;
const { reply, event } = parseTntLine(line);
if (reply) write(reply);
if (event) gateway.handleEvent(event);
if (!event) continue;
if (event.type === "handshake") {
identity = supportsIdentity(event.version);
} else if (event.type === "identity.requested") {
const { session } = event;
if (!identity || signingIn.has(session)) continue;
signingIn.add(session);
void signIn({
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 }));
},
}).finally(() => signingIn.delete(session));
} else {
gateway.handleEvent(event);
}
}
process.exit(0);
29 changes: 24 additions & 5 deletions gateway/src/module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
export type TntEvent =
| { type: "message.created"; message: { timestamp: string; sender: string; plain_text: string } }
| { type: "handshake"; version: string }
| { type: "message.created"; message: { timestamp: string; sender: string; plain_text: string; verified: boolean } }
| { type: "identity.requested"; session: string }
| { type: "presence.snapshot"; nicknames: string[] }
| { type: "presence.joined" | "presence.left"; nickname: string };

export type RoomEvent = Exclude<TntEvent, { type: "handshake" | "identity.requested" }>;

export const HANDSHAKE_OK = `${JSON.stringify({
type: "handshake.ok",
protocol: "tnt.module.v1",
Expand All @@ -16,6 +20,16 @@ export function encodePost(sender: string, text: string): string {
return `${JSON.stringify({ type: "message.post", sender, plain_text: text })}\n`;
}

export function encodeRecord(record: Record<string, string>): string {
return `${JSON.stringify(record)}\n`;
}

/** Identity records arrived in TNT 1.5.0; older servers treat them as protocol failures. */
export function supportsIdentity(version: string): boolean {
const [major = 0, minor = 0] = version.split(".").map(Number);
return major > 1 || (major === 1 && minor >= 5);
}

export function parseTntLine(line: string): { reply: string | null; event: TntEvent | null } {
let record: Record<string, unknown>;
try {
Expand All @@ -27,16 +41,21 @@ export function parseTntLine(line: string): { reply: string | null; event: TntEv
}

switch (record.type) {
case "handshake":
return { reply: HANDSHAKE_OK, event: null };
case "handshake": {
const version = (record.server as Record<string, unknown> | undefined)?.version;
return { reply: HANDSHAKE_OK, event: { type: "handshake", version: isString(version) ? version : "" } };
}
case "message.created": {
const { timestamp, sender, plain_text } = (record.message ?? {}) as Record<string, unknown>;
const { timestamp, sender, plain_text, metadata } = (record.message ?? {}) as Record<string, unknown>;
const valid = isString(timestamp) && isString(sender) && isString(plain_text);
const verified = (metadata as Record<string, unknown> | undefined)?.verified === true;
return {
reply: EVENT_OK,
event: valid ? { type: "message.created", message: { timestamp, sender, plain_text } } : null,
event: valid ? { type: "message.created", message: { timestamp, sender, plain_text, verified } } : null,
};
}
case "identity.requested":
return isString(record.session) ? { reply: null, event: { type: "identity.requested", session: record.session } } : NOTHING;
case "presence.snapshot":
return Array.isArray(record.nicknames)
? { reply: null, event: { type: "presence.snapshot", nicknames: record.nicknames.filter(isString) } }
Expand Down
11 changes: 6 additions & 5 deletions gateway/test/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ describe("session", () => {
timestamp: "2026-09-22T10:00:00Z",
kind: "text",
mentions: [],
verified: true,
});
expect(posts).toEqual([["alice", "hello"]]);
});
Expand Down Expand Up @@ -102,7 +103,7 @@ describe("session", () => {

gateway.handleEvent({
type: "message.created",
message: { timestamp: "2026-09-22T10:00:05Z", sender: "carol", plain_text: "a|b" },
message: { timestamp: "2026-09-22T10:00:05Z", sender: "carol", plain_text: "a|b", verified: false },
});
expect(await alice.next((f) => f.type === "message")).toMatchObject({
sender: "carol",
Expand Down Expand Up @@ -162,18 +163,18 @@ describe("message kinds", () => {
test("classifies actions, notices and module replies and lists mentions", async () => {
const alice = connect(url, "alice");
await alice.next((f) => f.type === "ready");
gateway.handleEvent({ type: "message.created", message: { timestamp: "2026-09-22T10:00:01Z", sender: "*", plain_text: "carol waves at @alice" } });
gateway.handleEvent({ type: "message.created", message: { timestamp: "2026-09-22T10:00:01Z", sender: "*", plain_text: "carol waves at @alice", verified: false } });
expect(await alice.next((f) => f.type === "message")).toMatchObject({ kind: "action", sender: "carol", text: "waves at @alice", mentions: ["alice"] });
gateway.handleEvent({ type: "message.created", message: { timestamp: "2026-09-22T10:00:02Z", sender: "系统", plain_text: "carol 加入了聊天室" } });
gateway.handleEvent({ type: "message.created", message: { timestamp: "2026-09-22T10:00:02Z", sender: "系统", plain_text: "carol 加入了聊天室", verified: false } });
expect(await alice.next((f) => f.type === "message")).toMatchObject({ kind: "system", sender: "系统", text: "carol 加入了聊天室" });
gateway.handleEvent({ type: "message.created", message: { timestamp: "2026-09-22T10:00:03Z", sender: "module:roll-module", plain_text: "🎲 carol rolled d6 → 4" } });
gateway.handleEvent({ type: "message.created", message: { timestamp: "2026-09-22T10:00:03Z", sender: "module:roll-module", plain_text: "🎲 carol rolled d6 → 4", verified: false } });
expect(await alice.next((f) => f.type === "message")).toMatchObject({ kind: "module", sender: "roll-module", text: "🎲 carol rolled d6 → 4" });
alice.send({ type: "send", text: "hi @Bob, @nobody" });
expect(await alice.next((f) => f.type === "message")).toMatchObject({ kind: "text", sender: "alice", mentions: ["bob", "nobody"] });
});

test("history keeps notices and module replies", async () => {
gateway.handleEvent({ type: "message.created", message: { timestamp: "2026-09-22T10:00:04Z", sender: "module:flip-module", plain_text: "🪙 carol flipped → heads" } });
gateway.handleEvent({ type: "message.created", message: { timestamp: "2026-09-22T10:00:04Z", sender: "module:flip-module", plain_text: "🪙 carol flipped → heads", verified: false } });
const alice = connect(url, "alice");
const ready = await alice.next((f) => f.type === "ready");
expect(ready.history.at(-1)).toMatchObject({ kind: "module", sender: "flip-module" });
Expand Down
24 changes: 21 additions & 3 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, messageId, parseRecord, readHistory } from "../src/history";
import { Ledger, decodeContent, markReserved, messageId, parseRecord, readHistory, readReserved } 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 Down Expand Up @@ -64,8 +64,8 @@ describe("Ledger", () => {
expect(ledger.add(HELLO).id).toBe("aa2df149c78d997a");
ledger.add({ ...HELLO, text: "later" });
expect(ledger.recent()).toEqual([
{ id: "aa2df149c78d997a", room: "lobby", sender: "alice", text: "hello", timestamp: "2026-09-22T10:00:00Z", kind: "text", mentions: [] },
{ id: messageId({ ...HELLO, text: "later" }, 0), room: "lobby", sender: "alice", text: "later", timestamp: "2026-09-22T10:00:00Z", kind: "text", mentions: [] },
{ id: "aa2df149c78d997a", room: "lobby", sender: "alice", text: "hello", timestamp: "2026-09-22T10:00:00Z", kind: "text", mentions: [], verified: false },
{ id: messageId({ ...HELLO, text: "later" }, 0), room: "lobby", sender: "alice", text: "later", timestamp: "2026-09-22T10:00:00Z", kind: "text", mentions: [], verified: false },
]);
});
});
Expand Down Expand Up @@ -157,3 +157,21 @@ describe("readHistory whole files", () => {
expect((await readHistory(dir, 5000, NOW)).length).toBe(3000);
});
});

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 });
}
});
});
Loading
Loading