@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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ChatError,
3
3
  errorFrom
4
- } from "./chunk-T3QOUFOM.js";
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.requireId("reading history")}/messages`, {
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.requireId("reacting"), messageId, emoji, op: "add" });
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.requireId("reacting"), messageId, emoji, op: "remove" });
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.requireId("marking read"), seq };
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.requireId("joining")}/join`);
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.requireId("leaving")}/members/me`);
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.requireId("reading presence")}/presence`, { full: "true" });
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.requireId("reading reactions")}/messages/${messageId}/reactions`, {
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.requireId("sending");
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
- const authData = await this.authData();
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();
@@ -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 mac = (0, import_node_crypto.createHmac)("sha256", secret).update(signingInput).digest();
98
- return `${signingInput}.${mac.toString("base64url")}`;
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 import_node_crypto2 = require("crypto");
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, import_node_crypto2.createHmac)("sha256", secret).update(`${timestamp}
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(input) {
212
- return signHS256(
213
- { alg: "HS256", typ: "JWT", kid: options.keyId },
214
- tokenClaims(input, /* @__PURE__ */ new Date()),
215
- options.secretKey
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
@@ -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 };
@@ -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 };