From 4190278a43cd1f88102eabdd451f9f3cfb46f07e Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 14 Oct 2026 14:49:02 +0800 Subject: [PATCH 1/2] feat(gateway): sign terminal users in with m1ngsama ID and mark verified messages :login in a TNT 1.5 session reaches the gateway as identity.requested. The gateway runs the device authorization grant against m1ngsama ID, relays the link and code to that session, and reports identity.verified once approved. App users' handles are reserved as they connect, and every message frame now says whether its sender is verified. --- gateway/src/gateway.ts | 12 ++--- gateway/src/history.ts | 20 ++++++++- gateway/src/identity.ts | 60 +++++++++++++++++++++++++ gateway/src/main.ts | 40 ++++++++++++++--- gateway/src/module.ts | 29 +++++++++--- gateway/test/gateway.test.ts | 11 ++--- gateway/test/history.test.ts | 24 ++++++++-- gateway/test/identity.test.ts | 84 +++++++++++++++++++++++++++++++++++ gateway/test/module.test.ts | 24 ++++++++-- gateway/tnt-module.json | 14 ++++-- 10 files changed, 288 insertions(+), 30 deletions(-) create mode 100644 gateway/src/identity.ts create mode 100644 gateway/test/identity.test.ts diff --git a/gateway/src/gateway.ts b/gateway/src/gateway.ts index 406c29b..50e14da 100644 --- a/gateway/src/gateway.ts +++ b/gateway/src/gateway.ts @@ -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"; @@ -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; }; @@ -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( @@ -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); } @@ -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); diff --git a/gateway/src/history.ts b/gateway/src/history.ts index 65b6ea1..927804e 100644 --- a/gateway/src/history.ts +++ b/gateway/src/history.ts @@ -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; @@ -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$/; @@ -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); @@ -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> { + 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/identity.ts b/gateway/src/identity.ts new file mode 100644 index 0000000..86038f2 --- /dev/null +++ b/gateway/src/identity.ts @@ -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; + 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 right now. Try :login again later. 登录暂时不可用,请稍后再试。"; + +/** Runs an RFC 8628 device authorization grant against m1ngsama ID for one terminal session. */ +export async function signIn(options: SignInOptions): Promise { + const request = options.fetch ?? fetch; + const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms)); + const now = options.now ?? Date.now; + const form = (fields: Record) => ({ 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); + options.notice( + `Open ${grant.verification_uri_complete} and approve code ${grant.user_code} within ${minutes} minutes. ` + + `在浏览器打开链接,确认验证码 ${grant.user_code}。`, + ); + 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 sign-in code expired. Run :login again. 验证码已过期,请重新执行 :login。"); + } catch { + options.notice(UNAVAILABLE); + } +} diff --git a/gateway/src/main.ts b/gateway/src/main.ts index fc5f651..65eccd6 100644 --- a/gateway/src/main.ts +++ b/gateway/src/main.ts @@ -1,10 +1,12 @@ 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) => { @@ -12,13 +14,23 @@ const write = (line: string) => { 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(); 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); @@ -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); diff --git a/gateway/src/module.ts b/gateway/src/module.ts index d030f48..520072e 100644 --- a/gateway/src/module.ts +++ b/gateway/src/module.ts @@ -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; + export const HANDSHAKE_OK = `${JSON.stringify({ type: "handshake.ok", protocol: "tnt.module.v1", @@ -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 { + 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; try { @@ -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 | 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; + const { timestamp, sender, plain_text, metadata } = (record.message ?? {}) as Record; const valid = isString(timestamp) && isString(sender) && isString(plain_text); + const verified = (metadata as Record | 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) } } diff --git a/gateway/test/gateway.test.ts b/gateway/test/gateway.test.ts index 8fbba64..a052387 100644 --- a/gateway/test/gateway.test.ts +++ b/gateway/test/gateway.test.ts @@ -67,6 +67,7 @@ describe("session", () => { timestamp: "2026-09-22T10:00:00Z", kind: "text", mentions: [], + verified: true, }); expect(posts).toEqual([["alice", "hello"]]); }); @@ -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", @@ -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" }); diff --git a/gateway/test/history.test.ts b/gateway/test/history.test.ts index 9c1fc69..1dbe928 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, 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"); @@ -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 }, ]); }); }); @@ -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 }); + } + }); +}); diff --git a/gateway/test/identity.test.ts b/gateway/test/identity.test.ts new file mode 100644 index 0000000..33a6178 --- /dev/null +++ b/gateway/test/identity.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { signIn } from "../src/identity"; + +const GRANT = { + device_code: "dc", + user_code: "BCDF-GHJK", + verification_uri_complete: "https://id.test/device?code=BCDF-GHJK", + expires_in: 600, + interval: 5, +}; + +function run(tokens: { status: number; body: object }[], verify = async (t: string) => (t === "good" ? "lune" : null)) { + const notices: string[] = []; + const verified: string[] = []; + const waits: number[] = []; + const requests: string[] = []; + let clock = 0; + const fake = (async (url: string | URL | Request, init?: RequestInit) => { + requests.push(`${url} ${String(init?.body)}`); + if (String(url).endsWith("/device/code")) return Response.json(GRANT); + const next = tokens.shift() ?? { status: 400, body: { error: "expired_token" } }; + return Response.json(next.body, { status: next.status }); + }) as typeof fetch; + const done = signIn({ + issuer: "https://id.test", + verify, + notice: (text) => notices.push(text), + verified: (handle) => verified.push(handle), + fetch: fake, + sleep: async (ms) => { + waits.push(ms); + clock += ms; + }, + now: () => clock, + }); + return done.then(() => ({ notices, verified, waits, requests })); +} + +describe("signIn", () => { + test("shows the link, waits out pending and slow_down, then verifies", async () => { + const result = await run([ + { status: 400, body: { error: "authorization_pending" } }, + { status: 400, body: { error: "slow_down" } }, + { status: 200, body: { access_token: "good", token_type: "Bearer", expires_in: 300 } }, + ]); + expect(result.notices[0]).toContain("https://id.test/device?code=BCDF-GHJK"); + expect(result.waits).toEqual([5000, 5000, 10000]); + expect(result.verified).toEqual(["lune"]); + expect(result.requests[0]).toBe("https://id.test/device/code client_id=tnt"); + expect(result.requests[1]).toContain("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=dc"); + }); + + test("reports a cancelled request", async () => { + const result = await run([{ status: 400, body: { error: "access_denied" } }]); + expect(result.verified).toEqual([]); + expect(result.notices.at(-1)).toContain("cancelled"); + }); + + test("gives up when the code expires", async () => { + const result = await run(Array.from({ length: 200 }, () => ({ status: 400, body: { error: "authorization_pending" } }))); + expect(result.waits.reduce((a, b) => a + b, 0)).toBe(600_000); + expect(result.notices.at(-1)).toContain("expired"); + }); + + test("refuses a token that does not verify", async () => { + const result = await run([{ status: 200, body: { access_token: "forged" } }]); + expect(result.verified).toEqual([]); + expect(result.notices.at(-1)).toContain("unavailable"); + }); + + test("survives the identity provider being down", async () => { + const notices: string[] = []; + await signIn({ + issuer: "https://id.test", + verify: async () => null, + notice: (text) => notices.push(text), + verified: () => {}, + fetch: (async () => { + throw new Error("offline"); + }) as unknown as typeof fetch, + }); + expect(notices).toHaveLength(1); + }); +}); diff --git a/gateway/test/module.test.ts b/gateway/test/module.test.ts index 59e2f04..e431afd 100644 --- a/gateway/test/module.test.ts +++ b/gateway/test/module.test.ts @@ -1,12 +1,19 @@ import { describe, expect, test } from "bun:test"; -import { EVENT_OK, HANDSHAKE_OK, encodePost, parseTntLine } from "../src/module"; +import { EVENT_OK, HANDSHAKE_OK, encodePost, parseTntLine, supportsIdentity } from "../src/module"; describe("parseTntLine", () => { test("answers the handshake", () => { const { reply, event } = parseTntLine('{"type":"handshake","protocol":"tnt.module.v1","server":{"name":"tnt","version":"1.4.0"}}'); expect(reply).toBe(HANDSHAKE_OK); expect(JSON.parse(reply!)).toMatchObject({ type: "handshake.ok", protocol: "tnt.module.v1" }); - expect(event).toBeNull(); + expect(event).toEqual({ type: "handshake", version: "1.4.0" }); + }); + + test("knows which servers accept identity records", () => { + expect(supportsIdentity("1.4.0")).toBe(false); + expect(supportsIdentity("1.5.0")).toBe(true); + expect(supportsIdentity("2.0.0")).toBe(true); + expect(supportsIdentity("")).toBe(false); }); test("acknowledges every message.created, even a malformed one", () => { @@ -14,11 +21,22 @@ describe("parseTntLine", () => { parseTntLine('{"type":"message.created","message":{"id":"local-1","timestamp":"2026-09-22T10:00:00Z","sender":"carol","kind":"text","plain_text":"hi","metadata":{}}}'), ).toEqual({ reply: EVENT_OK, - event: { type: "message.created", message: { timestamp: "2026-09-22T10:00:00Z", sender: "carol", plain_text: "hi" } }, + event: { type: "message.created", message: { timestamp: "2026-09-22T10:00:00Z", sender: "carol", plain_text: "hi", verified: false } }, }); expect(parseTntLine('{"type":"message.created","message":{"sender":1}}')).toEqual({ reply: EVENT_OK, event: null }); }); + test("reads the verified flag and identity requests", () => { + expect( + parseTntLine('{"type":"message.created","message":{"timestamp":"2026-09-22T10:00:00Z","sender":"m1ngsama","plain_text":"hi","metadata":{"verified":true}}}').event, + ).toMatchObject({ message: { verified: true } }); + expect(parseTntLine('{"type":"identity.requested","session":"s-3","nickname":"anon"}')).toEqual({ + reply: null, + event: { type: "identity.requested", session: "s-3" }, + }); + expect(parseTntLine('{"type":"identity.requested"}')).toEqual({ reply: null, event: null }); + }); + test("parses presence records without answering them", () => { expect(parseTntLine('{"type":"presence.snapshot","nicknames":["a",1,"b"]}')).toEqual({ reply: null, diff --git a/gateway/tnt-module.json b/gateway/tnt-module.json index 0479955..2761e0d 100644 --- a/gateway/tnt-module.json +++ b/gateway/tnt-module.json @@ -2,9 +2,17 @@ "protocol": "tnt.module.v1", "name": "tnt-gateway", "version": "0.1.0", - "tnt_min_version": "1.4.0", + "tnt_min_version": "1.5.0", "description": "WebSocket gateway for the TNT public room.", "entrypoint": "./tnt-gateway", - "permissions": ["message:read", "message:create", "message:post", "presence:read"], - "events": ["message.created"] + "permissions": [ + "message:read", + "message:create", + "message:post", + "presence:read", + "identity:write" + ], + "events": [ + "message.created" + ] } From 695b3ee8002da1d1c42bc57837ec0ee6a2b29a76 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 14 Oct 2026 15:39:02 +0800 Subject: [PATCH 2/2] fix(gateway): send the sign-in link and code on separate lines --- gateway/src/identity.ts | 12 ++++++------ gateway/test/identity.test.ts | 7 ++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/gateway/src/identity.ts b/gateway/src/identity.ts index 86038f2..67d8c3b 100644 --- a/gateway/src/identity.ts +++ b/gateway/src/identity.ts @@ -15,7 +15,7 @@ export type SignInOptions = { type Grant = { device_code: string; user_code: string; verification_uri_complete: string; expires_in: number; interval: number }; -const UNAVAILABLE = "Sign-in is unavailable right now. Try :login again later. 登录暂时不可用,请稍后再试。"; +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 { @@ -28,10 +28,10 @@ export async function signIn(options: SignInOptions): Promise { if (!started.ok) return options.notice(UNAVAILABLE); const grant = (await started.json()) as Grant; const minutes = Math.round(grant.expires_in / 60); - options.notice( - `Open ${grant.verification_uri_complete} and approve code ${grant.user_code} within ${minutes} minutes. ` + - `在浏览器打开链接,确认验证码 ${grant.user_code}。`, - ); + // 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) { @@ -53,7 +53,7 @@ export async function signIn(options: SignInOptions): Promise { if (body.error === "access_denied") return options.notice("Sign-in was cancelled. 已取消登录。"); break; } - options.notice("The sign-in code expired. Run :login again. 验证码已过期,请重新执行 :login。"); + options.notice("The code expired. Run :login again. 验证码已过期。"); } catch { options.notice(UNAVAILABLE); } diff --git a/gateway/test/identity.test.ts b/gateway/test/identity.test.ts index 33a6178..902477c 100644 --- a/gateway/test/identity.test.ts +++ b/gateway/test/identity.test.ts @@ -43,7 +43,12 @@ describe("signIn", () => { { status: 400, body: { error: "slow_down" } }, { status: 200, body: { access_token: "good", token_type: "Bearer", expires_in: 300 } }, ]); - expect(result.notices[0]).toContain("https://id.test/device?code=BCDF-GHJK"); + expect(result.notices.slice(0, 3)).toEqual([ + "Open this link to sign in / 在浏览器打开链接登录:", + "https://id.test/device?code=BCDF-GHJK", + "Code / 验证码 BCDF-GHJK, 10 min", + ]); + for (const line of result.notices) expect(line.length).toBeLessThanOrEqual(60); expect(result.waits).toEqual([5000, 5000, 10000]); expect(result.verified).toEqual(["lune"]); expect(result.requests[0]).toBe("https://id.test/device/code client_id=tnt");