@kispi/chat 0.1.2 → 0.2.1

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.
@@ -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 };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  errorFrom
3
- } from "../chunk-T3QOUFOM.js";
3
+ } from "../chunk-5QQRM7BH.js";
4
4
 
5
5
  // src/server/api.ts
6
6
  var ServerRest = class {
@@ -36,12 +36,15 @@ var ServerRest = class {
36
36
  }
37
37
  };
38
38
 
39
+ // src/server/guest.ts
40
+ import { createHmac as createHmac2, randomBytes } from "crypto";
41
+
39
42
  // src/server/jwt.ts
40
43
  import { createHmac, timingSafeEqual } from "crypto";
41
44
  function signHS256(header, claims, secret) {
42
45
  const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
43
- const mac = createHmac("sha256", secret).update(signingInput).digest();
44
- return `${signingInput}.${mac.toString("base64url")}`;
46
+ const mac2 = createHmac("sha256", secret).update(signingInput).digest();
47
+ return `${signingInput}.${mac2.toString("base64url")}`;
45
48
  }
46
49
  function macMatches(expected, given) {
47
50
  return expected.length === given.length && timingSafeEqual(expected, given);
@@ -50,6 +53,32 @@ function b64url(s) {
50
53
  return Buffer.from(s, "utf8").toString("base64url");
51
54
  }
52
55
 
56
+ // src/server/guest.ts
57
+ var GuestIdPrefix = "g_";
58
+ var CredentialVersion = "g1";
59
+ var MacLabel = "kispi-chat/guest/v1\n";
60
+ var MinGuestSecretLength = 32;
61
+ function newGuestId() {
62
+ return `${GuestIdPrefix}${randomBytes(16).toString("base64url")}`;
63
+ }
64
+ function signGuestCredential(secret, userId) {
65
+ return `${CredentialVersion}.${userId}.${mac(secret, userId).toString("base64url")}`;
66
+ }
67
+ function verifyGuestCredential(secrets, credential) {
68
+ const parts = credential.split(".");
69
+ if (parts.length !== 3) return void 0;
70
+ const [version, userId, given] = parts;
71
+ if (version !== CredentialVersion || !userId.startsWith(GuestIdPrefix)) return void 0;
72
+ const givenMac = Buffer.from(given, "base64url");
73
+ for (const [i, secret] of secrets.entries()) {
74
+ if (macMatches(mac(secret, userId), givenMac)) return { userId, secretIndex: i };
75
+ }
76
+ return void 0;
77
+ }
78
+ function mac(secret, userId) {
79
+ return createHmac2("sha256", secret).update(MacLabel + userId).digest();
80
+ }
81
+
53
82
  // src/server/token.ts
54
83
  var MaxTokenTtlSeconds = 24 * 60 * 60;
55
84
  var DefaultTokenTtlSeconds = 60 * 60;
@@ -81,7 +110,7 @@ function tokenClaims(input, now) {
81
110
  }
82
111
 
83
112
  // src/server/webhooks.ts
84
- import { createHmac as createHmac2 } from "crypto";
113
+ import { createHmac as createHmac3 } from "crypto";
85
114
  var SignatureToleranceMs = 5 * 60 * 1e3;
86
115
  var EventBeforePublish = "before_publish";
87
116
  function verifyWebhook(secret, headers, rawBody, options = {}) {
@@ -119,7 +148,7 @@ function verifyWebhook(secret, headers, rawBody, options = {}) {
119
148
  if (age > tolerance) {
120
149
  throw new Error(`webhook: signature is ${Math.round(age / 1e3)}s old, tolerance is ${tolerance / 1e3}s`);
121
150
  }
122
- const expected = createHmac2("sha256", secret).update(`${timestamp}
151
+ const expected = createHmac3("sha256", secret).update(`${timestamp}
123
152
  ${event}
124
153
  ${delivery}
125
154
  ${rawBody}`).digest();
@@ -153,13 +182,29 @@ function createChatServer(options) {
153
182
  throw new TypeError("keyId is required: it becomes the token's kid, which tells the server which key signed it");
154
183
  }
155
184
  const rest = new ServerRest(options.url, options.secretKey, options.fetch ?? globalThis.fetch.bind(globalThis));
185
+ const guestSecrets = options.guestSecret === void 0 ? [] : typeof options.guestSecret === "string" ? [options.guestSecret] : options.guestSecret;
186
+ for (const secret of guestSecrets) {
187
+ if (secret.length < MinGuestSecretLength) {
188
+ throw new RangeError(`guestSecret must be at least ${MinGuestSecretLength} characters`);
189
+ }
190
+ }
191
+ const sign = (input) => signHS256({ alg: "HS256", typ: "JWT", kid: options.keyId }, tokenClaims(input, /* @__PURE__ */ new Date()), options.secretKey);
156
192
  return {
157
- token(input) {
158
- return signHS256(
159
- { alg: "HS256", typ: "JWT", kid: options.keyId },
160
- tokenClaims(input, /* @__PURE__ */ new Date()),
161
- options.secretKey
162
- );
193
+ token: sign,
194
+ guest(input) {
195
+ const signer = guestSecrets[0];
196
+ if (signer === void 0) {
197
+ throw new TypeError("guestSecret was not given to createChatServer, so guest credentials cannot be signed");
198
+ }
199
+ const { credential, ...claims } = input;
200
+ const verified = credential ? verifyGuestCredential(guestSecrets, credential) : void 0;
201
+ const userId = verified?.userId ?? newGuestId();
202
+ return {
203
+ userId,
204
+ credential: verified !== void 0 && verified.secretIndex === 0 ? credential : signGuestCredential(signer, userId),
205
+ token: sign({ ...claims, userId }),
206
+ created: verified === void 0
207
+ };
163
208
  },
164
209
  rooms: {
165
210
  ensure: (input) => rest.call("PUT", "/v1/rooms", input),
@@ -250,6 +295,7 @@ function createChatServer(options) {
250
295
  }
251
296
  export {
252
297
  DefaultTokenTtlSeconds,
298
+ GuestIdPrefix,
253
299
  MaxTokenTtlSeconds,
254
300
  SignatureToleranceMs,
255
301
  createChatServer
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@kispi/chat",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Client SDK for the chat server: one WebSocket, many rooms, ordered history.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": false,
8
8
  "files": [
9
- "dist"
9
+ "dist",
10
+ "README.en.md"
10
11
  ],
11
12
  "exports": {
12
13
  ".": {