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..67d8c3b --- /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 now. 登录暂时不可用,请稍后再试。"; + +/** 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); + // 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); + } +} 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..902477c --- /dev/null +++ b/gateway/test/identity.test.ts @@ -0,0 +1,89 @@ +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.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"); + 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" + ] }