@kispi/chat 0.1.2 → 0.2.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.
- package/README.md +103 -8
- package/dist/{chunk-T3QOUFOM.js → chunk-5QQRM7BH.js} +2 -0
- package/dist/index.cjs +145 -14
- package/dist/index.d.cts +96 -6
- package/dist/index.d.ts +96 -6
- package/dist/index.js +144 -15
- package/dist/server/index.cjs +59 -10
- package/dist/server/index.d.cts +52 -1
- package/dist/server/index.d.ts +52 -1
- package/dist/server/index.js +57 -11
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ChatError,
|
|
3
3
|
errorFrom
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-5QQRM7BH.js";
|
|
5
5
|
|
|
6
6
|
// src/emitter.ts
|
|
7
7
|
var Emitter = class {
|
|
@@ -57,6 +57,16 @@ var Rest = class {
|
|
|
57
57
|
}
|
|
58
58
|
async call(method, path, body) {
|
|
59
59
|
const token = await this.options.token();
|
|
60
|
+
try {
|
|
61
|
+
return await this.callWith(token, method, path, body);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (!(err instanceof ChatError) || err.status !== 401 || this.options.refreshToken === void 0) throw err;
|
|
64
|
+
const fresh = await this.options.refreshToken().catch(() => void 0);
|
|
65
|
+
if (fresh === void 0 || fresh === token) throw err;
|
|
66
|
+
return this.callWith(fresh, method, path, body);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async callWith(token, method, path, body) {
|
|
60
70
|
if (token === void 0) {
|
|
61
71
|
throw new ChatError(
|
|
62
72
|
"unauthorized",
|
|
@@ -78,6 +88,7 @@ var Rest = class {
|
|
|
78
88
|
const parsed = text === "" ? void 0 : JSON.parse(text);
|
|
79
89
|
if (!res.ok) {
|
|
80
90
|
const err = errorFrom(parsed, `${method} ${path} failed with ${res.status}`);
|
|
91
|
+
err.status = res.status;
|
|
81
92
|
throw err;
|
|
82
93
|
}
|
|
83
94
|
return parsed;
|
|
@@ -124,10 +135,24 @@ var Timeline = class {
|
|
|
124
135
|
* rows into the new list.
|
|
125
136
|
*/
|
|
126
137
|
epoch = 0;
|
|
138
|
+
/**
|
|
139
|
+
* `items` has been handed out -- by the getter or by `onChange`.
|
|
140
|
+
*
|
|
141
|
+
* 한 번 내준 배열은 다시 건드리지 않는다. 같은 배열을 제자리에서 고쳐
|
|
142
|
+
* 다시 내주면 Svelte `$state.raw`, React `useState`, Vue `shallowRef`처럼
|
|
143
|
+
* 참조가 바뀌어야 다시 그리는 쪽은 변화를 보지 못하고 화면이 멈춘다. 그래서
|
|
144
|
+
* 내준 뒤의 첫 변경은 복사본에서 한다(copy-on-write). 행 객체도 같은 규칙이라
|
|
145
|
+
* 바뀐 행만 새 객체다.
|
|
146
|
+
*/
|
|
147
|
+
shared = false;
|
|
148
|
+
/** The `loadOlder` in flight, so a scroll handler firing twice asks once. */
|
|
149
|
+
older;
|
|
127
150
|
constructor(options) {
|
|
128
151
|
this.options = options;
|
|
129
152
|
}
|
|
153
|
+
/** A new array whenever the list changes; never mutated once returned. */
|
|
130
154
|
get messages() {
|
|
155
|
+
this.shared = true;
|
|
131
156
|
return this.items;
|
|
132
157
|
}
|
|
133
158
|
/** The highest seq this timeline holds, hole or no hole. */
|
|
@@ -174,6 +199,7 @@ var Timeline = class {
|
|
|
174
199
|
this.fills.clear();
|
|
175
200
|
this.pendingDeletes.clear();
|
|
176
201
|
this.items = [];
|
|
202
|
+
this.shared = false;
|
|
177
203
|
this.changed();
|
|
178
204
|
}
|
|
179
205
|
/**
|
|
@@ -200,6 +226,7 @@ var Timeline = class {
|
|
|
200
226
|
const page = [...messages].sort((a, b) => a.seq - b.seq);
|
|
201
227
|
const top = page.at(-1)?.seq ?? 0;
|
|
202
228
|
this.items = [...page, ...this.items.filter((m) => m.seq > top)];
|
|
229
|
+
this.shared = false;
|
|
203
230
|
for (const m of this.items) {
|
|
204
231
|
if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
|
|
205
232
|
}
|
|
@@ -234,6 +261,7 @@ var Timeline = class {
|
|
|
234
261
|
if (at === -1) return;
|
|
235
262
|
const current = this.items[at];
|
|
236
263
|
if (current.deletedAt !== void 0) return;
|
|
264
|
+
this.own();
|
|
237
265
|
this.items[at] = { ...current, ...message };
|
|
238
266
|
this.changed();
|
|
239
267
|
}
|
|
@@ -260,6 +288,7 @@ var Timeline = class {
|
|
|
260
288
|
if (count <= 0) next = existing.filter((r) => r.emoji !== emoji);
|
|
261
289
|
else if (slot === -1) next = [...existing, { emoji, count }];
|
|
262
290
|
else next = existing.map((r, i) => i === slot ? { emoji, count } : r);
|
|
291
|
+
this.own();
|
|
263
292
|
this.items[at] = { ...current, reactions: next };
|
|
264
293
|
this.changed();
|
|
265
294
|
}
|
|
@@ -268,6 +297,7 @@ var Timeline = class {
|
|
|
268
297
|
const at = this.items.findIndex((m) => m.id === rootId);
|
|
269
298
|
if (at === -1) return;
|
|
270
299
|
const current = this.items[at];
|
|
300
|
+
this.own();
|
|
271
301
|
this.items[at] = { ...current, thread: lastSeq === void 0 ? { count } : { count, lastSeq } };
|
|
272
302
|
this.changed();
|
|
273
303
|
}
|
|
@@ -275,6 +305,36 @@ var Timeline = class {
|
|
|
275
305
|
remove(id) {
|
|
276
306
|
if (!this.applyDelete(id)) this.pendingDeletes.add(id);
|
|
277
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* Prepends the page just below the oldest message held.
|
|
310
|
+
*
|
|
311
|
+
* 스크롤을 올려 과거를 읽는 소비자가 `history()` 결과를 라이브 목록과 따로
|
|
312
|
+
* 들고 합치고, 중복을 걸러 내고, reset과 재접속을 따로 처리해야 했다. 같은
|
|
313
|
+
* 목록에 끼워 넣으면 그 일이 전부 이미 있는 규칙(seq 자리에 넣기, 같은 seq는
|
|
314
|
+
* 한 행, reset이면 epoch로 버리기)으로 끝난다.
|
|
315
|
+
*
|
|
316
|
+
* `hasMore`는 더 올라갈 것이 있는지다. 서버의 seq는 1부터 구멍 없이 붙으므로
|
|
317
|
+
* 맨 위가 1이거나 페이지가 덜 찼으면 끝이다(보존 기간이 지운 앞부분도 덜 찬
|
|
318
|
+
* 페이지로 드러난다). 한 번에 하나만 돈다 — 스크롤 핸들러가 두 번 불러도
|
|
319
|
+
* 요청은 하나다(진행 중인 호출과 같은 결과를 받으므로 뒤 호출의 `limit`은 쓰이지 않는다).
|
|
320
|
+
*/
|
|
321
|
+
loadOlder(limit = this.options.pageSize ?? MaxPageSize) {
|
|
322
|
+
this.older ??= this.readOlder(Math.min(limit, MaxPageSize)).finally(() => {
|
|
323
|
+
this.older = void 0;
|
|
324
|
+
});
|
|
325
|
+
return this.older;
|
|
326
|
+
}
|
|
327
|
+
async readOlder(limit) {
|
|
328
|
+
const first = this.items[0];
|
|
329
|
+
if (first === void 0 || first.seq <= 1) return { messages: [], hasMore: false };
|
|
330
|
+
const epoch = this.epoch;
|
|
331
|
+
const page = await this.options.fetchRange({ after: 0, before: first.seq, limit });
|
|
332
|
+
if (this.epoch !== epoch) return { messages: [], hasMore: true };
|
|
333
|
+
for (const m of page) this.insert(m, false);
|
|
334
|
+
if (page.length > 0) this.changed();
|
|
335
|
+
const top = page[0];
|
|
336
|
+
return { messages: page, hasMore: page.length >= limit && top !== void 0 && top.seq > 1 };
|
|
337
|
+
}
|
|
278
338
|
/**
|
|
279
339
|
* Fetches everything between what we have and `upTo`.
|
|
280
340
|
*
|
|
@@ -319,10 +379,11 @@ var Timeline = class {
|
|
|
319
379
|
return run;
|
|
320
380
|
}
|
|
321
381
|
/** Inserts at the seq position, replacing an existing row with that seq. */
|
|
322
|
-
insert(message) {
|
|
382
|
+
insert(message, notify = true) {
|
|
323
383
|
const pending = this.pendingDeletes.has(message.id);
|
|
324
384
|
if (pending) this.pendingDeletes.delete(message.id);
|
|
325
385
|
const withDelete = pending ? emptied(message) : message;
|
|
386
|
+
this.own();
|
|
326
387
|
const at = this.items.findIndex((m) => m.seq >= withDelete.seq);
|
|
327
388
|
if (at === -1) {
|
|
328
389
|
this.items.push(withDelete);
|
|
@@ -332,17 +393,25 @@ var Timeline = class {
|
|
|
332
393
|
} else {
|
|
333
394
|
this.items.splice(at, 0, withDelete);
|
|
334
395
|
}
|
|
335
|
-
this.changed();
|
|
396
|
+
if (notify) this.changed();
|
|
336
397
|
}
|
|
337
398
|
applyDelete(id) {
|
|
338
399
|
const at = this.items.findIndex((m) => m.id === id);
|
|
339
400
|
if (at === -1) return false;
|
|
340
401
|
this.pendingDeletes.delete(id);
|
|
402
|
+
this.own();
|
|
341
403
|
this.items[at] = emptied(this.items[at]);
|
|
342
404
|
this.changed();
|
|
343
405
|
return true;
|
|
344
406
|
}
|
|
407
|
+
/** Makes `items` safe to mutate: a copy, if the current one was handed out. */
|
|
408
|
+
own() {
|
|
409
|
+
if (!this.shared) return;
|
|
410
|
+
this.items = this.items.slice();
|
|
411
|
+
this.shared = false;
|
|
412
|
+
}
|
|
345
413
|
changed() {
|
|
414
|
+
this.shared = true;
|
|
346
415
|
this.options.onChange?.(this.items);
|
|
347
416
|
}
|
|
348
417
|
};
|
|
@@ -407,10 +476,27 @@ var Room = class extends Emitter {
|
|
|
407
476
|
onChange: (messages) => this.emit("messages", messages)
|
|
408
477
|
});
|
|
409
478
|
}
|
|
410
|
-
/** Everything this client knows about the room, in seq order. */
|
|
479
|
+
/** Everything this client knows about the room, in seq order. A new array whenever it changes. */
|
|
411
480
|
get messages() {
|
|
412
481
|
return this.timeline.messages;
|
|
413
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* Reads the page just before the oldest message held and **prepends it to
|
|
485
|
+
* `messages`**.
|
|
486
|
+
*
|
|
487
|
+
* The scroll-back call. Unlike `history()`, the rows join the room's own
|
|
488
|
+
* list, so ordering, de-duplication, deletes, reactions and reconnects
|
|
489
|
+
* apply to them like any other row, and the `messages` event fires once.
|
|
490
|
+
* A `reset` (or `reload()`) drops them with everything else; a read that
|
|
491
|
+
* lands after one is discarded rather than stitched onto the new list.
|
|
492
|
+
*
|
|
493
|
+
* `hasMore` is false once the top of the room -- or of its retention --
|
|
494
|
+
* is reached. Concurrent calls share one request.
|
|
495
|
+
*/
|
|
496
|
+
async loadOlder(options = {}) {
|
|
497
|
+
await this.resolveId("loading older messages");
|
|
498
|
+
return this.timeline.loadOlder(options.limit);
|
|
499
|
+
}
|
|
414
500
|
/**
|
|
415
501
|
* Reloads recent history, discarding what is held.
|
|
416
502
|
*
|
|
@@ -484,7 +570,8 @@ var Room = class extends Emitter {
|
|
|
484
570
|
*
|
|
485
571
|
* The list this room keeps is the live one; this is for a consumer
|
|
486
572
|
* scrolling back, which owns its own window and does not want the
|
|
487
|
-
* bottom of the room rearranged under it.
|
|
573
|
+
* bottom of the room rearranged under it. **Scrolling the room's own
|
|
574
|
+
* list back is `loadOlder()`**, which keeps one list instead of two.
|
|
488
575
|
*
|
|
489
576
|
* `view` defaults to the server's, which is every message including
|
|
490
577
|
* thread replies. **`view: 'main'` is a display filter, not a sync
|
|
@@ -493,7 +580,7 @@ var Room = class extends Emitter {
|
|
|
493
580
|
*/
|
|
494
581
|
async history(options = {}) {
|
|
495
582
|
const view = options.view === void 0 ? void 0 : typeof options.view === "string" ? options.view : `thread:${options.view.thread}`;
|
|
496
|
-
const page = await this.rest.get(`/v1/rooms/${this.
|
|
583
|
+
const page = await this.rest.get(`/v1/rooms/${await this.resolveId("reading history")}/messages`, {
|
|
497
584
|
before: options.before,
|
|
498
585
|
after: options.after,
|
|
499
586
|
limit: options.limit,
|
|
@@ -503,10 +590,10 @@ var Room = class extends Emitter {
|
|
|
503
590
|
}
|
|
504
591
|
/** Adds a reaction. Idempotent, like the frame. */
|
|
505
592
|
async react(messageId, emoji) {
|
|
506
|
-
await this.chat.send("react", { roomId: this.
|
|
593
|
+
await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
|
|
507
594
|
}
|
|
508
595
|
async unreact(messageId, emoji) {
|
|
509
|
-
await this.chat.send("react", { roomId: this.
|
|
596
|
+
await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
|
|
510
597
|
}
|
|
511
598
|
/**
|
|
512
599
|
* Moves this user's read cursor.
|
|
@@ -516,7 +603,7 @@ var Room = class extends Emitter {
|
|
|
516
603
|
* way to know what happened. It is returned rather than swallowed.
|
|
517
604
|
*/
|
|
518
605
|
async markRead(seq, options = {}) {
|
|
519
|
-
const data = { roomId: this.
|
|
606
|
+
const data = { roomId: await this.resolveId("marking read"), seq };
|
|
520
607
|
if (options.threadId !== void 0) data["threadId"] = options.threadId;
|
|
521
608
|
return await this.chat.send("read", data);
|
|
522
609
|
}
|
|
@@ -543,15 +630,15 @@ var Room = class extends Emitter {
|
|
|
543
630
|
}
|
|
544
631
|
/** Joins a public or channel room. Idempotent. */
|
|
545
632
|
async join() {
|
|
546
|
-
await this.rest.post(`/v1/rooms/${this.
|
|
633
|
+
await this.rest.post(`/v1/rooms/${await this.resolveId("joining")}/join`);
|
|
547
634
|
}
|
|
548
635
|
/** Leaves. Idempotent from the caller's side. */
|
|
549
636
|
async leave() {
|
|
550
|
-
await this.rest.delete(`/v1/rooms/${this.
|
|
637
|
+
await this.rest.delete(`/v1/rooms/${await this.resolveId("leaving")}/members/me`);
|
|
551
638
|
}
|
|
552
639
|
/** The full presence list, for rooms too large to send it in the ack. */
|
|
553
640
|
async presenceList() {
|
|
554
|
-
return this.rest.get(`/v1/rooms/${this.
|
|
641
|
+
return this.rest.get(`/v1/rooms/${await this.resolveId("reading presence")}/presence`, { full: "true" });
|
|
555
642
|
}
|
|
556
643
|
/**
|
|
557
644
|
* Who reacted, one row per (user, emoji) pair.
|
|
@@ -563,7 +650,7 @@ var Room = class extends Emitter {
|
|
|
563
650
|
* most, and a full page still carries a cursor when more follows.
|
|
564
651
|
*/
|
|
565
652
|
async reactionsOf(messageId, options = {}) {
|
|
566
|
-
return this.rest.get(`/v1/rooms/${this.
|
|
653
|
+
return this.rest.get(`/v1/rooms/${await this.resolveId("reading reactions")}/messages/${messageId}/reactions`, {
|
|
567
654
|
emoji: options.emoji,
|
|
568
655
|
cursor: options.cursor,
|
|
569
656
|
limit: options.limit
|
|
@@ -629,9 +716,22 @@ var Room = class extends Emitter {
|
|
|
629
716
|
}
|
|
630
717
|
return this.id;
|
|
631
718
|
}
|
|
719
|
+
/**
|
|
720
|
+
* The room's id, waiting for a subscribe in flight to learn it.
|
|
721
|
+
*
|
|
722
|
+
* `roomByKey(k).subscribe()`를 await하지 않고 곧바로 `send`하는 것은 자연스러운
|
|
723
|
+
* 코드이고, 그때 id는 구독 ack가 와야 생긴다. 거절(`closed`)하면 소비자는
|
|
724
|
+
* "보내도 되는 때"를 알릴 신호를 따로 찾아야 한다 — 이미 날아가고 있는
|
|
725
|
+
* 구독을 기다리면 그 신호가 필요 없다. 구독이 실패하면 그 실패가 그대로
|
|
726
|
+
* 나간다. 구독한 적이 없으면 기다릴 것이 없으니 예전처럼 거절한다.
|
|
727
|
+
*/
|
|
728
|
+
async resolveId(what) {
|
|
729
|
+
while (this.id === void 0 && this.subscribing !== void 0) await this.subscribing;
|
|
730
|
+
return this.requireId(what);
|
|
731
|
+
}
|
|
632
732
|
/** Publishes and resolves when the server acks. */
|
|
633
733
|
async send(input, options = {}) {
|
|
634
|
-
const roomId = this.
|
|
734
|
+
const roomId = await this.resolveId("sending");
|
|
635
735
|
const body = {};
|
|
636
736
|
if (input.text !== void 0) body["text"] = input.text;
|
|
637
737
|
if (input.entities !== void 0) body["entities"] = input.entities;
|
|
@@ -763,6 +863,7 @@ var ChatClient = class extends Emitter {
|
|
|
763
863
|
url: options.restURL ?? restURLFrom(options.url),
|
|
764
864
|
key: options.key,
|
|
765
865
|
token: async () => this.lastToken,
|
|
866
|
+
refreshToken: () => this.refreshToken(),
|
|
766
867
|
fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
767
868
|
});
|
|
768
869
|
this.setupLifecycle();
|
|
@@ -983,7 +1084,13 @@ var ChatClient = class extends Emitter {
|
|
|
983
1084
|
}
|
|
984
1085
|
async openOnce() {
|
|
985
1086
|
this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
|
|
986
|
-
|
|
1087
|
+
let authData;
|
|
1088
|
+
try {
|
|
1089
|
+
authData = await this.authData();
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
this.scheduleReconnect();
|
|
1092
|
+
throw err;
|
|
1093
|
+
}
|
|
987
1094
|
if (this.closedByCaller) {
|
|
988
1095
|
throw new ChatError("closed", "the client was closed while connecting");
|
|
989
1096
|
}
|
|
@@ -1152,6 +1259,28 @@ var ChatClient = class extends Emitter {
|
|
|
1152
1259
|
data["token"] = token;
|
|
1153
1260
|
return data;
|
|
1154
1261
|
}
|
|
1262
|
+
/**
|
|
1263
|
+
* REST가 401을 받았을 때 토큰을 새로 받는다.
|
|
1264
|
+
*
|
|
1265
|
+
* 소켓은 접속할 때 한 번 인증하고 그 뒤로는 토큰을 다시 보지 않지만, REST는
|
|
1266
|
+
* 요청마다 본다. 그래서 한 시간짜리 토큰이면 한 시간 뒤 라이브는 멀쩡한데
|
|
1267
|
+
* 히스토리·구멍 메우기만 401이 된다. 동시에 실패한 요청들이 `token()`을
|
|
1268
|
+
* 각자 부르지 않도록 진행 중인 것 하나를 나눠 쓴다.
|
|
1269
|
+
*/
|
|
1270
|
+
refreshToken() {
|
|
1271
|
+
if (this.options.externalToken !== void 0) return Promise.resolve(void 0);
|
|
1272
|
+
this.refreshing ??= (async () => {
|
|
1273
|
+
try {
|
|
1274
|
+
const token = await this.options.token();
|
|
1275
|
+
this.lastToken = token;
|
|
1276
|
+
return token;
|
|
1277
|
+
} finally {
|
|
1278
|
+
this.refreshing = void 0;
|
|
1279
|
+
}
|
|
1280
|
+
})();
|
|
1281
|
+
return this.refreshing;
|
|
1282
|
+
}
|
|
1283
|
+
refreshing;
|
|
1155
1284
|
failPending(err) {
|
|
1156
1285
|
for (const [, waiter] of this.pending) waiter.reject(err);
|
|
1157
1286
|
this.pending.clear();
|
package/dist/server/index.cjs
CHANGED
|
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var server_exports = {};
|
|
22
22
|
__export(server_exports, {
|
|
23
23
|
DefaultTokenTtlSeconds: () => DefaultTokenTtlSeconds,
|
|
24
|
+
GuestIdPrefix: () => GuestIdPrefix,
|
|
24
25
|
MaxTokenTtlSeconds: () => MaxTokenTtlSeconds,
|
|
25
26
|
SignatureToleranceMs: () => SignatureToleranceMs,
|
|
26
27
|
createChatServer: () => createChatServer
|
|
@@ -34,6 +35,8 @@ var ChatError = class extends Error {
|
|
|
34
35
|
retryAfterMs;
|
|
35
36
|
/** The consumer's own code, when a before_publish hook denied this. */
|
|
36
37
|
appCode;
|
|
38
|
+
/** The HTTP status, when this came from a REST call. */
|
|
39
|
+
status;
|
|
37
40
|
constructor(code, message, extra) {
|
|
38
41
|
super(message);
|
|
39
42
|
this.name = "ChatError";
|
|
@@ -90,12 +93,15 @@ var ServerRest = class {
|
|
|
90
93
|
}
|
|
91
94
|
};
|
|
92
95
|
|
|
96
|
+
// src/server/guest.ts
|
|
97
|
+
var import_node_crypto2 = require("crypto");
|
|
98
|
+
|
|
93
99
|
// src/server/jwt.ts
|
|
94
100
|
var import_node_crypto = require("crypto");
|
|
95
101
|
function signHS256(header, claims, secret) {
|
|
96
102
|
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
|
|
97
|
-
const
|
|
98
|
-
return `${signingInput}.${
|
|
103
|
+
const mac2 = (0, import_node_crypto.createHmac)("sha256", secret).update(signingInput).digest();
|
|
104
|
+
return `${signingInput}.${mac2.toString("base64url")}`;
|
|
99
105
|
}
|
|
100
106
|
function macMatches(expected, given) {
|
|
101
107
|
return expected.length === given.length && (0, import_node_crypto.timingSafeEqual)(expected, given);
|
|
@@ -104,6 +110,32 @@ function b64url(s) {
|
|
|
104
110
|
return Buffer.from(s, "utf8").toString("base64url");
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
// src/server/guest.ts
|
|
114
|
+
var GuestIdPrefix = "g_";
|
|
115
|
+
var CredentialVersion = "g1";
|
|
116
|
+
var MacLabel = "kispi-chat/guest/v1\n";
|
|
117
|
+
var MinGuestSecretLength = 32;
|
|
118
|
+
function newGuestId() {
|
|
119
|
+
return `${GuestIdPrefix}${(0, import_node_crypto2.randomBytes)(16).toString("base64url")}`;
|
|
120
|
+
}
|
|
121
|
+
function signGuestCredential(secret, userId) {
|
|
122
|
+
return `${CredentialVersion}.${userId}.${mac(secret, userId).toString("base64url")}`;
|
|
123
|
+
}
|
|
124
|
+
function verifyGuestCredential(secrets, credential) {
|
|
125
|
+
const parts = credential.split(".");
|
|
126
|
+
if (parts.length !== 3) return void 0;
|
|
127
|
+
const [version, userId, given] = parts;
|
|
128
|
+
if (version !== CredentialVersion || !userId.startsWith(GuestIdPrefix)) return void 0;
|
|
129
|
+
const givenMac = Buffer.from(given, "base64url");
|
|
130
|
+
for (const [i, secret] of secrets.entries()) {
|
|
131
|
+
if (macMatches(mac(secret, userId), givenMac)) return { userId, secretIndex: i };
|
|
132
|
+
}
|
|
133
|
+
return void 0;
|
|
134
|
+
}
|
|
135
|
+
function mac(secret, userId) {
|
|
136
|
+
return (0, import_node_crypto2.createHmac)("sha256", secret).update(MacLabel + userId).digest();
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
// src/server/token.ts
|
|
108
140
|
var MaxTokenTtlSeconds = 24 * 60 * 60;
|
|
109
141
|
var DefaultTokenTtlSeconds = 60 * 60;
|
|
@@ -135,7 +167,7 @@ function tokenClaims(input, now) {
|
|
|
135
167
|
}
|
|
136
168
|
|
|
137
169
|
// src/server/webhooks.ts
|
|
138
|
-
var
|
|
170
|
+
var import_node_crypto3 = require("crypto");
|
|
139
171
|
var SignatureToleranceMs = 5 * 60 * 1e3;
|
|
140
172
|
var EventBeforePublish = "before_publish";
|
|
141
173
|
function verifyWebhook(secret, headers, rawBody, options = {}) {
|
|
@@ -173,7 +205,7 @@ function verifyWebhook(secret, headers, rawBody, options = {}) {
|
|
|
173
205
|
if (age > tolerance) {
|
|
174
206
|
throw new Error(`webhook: signature is ${Math.round(age / 1e3)}s old, tolerance is ${tolerance / 1e3}s`);
|
|
175
207
|
}
|
|
176
|
-
const expected = (0,
|
|
208
|
+
const expected = (0, import_node_crypto3.createHmac)("sha256", secret).update(`${timestamp}
|
|
177
209
|
${event}
|
|
178
210
|
${delivery}
|
|
179
211
|
${rawBody}`).digest();
|
|
@@ -207,13 +239,29 @@ function createChatServer(options) {
|
|
|
207
239
|
throw new TypeError("keyId is required: it becomes the token's kid, which tells the server which key signed it");
|
|
208
240
|
}
|
|
209
241
|
const rest = new ServerRest(options.url, options.secretKey, options.fetch ?? globalThis.fetch.bind(globalThis));
|
|
242
|
+
const guestSecrets = options.guestSecret === void 0 ? [] : typeof options.guestSecret === "string" ? [options.guestSecret] : options.guestSecret;
|
|
243
|
+
for (const secret of guestSecrets) {
|
|
244
|
+
if (secret.length < MinGuestSecretLength) {
|
|
245
|
+
throw new RangeError(`guestSecret must be at least ${MinGuestSecretLength} characters`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const sign = (input) => signHS256({ alg: "HS256", typ: "JWT", kid: options.keyId }, tokenClaims(input, /* @__PURE__ */ new Date()), options.secretKey);
|
|
210
249
|
return {
|
|
211
|
-
token
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
250
|
+
token: sign,
|
|
251
|
+
guest(input) {
|
|
252
|
+
const signer = guestSecrets[0];
|
|
253
|
+
if (signer === void 0) {
|
|
254
|
+
throw new TypeError("guestSecret was not given to createChatServer, so guest credentials cannot be signed");
|
|
255
|
+
}
|
|
256
|
+
const { credential, ...claims } = input;
|
|
257
|
+
const verified = credential ? verifyGuestCredential(guestSecrets, credential) : void 0;
|
|
258
|
+
const userId = verified?.userId ?? newGuestId();
|
|
259
|
+
return {
|
|
260
|
+
userId,
|
|
261
|
+
credential: verified !== void 0 && verified.secretIndex === 0 ? credential : signGuestCredential(signer, userId),
|
|
262
|
+
token: sign({ ...claims, userId }),
|
|
263
|
+
created: verified === void 0
|
|
264
|
+
};
|
|
217
265
|
},
|
|
218
266
|
rooms: {
|
|
219
267
|
ensure: (input) => rest.call("PUT", "/v1/rooms", input),
|
|
@@ -305,6 +353,7 @@ function createChatServer(options) {
|
|
|
305
353
|
// Annotate the CommonJS export names for ESM import in node:
|
|
306
354
|
0 && (module.exports = {
|
|
307
355
|
DefaultTokenTtlSeconds,
|
|
356
|
+
GuestIdPrefix,
|
|
308
357
|
MaxTokenTtlSeconds,
|
|
309
358
|
SignatureToleranceMs,
|
|
310
359
|
createChatServer
|
package/dist/server/index.d.cts
CHANGED
|
@@ -39,6 +39,24 @@ type VerifyOptions = {
|
|
|
39
39
|
tolerance?: number;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Guest identities without a database.
|
|
44
|
+
*
|
|
45
|
+
* 비로그인 방문자에게 안정적인 id를 주려면 브라우저가 무언가를 들고 있다가
|
|
46
|
+
* 돌려줘야 한다. 그것이 **id 자체**이면 안 된다 — `sender.id`는 방의 모든
|
|
47
|
+
* 메시지에 실려 공개되므로, 백엔드가 "돌려받은 id를 그대로 서명"하면 누구나
|
|
48
|
+
* 남의 메시지에서 id를 복사해 그 게스트로 말할 수 있다. 그래서 브라우저가 드는
|
|
49
|
+
* 것은 id에 대한 MAC이 붙은 자격증명이고, id는 그 안에서 꺼낸다. MAC은 소비자
|
|
50
|
+
* 백엔드만 아는 비밀로 만들고 검증하므로 저장소가 필요 없다.
|
|
51
|
+
*
|
|
52
|
+
* 비밀을 `sk_`에서 유도하지 않는 이유: `sk_`는 유출되면 돌리는 키이고, 돌리는
|
|
53
|
+
* 순간 모든 게스트가 새 사람이 된다(자기 메시지·읽음 커서를 잃는다). 게스트
|
|
54
|
+
* 신원의 수명은 키의 수명과 무관해야 하므로 비밀을 따로 받고, 배열로 받아
|
|
55
|
+
* 교체 중에도 옛 자격증명을 받아 준다.
|
|
56
|
+
*/
|
|
57
|
+
/** Every guest id starts with this, so it cannot collide with a member id that does not. */
|
|
58
|
+
declare const GuestIdPrefix = "g_";
|
|
59
|
+
|
|
42
60
|
type ChatServerOptions = {
|
|
43
61
|
/** The chat server's base URL, e.g. `https://chat.example.com`. */
|
|
44
62
|
url: string;
|
|
@@ -56,6 +74,15 @@ type ChatServerOptions = {
|
|
|
56
74
|
keyId: string;
|
|
57
75
|
/** Needed only by `webhooks.verify`. */
|
|
58
76
|
webhookSecret?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Needed only by `guest()`: the secret guest credentials are signed with,
|
|
79
|
+
* at least 32 characters, kept only on your backend.
|
|
80
|
+
*
|
|
81
|
+
* An array while rotating: the first signs, every one verifies, and a
|
|
82
|
+
* credential verified by an older one is re-issued under the first --
|
|
83
|
+
* same `userId` -- so guests migrate on their next visit.
|
|
84
|
+
*/
|
|
85
|
+
guestSecret?: string | readonly string[];
|
|
59
86
|
/** Defaults to the global. */
|
|
60
87
|
fetch?: typeof globalThis.fetch;
|
|
61
88
|
};
|
|
@@ -82,9 +109,33 @@ type ServerSendInput = {
|
|
|
82
109
|
replyTo?: string;
|
|
83
110
|
threadId?: string;
|
|
84
111
|
};
|
|
112
|
+
type GuestInput = Omit<TokenInput, 'userId'> & {
|
|
113
|
+
/** What `guest()` returned last time, as the browser kept it. Missing or invalid means a new guest. */
|
|
114
|
+
credential?: string | null;
|
|
115
|
+
};
|
|
116
|
+
type GuestIdentity = {
|
|
117
|
+
/** `g_…`. Public -- it is `sender.id` on every message -- and never proof of anything. */
|
|
118
|
+
userId: string;
|
|
119
|
+
/** Give this back to the browser to keep, and take it back next time. This is the proof. */
|
|
120
|
+
credential: string;
|
|
121
|
+
/** A user token for `userId`, as `token()` would sign it. */
|
|
122
|
+
token: string;
|
|
123
|
+
/** True when a new guest was issued (no credential, or one that did not verify). */
|
|
124
|
+
created: boolean;
|
|
125
|
+
};
|
|
85
126
|
type ChatServer = {
|
|
86
127
|
/** Signs a user token locally. No network call. */
|
|
87
128
|
token: (input: TokenInput) => string;
|
|
129
|
+
/**
|
|
130
|
+
* Issues or resumes a guest: verifies `credential` and signs a token for
|
|
131
|
+
* the guest id inside it, or mints a new guest when there is none. No
|
|
132
|
+
* network call and no storage. Needs `guestSecret`.
|
|
133
|
+
*
|
|
134
|
+
* **Never sign a user id the browser sends back.** A guest id is public,
|
|
135
|
+
* so re-signing it lets anyone speak as any guest. Only the credential
|
|
136
|
+
* proves who a guest is.
|
|
137
|
+
*/
|
|
138
|
+
guest: (input: GuestInput) => GuestIdentity;
|
|
88
139
|
rooms: {
|
|
89
140
|
/** get-or-create. The same call whether the room exists or not. */
|
|
90
141
|
ensure: (input: EnsureRoomInput) => Promise<{
|
|
@@ -205,4 +256,4 @@ type ChatServer = {
|
|
|
205
256
|
};
|
|
206
257
|
declare function createChatServer(options: ChatServerOptions): ChatServer;
|
|
207
258
|
|
|
208
|
-
export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, MaxTokenTtlSeconds, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };
|
|
259
|
+
export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, GuestIdPrefix, type GuestIdentity, type GuestInput, MaxTokenTtlSeconds, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };
|
package/dist/server/index.d.ts
CHANGED
|
@@ -39,6 +39,24 @@ type VerifyOptions = {
|
|
|
39
39
|
tolerance?: number;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Guest identities without a database.
|
|
44
|
+
*
|
|
45
|
+
* 비로그인 방문자에게 안정적인 id를 주려면 브라우저가 무언가를 들고 있다가
|
|
46
|
+
* 돌려줘야 한다. 그것이 **id 자체**이면 안 된다 — `sender.id`는 방의 모든
|
|
47
|
+
* 메시지에 실려 공개되므로, 백엔드가 "돌려받은 id를 그대로 서명"하면 누구나
|
|
48
|
+
* 남의 메시지에서 id를 복사해 그 게스트로 말할 수 있다. 그래서 브라우저가 드는
|
|
49
|
+
* 것은 id에 대한 MAC이 붙은 자격증명이고, id는 그 안에서 꺼낸다. MAC은 소비자
|
|
50
|
+
* 백엔드만 아는 비밀로 만들고 검증하므로 저장소가 필요 없다.
|
|
51
|
+
*
|
|
52
|
+
* 비밀을 `sk_`에서 유도하지 않는 이유: `sk_`는 유출되면 돌리는 키이고, 돌리는
|
|
53
|
+
* 순간 모든 게스트가 새 사람이 된다(자기 메시지·읽음 커서를 잃는다). 게스트
|
|
54
|
+
* 신원의 수명은 키의 수명과 무관해야 하므로 비밀을 따로 받고, 배열로 받아
|
|
55
|
+
* 교체 중에도 옛 자격증명을 받아 준다.
|
|
56
|
+
*/
|
|
57
|
+
/** Every guest id starts with this, so it cannot collide with a member id that does not. */
|
|
58
|
+
declare const GuestIdPrefix = "g_";
|
|
59
|
+
|
|
42
60
|
type ChatServerOptions = {
|
|
43
61
|
/** The chat server's base URL, e.g. `https://chat.example.com`. */
|
|
44
62
|
url: string;
|
|
@@ -56,6 +74,15 @@ type ChatServerOptions = {
|
|
|
56
74
|
keyId: string;
|
|
57
75
|
/** Needed only by `webhooks.verify`. */
|
|
58
76
|
webhookSecret?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Needed only by `guest()`: the secret guest credentials are signed with,
|
|
79
|
+
* at least 32 characters, kept only on your backend.
|
|
80
|
+
*
|
|
81
|
+
* An array while rotating: the first signs, every one verifies, and a
|
|
82
|
+
* credential verified by an older one is re-issued under the first --
|
|
83
|
+
* same `userId` -- so guests migrate on their next visit.
|
|
84
|
+
*/
|
|
85
|
+
guestSecret?: string | readonly string[];
|
|
59
86
|
/** Defaults to the global. */
|
|
60
87
|
fetch?: typeof globalThis.fetch;
|
|
61
88
|
};
|
|
@@ -82,9 +109,33 @@ type ServerSendInput = {
|
|
|
82
109
|
replyTo?: string;
|
|
83
110
|
threadId?: string;
|
|
84
111
|
};
|
|
112
|
+
type GuestInput = Omit<TokenInput, 'userId'> & {
|
|
113
|
+
/** What `guest()` returned last time, as the browser kept it. Missing or invalid means a new guest. */
|
|
114
|
+
credential?: string | null;
|
|
115
|
+
};
|
|
116
|
+
type GuestIdentity = {
|
|
117
|
+
/** `g_…`. Public -- it is `sender.id` on every message -- and never proof of anything. */
|
|
118
|
+
userId: string;
|
|
119
|
+
/** Give this back to the browser to keep, and take it back next time. This is the proof. */
|
|
120
|
+
credential: string;
|
|
121
|
+
/** A user token for `userId`, as `token()` would sign it. */
|
|
122
|
+
token: string;
|
|
123
|
+
/** True when a new guest was issued (no credential, or one that did not verify). */
|
|
124
|
+
created: boolean;
|
|
125
|
+
};
|
|
85
126
|
type ChatServer = {
|
|
86
127
|
/** Signs a user token locally. No network call. */
|
|
87
128
|
token: (input: TokenInput) => string;
|
|
129
|
+
/**
|
|
130
|
+
* Issues or resumes a guest: verifies `credential` and signs a token for
|
|
131
|
+
* the guest id inside it, or mints a new guest when there is none. No
|
|
132
|
+
* network call and no storage. Needs `guestSecret`.
|
|
133
|
+
*
|
|
134
|
+
* **Never sign a user id the browser sends back.** A guest id is public,
|
|
135
|
+
* so re-signing it lets anyone speak as any guest. Only the credential
|
|
136
|
+
* proves who a guest is.
|
|
137
|
+
*/
|
|
138
|
+
guest: (input: GuestInput) => GuestIdentity;
|
|
88
139
|
rooms: {
|
|
89
140
|
/** get-or-create. The same call whether the room exists or not. */
|
|
90
141
|
ensure: (input: EnsureRoomInput) => Promise<{
|
|
@@ -205,4 +256,4 @@ type ChatServer = {
|
|
|
205
256
|
};
|
|
206
257
|
declare function createChatServer(options: ChatServerOptions): ChatServer;
|
|
207
258
|
|
|
208
|
-
export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, MaxTokenTtlSeconds, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };
|
|
259
|
+
export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, GuestIdPrefix, type GuestIdentity, type GuestInput, MaxTokenTtlSeconds, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };
|