@kispi/chat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,311 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/server/index.ts
21
+ var server_exports = {};
22
+ __export(server_exports, {
23
+ DefaultTokenTtlSeconds: () => DefaultTokenTtlSeconds,
24
+ MaxTokenTtlSeconds: () => MaxTokenTtlSeconds,
25
+ SignatureToleranceMs: () => SignatureToleranceMs,
26
+ createChatServer: () => createChatServer
27
+ });
28
+ module.exports = __toCommonJS(server_exports);
29
+
30
+ // src/errors.ts
31
+ var ChatError = class extends Error {
32
+ code;
33
+ /** Present on rate limits, in milliseconds. */
34
+ retryAfterMs;
35
+ /** The consumer's own code, when a before_publish hook denied this. */
36
+ appCode;
37
+ constructor(code, message, extra) {
38
+ super(message);
39
+ this.name = "ChatError";
40
+ this.code = code;
41
+ if (extra?.retryAfterMs !== void 0) this.retryAfterMs = extra.retryAfterMs;
42
+ if (extra?.appCode !== void 0) this.appCode = extra.appCode;
43
+ }
44
+ };
45
+ function errorFrom(data, fallback = "the server refused the request") {
46
+ if (typeof data === "object" && data !== null) {
47
+ const d = data;
48
+ const inner = d["error"] ?? d;
49
+ const code = typeof inner["code"] === "string" ? inner["code"] : "internal";
50
+ const message = typeof inner["message"] === "string" ? inner["message"] : fallback;
51
+ const extra = {};
52
+ if (typeof inner["retryAfterMs"] === "number") extra.retryAfterMs = inner["retryAfterMs"];
53
+ if (typeof inner["appCode"] === "string") extra.appCode = inner["appCode"];
54
+ return new ChatError(code, message, extra);
55
+ }
56
+ return new ChatError("internal", fallback);
57
+ }
58
+
59
+ // src/server/api.ts
60
+ var ServerRest = class {
61
+ constructor(url, secretKey, doFetch) {
62
+ this.url = url;
63
+ this.secretKey = secretKey;
64
+ this.doFetch = doFetch;
65
+ }
66
+ url;
67
+ secretKey;
68
+ doFetch;
69
+ async call(method, path, body) {
70
+ const headers = { Authorization: `Bearer ${this.secretKey}` };
71
+ if (body !== void 0) headers["Content-Type"] = "application/json";
72
+ const res = await this.doFetch(`${this.url}${path}`, {
73
+ method,
74
+ headers,
75
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
76
+ });
77
+ if (res.status === 204) return void 0;
78
+ const text = await res.text();
79
+ const parsed = text === "" ? void 0 : JSON.parse(text);
80
+ if (!res.ok) throw errorFrom(parsed, `${method} ${path} failed with ${res.status}`);
81
+ return parsed;
82
+ }
83
+ query(params) {
84
+ const search = new URLSearchParams();
85
+ for (const [k, v] of Object.entries(params)) {
86
+ if (v !== void 0) search.set(k, String(v));
87
+ }
88
+ const s = search.toString();
89
+ return s === "" ? "" : `?${s}`;
90
+ }
91
+ };
92
+
93
+ // src/server/jwt.ts
94
+ var import_node_crypto = require("crypto");
95
+ function signHS256(header, claims, secret) {
96
+ const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
97
+ const mac = (0, import_node_crypto.createHmac)("sha256", secret).update(signingInput).digest();
98
+ return `${signingInput}.${mac.toString("base64url")}`;
99
+ }
100
+ function macMatches(expected, given) {
101
+ return expected.length === given.length && (0, import_node_crypto.timingSafeEqual)(expected, given);
102
+ }
103
+ function b64url(s) {
104
+ return Buffer.from(s, "utf8").toString("base64url");
105
+ }
106
+
107
+ // src/server/token.ts
108
+ var MaxTokenTtlSeconds = 24 * 60 * 60;
109
+ var DefaultTokenTtlSeconds = 60 * 60;
110
+ var MaxUserIdBytes = 128;
111
+ function tokenClaims(input, now) {
112
+ const ttl = input.ttlSeconds ?? DefaultTokenTtlSeconds;
113
+ if (!Number.isInteger(ttl) || ttl <= 0) {
114
+ throw new TypeError(`ttlSeconds must be a positive whole number of seconds, got ${input.ttlSeconds}`);
115
+ }
116
+ if (ttl > MaxTokenTtlSeconds) {
117
+ throw new RangeError(`ttlSeconds must be at most ${MaxTokenTtlSeconds} (24 hours), got ${ttl}`);
118
+ }
119
+ if (input.userId === "") {
120
+ throw new TypeError("userId is required");
121
+ }
122
+ if (Buffer.byteLength(input.userId, "utf8") > MaxUserIdBytes) {
123
+ throw new RangeError(`userId must be at most ${MaxUserIdBytes} bytes`);
124
+ }
125
+ const iat = Math.floor(now.getTime() / 1e3);
126
+ const claims = {
127
+ sub: input.userId,
128
+ name: input.name,
129
+ iat,
130
+ exp: iat + ttl
131
+ };
132
+ if (input.avatar !== void 0) claims["avatar"] = input.avatar;
133
+ if (input.meta !== void 0) claims["meta"] = input.meta;
134
+ return claims;
135
+ }
136
+
137
+ // src/server/webhooks.ts
138
+ var import_node_crypto2 = require("crypto");
139
+ var SignatureToleranceMs = 5 * 60 * 1e3;
140
+ var EventBeforePublish = "before_publish";
141
+ function verifyWebhook(secret, headers, rawBody, options = {}) {
142
+ const signature = headerOf(headers, "x-chat-signature");
143
+ if (signature === void 0) {
144
+ throw new Error("webhook: no X-Chat-Signature header");
145
+ }
146
+ const event = headerOf(headers, "x-chat-event");
147
+ if (event === void 0) {
148
+ throw new Error("webhook: X-Chat-Event is required");
149
+ }
150
+ const rawDelivery = headerOf(headers, "x-chat-delivery");
151
+ if (rawDelivery === void 0 && event !== EventBeforePublish) {
152
+ throw new Error("webhook: X-Chat-Delivery is required");
153
+ }
154
+ const delivery = rawDelivery ?? "";
155
+ const parts = signature.split(",");
156
+ let timestamp;
157
+ const candidates = [];
158
+ for (const part of parts) {
159
+ const [k, v] = part.trim().split("=", 2);
160
+ if (k === "t") timestamp = v;
161
+ else if (k === "v1" && v !== void 0) candidates.push(v);
162
+ }
163
+ if (timestamp === void 0 || candidates.length === 0) {
164
+ throw new Error(`webhook: signature header is missing t or v1: ${signature}`);
165
+ }
166
+ const seconds = Number.parseInt(timestamp, 10);
167
+ if (!Number.isFinite(seconds)) {
168
+ throw new Error(`webhook: signature t is not a unix timestamp: ${timestamp}`);
169
+ }
170
+ const now = options.now ?? /* @__PURE__ */ new Date();
171
+ const age = Math.abs(now.getTime() - seconds * 1e3);
172
+ const tolerance = options.tolerance ?? SignatureToleranceMs;
173
+ if (age > tolerance) {
174
+ throw new Error(`webhook: signature is ${Math.round(age / 1e3)}s old, tolerance is ${tolerance / 1e3}s`);
175
+ }
176
+ const expected = (0, import_node_crypto2.createHmac)("sha256", secret).update(`${timestamp}
177
+ ${event}
178
+ ${delivery}
179
+ ${rawBody}`).digest();
180
+ const matched = candidates.some((candidate) => {
181
+ const given = Buffer.from(candidate, "hex");
182
+ return given.length > 0 && macMatches(expected, given);
183
+ });
184
+ if (!matched) {
185
+ throw new Error("webhook: signature does not match");
186
+ }
187
+ return { event, delivery, data: rawBody === "" ? void 0 : JSON.parse(rawBody) };
188
+ }
189
+ function headerOf(headers, name) {
190
+ if (typeof headers.get === "function") {
191
+ return headers.get(name) ?? void 0;
192
+ }
193
+ const record = headers;
194
+ const value = record[name] ?? record[name.toUpperCase()] ?? record[titleCase(name)];
195
+ return Array.isArray(value) ? value[0] : value;
196
+ }
197
+ function titleCase(name) {
198
+ return name.split("-").map((p) => p === "" ? p : p[0].toUpperCase() + p.slice(1)).join("-");
199
+ }
200
+
201
+ // src/server/index.ts
202
+ function createChatServer(options) {
203
+ if (!options.secretKey.startsWith("sk_")) {
204
+ throw new TypeError("secretKey must be an sk_ key; a pk_ cannot sign tokens or reach server routes");
205
+ }
206
+ if (options.keyId === "") {
207
+ throw new TypeError("keyId is required: it becomes the token's kid, which tells the server which key signed it");
208
+ }
209
+ const rest = new ServerRest(options.url, options.secretKey, options.fetch ?? globalThis.fetch.bind(globalThis));
210
+ return {
211
+ token(input) {
212
+ return signHS256(
213
+ { alg: "HS256", typ: "JWT", kid: options.keyId },
214
+ tokenClaims(input, /* @__PURE__ */ new Date()),
215
+ options.secretKey
216
+ );
217
+ },
218
+ rooms: {
219
+ ensure: (input) => rest.call("PUT", "/v1/rooms", input),
220
+ ensureDM: (members) => rest.call("PUT", "/v1/rooms", { type: "dm", members }),
221
+ get: (roomId) => rest.call("GET", `/v1/rooms/${roomId}`),
222
+ update: (roomId, patch) => rest.call("PATCH", `/v1/rooms/${roomId}`, patch),
223
+ delete: async (roomId) => {
224
+ await rest.call("DELETE", `/v1/rooms/${roomId}`);
225
+ },
226
+ custom: async (roomId, payload) => {
227
+ await rest.call("POST", `/v1/rooms/${roomId}/custom`, { payload });
228
+ },
229
+ members: {
230
+ list: (roomId, o) => rest.call("GET", `/v1/rooms/${roomId}/members${rest.query({ cursor: o?.cursor, limit: o?.limit })}`),
231
+ add: async (roomId, userId, role) => {
232
+ await rest.call("PUT", `/v1/rooms/${roomId}/members/${userId}`, role === void 0 ? {} : { role });
233
+ },
234
+ remove: async (roomId, userId) => {
235
+ await rest.call("DELETE", `/v1/rooms/${roomId}/members/${userId}`);
236
+ }
237
+ }
238
+ },
239
+ messages: {
240
+ send: (roomId, input) => {
241
+ const body = {
242
+ clientMessageId: input.clientMessageId ?? `srv-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
243
+ sender: input.sender,
244
+ body: {
245
+ ...input.text === void 0 ? {} : { text: input.text },
246
+ ...input.attachments === void 0 ? {} : { attachments: input.attachments }
247
+ }
248
+ };
249
+ if (input.kind !== void 0) body["kind"] = input.kind;
250
+ if (input.appMeta !== void 0) body["appMeta"] = input.appMeta;
251
+ if (input.replyTo !== void 0) body["replyTo"] = input.replyTo;
252
+ if (input.threadId !== void 0) body["threadId"] = input.threadId;
253
+ return rest.call("POST", `/v1/rooms/${roomId}/messages`, body);
254
+ },
255
+ list: (roomId, o) => rest.call(
256
+ "GET",
257
+ `/v1/rooms/${roomId}/messages${rest.query({ before: o?.before, after: o?.after, limit: o?.limit })}`
258
+ ),
259
+ delete: async (roomId, messageId) => {
260
+ await rest.call("DELETE", `/v1/rooms/${roomId}/messages/${messageId}`);
261
+ }
262
+ },
263
+ users: {
264
+ list: (o) => rest.call("GET", `/v1/users${rest.query({ cursor: o?.cursor, limit: o?.limit })}`),
265
+ update: (userId, patch) => rest.call("PUT", `/v1/users/${userId}`, patch),
266
+ // `purge_messages`, not `purgeMessages`: the option is named in
267
+ // this SDK's style and the query parameter in the wire's, and
268
+ // those are two different names for the same thing rather than
269
+ // one name used twice. This sent the SDK spelling and the server
270
+ // read no parameter at all -- so a purge request was answered
271
+ // with 200 and `purgedMessages: 0`, the identity anonymized and
272
+ // the messages left standing. Nothing failed; the caller was told
273
+ // it had worked.
274
+ delete: (userId, o) => rest.call("DELETE", `/v1/users/${userId}${rest.query({ purge_messages: o?.purgeMessages ? "true" : void 0 })}`),
275
+ revokeTokens: async (userId) => {
276
+ await rest.call("POST", `/v1/users/${userId}/revoke-tokens`);
277
+ },
278
+ ban: async (userId, input) => {
279
+ await rest.call("PUT", `/v1/users/${userId}/ban`, input);
280
+ },
281
+ unban: async (userId) => {
282
+ await rest.call("DELETE", `/v1/users/${userId}/ban`);
283
+ },
284
+ disconnect: (userId, o) => rest.call("POST", `/v1/users/${userId}/disconnect`, o ?? {})
285
+ },
286
+ events: {
287
+ // The cursor is a position, and a row committed late can appear
288
+ // behind a page already walked. The server documents the recovery
289
+ // -- read the last page again, or step `after` back a little --
290
+ // and the SDK does **not** do it silently: a consumer dedups on
291
+ // `delivery` anyway, and a hidden re-read would double the work
292
+ // for consumers who already got that right.
293
+ list: (o) => rest.call("GET", `/v1/events${rest.query({ after: o?.after, limit: o?.limit })}`)
294
+ },
295
+ webhooks: {
296
+ verify: (headers, rawBody, verifyOptions) => {
297
+ if (options.webhookSecret === void 0) {
298
+ throw new TypeError("webhookSecret was not given to createChatServer, so a delivery cannot be verified");
299
+ }
300
+ return verifyWebhook(options.webhookSecret, headers, rawBody, verifyOptions ?? {});
301
+ }
302
+ }
303
+ };
304
+ }
305
+ // Annotate the CommonJS export names for ESM import in node:
306
+ 0 && (module.exports = {
307
+ DefaultTokenTtlSeconds,
308
+ MaxTokenTtlSeconds,
309
+ SignatureToleranceMs,
310
+ createChatServer
311
+ });
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The claims of a token-mode user token (spec: "유저 토큰 (token 모드)").
3
+ *
4
+ * `userId` is the consumer's own id for the person and becomes `sub`. The
5
+ * chat server upserts `(app_id, sub)` on connect and takes `name` and
6
+ * `avatar` from here, which is why a rename is a re-issued token rather
7
+ * than a call.
8
+ */
9
+ type TokenInput = {
10
+ userId: string;
11
+ name: string;
12
+ avatar?: string;
13
+ /** Merged into `users.meta.provider`. The consumer's trusted channel. */
14
+ meta?: Record<string, unknown>;
15
+ /** Default 3600. The server refuses anything over a day. */
16
+ ttlSeconds?: number;
17
+ };
18
+ /** The spec's ceiling: `exp - iat ≤ 24h`. */
19
+ declare const MaxTokenTtlSeconds: number;
20
+ declare const DefaultTokenTtlSeconds: number;
21
+
22
+ /** How old a signature may be. The server signs with the same window. */
23
+ declare const SignatureToleranceMs: number;
24
+ type VerifiedDelivery = {
25
+ /** `X-Chat-Event`, e.g. `message.created`. */
26
+ event: string;
27
+ /**
28
+ * `X-Chat-Delivery`. **The dedup key** for a queued delivery --
29
+ * delivery is at-least-once. Empty for `before_publish`, which is a
30
+ * synchronous question with nothing to dedup on.
31
+ */
32
+ delivery: string;
33
+ /** The parsed body. */
34
+ data: unknown;
35
+ };
36
+ type VerifyOptions = {
37
+ /** Now, for tests. Defaults to the real clock. */
38
+ now?: Date;
39
+ tolerance?: number;
40
+ };
41
+
42
+ type ChatServerOptions = {
43
+ /** The chat server's base URL, e.g. `https://chat.example.com`. */
44
+ url: string;
45
+ /** An `sk_` key. Never ship this to a browser. */
46
+ secretKey: string;
47
+ /**
48
+ * The id of the row that `secretKey` came from, printed by
49
+ * `chat-server app create --json`.
50
+ *
51
+ * It goes in the token's `kid`, which tells the server which key to
52
+ * verify with. Without it the server tries every unrevoked `sk_` the
53
+ * app has -- which works, and is the compatibility path for tokens
54
+ * minted before this SDK existed, not a mode to choose.
55
+ */
56
+ keyId: string;
57
+ /** Needed only by `webhooks.verify`. */
58
+ webhookSecret?: string;
59
+ /** Defaults to the global. */
60
+ fetch?: typeof globalThis.fetch;
61
+ };
62
+ type EnsureRoomInput = {
63
+ key?: string;
64
+ type: 'public' | 'private' | 'channel' | 'dm';
65
+ name?: string;
66
+ meta?: Record<string, unknown>;
67
+ members?: string[];
68
+ };
69
+ type ServerSendInput = {
70
+ clientMessageId?: string;
71
+ kind?: 'text' | 'system';
72
+ text?: string;
73
+ attachments?: unknown[];
74
+ /** The app's own annotation. A user token cannot set this. */
75
+ appMeta?: Record<string, unknown>;
76
+ /** Required: an sk_ has no user identity to attribute the message to. */
77
+ sender: {
78
+ id: string;
79
+ name: string;
80
+ avatar?: string;
81
+ };
82
+ replyTo?: string;
83
+ threadId?: string;
84
+ };
85
+ type ChatServer = {
86
+ /** Signs a user token locally. No network call. */
87
+ token: (input: TokenInput) => string;
88
+ rooms: {
89
+ /** get-or-create. The same call whether the room exists or not. */
90
+ ensure: (input: EnsureRoomInput) => Promise<{
91
+ id: string;
92
+ key?: string;
93
+ type: string;
94
+ }>;
95
+ /** get-or-create for the two-person room these two share. */
96
+ ensureDM: (members: [string, string]) => Promise<{
97
+ id: string;
98
+ type: string;
99
+ }>;
100
+ get: (roomId: string) => Promise<unknown>;
101
+ update: (roomId: string, patch: {
102
+ key?: string;
103
+ name?: string;
104
+ meta?: Record<string, unknown>;
105
+ }) => Promise<unknown>;
106
+ delete: (roomId: string) => Promise<void>;
107
+ /**
108
+ * Broadcasts an app-authored `custom` event into the room.
109
+ *
110
+ * The payload reaches `room.on('custom', ...)` unchanged and the
111
+ * server neither stores nor interprets it. This is the control
112
+ * channel -- "refresh now", "an alert fired" -- not a message: it
113
+ * leaves no history, no unread count and no webhook, so a client
114
+ * that was offline when it went out never learns of it. A signal
115
+ * that must survive a reconnect is a `kind: 'system'` message.
116
+ *
117
+ * A JSON object, bounded by the server (a control signal has to fit
118
+ * in one frame). `sk_` only, like every other route on this object.
119
+ */
120
+ custom: (roomId: string, payload: Record<string, unknown>) => Promise<void>;
121
+ members: {
122
+ list: (roomId: string, options?: {
123
+ cursor?: string;
124
+ limit?: number;
125
+ }) => Promise<unknown>;
126
+ add: (roomId: string, userId: string, role?: string) => Promise<void>;
127
+ remove: (roomId: string, userId: string) => Promise<void>;
128
+ };
129
+ };
130
+ messages: {
131
+ send: (roomId: string, input: ServerSendInput) => Promise<{
132
+ messageId: string;
133
+ seq: number;
134
+ }>;
135
+ list: (roomId: string, options?: {
136
+ before?: number;
137
+ after?: number;
138
+ limit?: number;
139
+ }) => Promise<unknown>;
140
+ delete: (roomId: string, messageId: string) => Promise<void>;
141
+ };
142
+ users: {
143
+ /**
144
+ * The console's user list, newest first.
145
+ *
146
+ * `cursor` is opaque -- do not parse it -- and an empty one means
147
+ * this was the last page. `limit` defaults to 50 and caps at 100.
148
+ *
149
+ * **Withdrawn identities are on this list**, carrying `deletedAt`.
150
+ * That is the opposite of the room-scoped listings, which hide a
151
+ * withdrawn user's id: those are readable by other *users*, and
152
+ * this one needs an `sk_`, so the only caller is the backend that
153
+ * issued the id. A consumer syncing live users filters on
154
+ * `deletedAt`.
155
+ */
156
+ list: (options?: {
157
+ cursor?: string;
158
+ limit?: number;
159
+ }) => Promise<{
160
+ users: unknown[];
161
+ cursor: string;
162
+ }>;
163
+ update: (userId: string, patch: {
164
+ name?: string;
165
+ avatar?: string;
166
+ meta?: Record<string, unknown>;
167
+ }) => Promise<unknown>;
168
+ /** Withdrawal: anonymises rather than deleting rows. */
169
+ delete: (userId: string, options?: {
170
+ purgeMessages?: boolean;
171
+ }) => Promise<unknown>;
172
+ revokeTokens: (userId: string) => Promise<void>;
173
+ /**
174
+ * Bans until a moment, given as **unix milliseconds**.
175
+ *
176
+ * `until` is required and the server refuses the call without it: an
177
+ * indefinite ban is a far-future date the caller chose, which shows
178
+ * up in an audit as a decision, where a missing field would show up
179
+ * as an accident.
180
+ */
181
+ ban: (userId: string, input: {
182
+ until: number;
183
+ reason?: string;
184
+ }) => Promise<void>;
185
+ unban: (userId: string) => Promise<void>;
186
+ disconnect: (userId: string, options?: {
187
+ connectionId?: string;
188
+ reason?: string;
189
+ }) => Promise<unknown>;
190
+ };
191
+ events: {
192
+ /** The outbox, for catching up on webhooks that were missed. */
193
+ list: (options?: {
194
+ after?: string;
195
+ limit?: number;
196
+ }) => Promise<{
197
+ events: unknown[];
198
+ cursor: string;
199
+ }>;
200
+ };
201
+ webhooks: {
202
+ /** Verifies a delivery's signature. Needs the raw body, not a parsed one. */
203
+ verify: (headers: Headers | Record<string, string | string[] | undefined>, rawBody: string, options?: VerifyOptions) => VerifiedDelivery;
204
+ };
205
+ };
206
+ declare function createChatServer(options: ChatServerOptions): ChatServer;
207
+
208
+ export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, MaxTokenTtlSeconds, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };