@dropby/server 0.4.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 ADDED
@@ -0,0 +1,343 @@
1
+ var DropByError = class extends Error {
2
+ name = "DropByError";
3
+ code;
4
+ status;
5
+ retryAfter;
6
+ constructor(code, message, options = {}) {
7
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
8
+ this.code = code;
9
+ this.status = options.status;
10
+ this.retryAfter = options.retryAfter;
11
+ }
12
+ };
13
+ function invalidInput(message) {
14
+ return new DropByError("invalid_input", message);
15
+ }
16
+ async function apiError(response) {
17
+ const body = await response.json().catch(() => null);
18
+ const error = isRecord(body) && isRecord(body.error) ? body.error : null;
19
+ const options = {
20
+ status: response.status,
21
+ retryAfter: retryAfter(response)
22
+ };
23
+ if (error && typeof error.code === "string" && typeof error.message === "string") return new DropByError(error.code, error.message, options);
24
+ return new DropByError("request_failed", `DropBy request failed with status ${response.status}`, options);
25
+ }
26
+ function retryAfter(response) {
27
+ const value = response.headers.get("retry-after");
28
+ return value !== null && /^\d{1,6}$/.test(value) ? Number(value) : void 0;
29
+ }
30
+ function isRecord(value) {
31
+ return typeof value === "object" && value !== null;
32
+ }
33
+ const defaultApiBaseUrl = "https://api.dropby.chat";
34
+ const defaultTimeoutMs = 3e4;
35
+ const defaultMaxRetries = 2;
36
+ const maxMaxRetries = 10;
37
+ const maxRetryAfterSeconds = 10;
38
+ const maxTimeoutMs = 2147483647;
39
+ const secretKeyPattern = /^sk_(?:live|test)_[0-9a-f]{64}$/;
40
+ const idBodyPattern = /^[A-Za-z0-9_-]+$/;
41
+ const idempotencyKeyPattern = /^[\x21-\x7e]{1,128}$/;
42
+ var Server = class {
43
+ asks;
44
+ browserSessions;
45
+ chat;
46
+ config;
47
+ ideas;
48
+ #secretKey;
49
+ #origin;
50
+ #fetch;
51
+ #timeoutMs;
52
+ #maxRetries;
53
+ #cache;
54
+ constructor(options) {
55
+ this.#secretKey = requiredSecretKey(options.secretKey);
56
+ this.#origin = apiOrigin(options.apiBaseUrl ?? defaultApiBaseUrl);
57
+ this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
58
+ this.#timeoutMs = requestTimeout(options.timeoutMs ?? defaultTimeoutMs);
59
+ this.#maxRetries = retries(options.maxRetries ?? defaultMaxRetries);
60
+ this.#cache = acceptsCacheOption();
61
+ this.asks = {
62
+ create: async (input, options = {}) => this.#send("POST", "/v1/asks", {
63
+ body: input,
64
+ key: options.idempotencyKey,
65
+ keyed: true
66
+ }),
67
+ list: async ({ filter, sort, limit, cursor } = {}) => this.#send("GET", "/v1/asks", { query: {
68
+ filter,
69
+ sort,
70
+ limit,
71
+ cursor
72
+ } }),
73
+ get: async (askId) => this.#send("GET", `/v1/asks/${pathId(askId, "ask_")}`),
74
+ listResponses: async (askId, { limit, cursor } = {}) => this.#send("GET", `/v1/asks/${pathId(askId, "ask_")}/responses`, { query: {
75
+ limit,
76
+ cursor
77
+ } })
78
+ };
79
+ this.browserSessions = { create: async (input) => this.#send("POST", "/v1/browser-sessions", { body: input }) };
80
+ this.chat = {
81
+ list: async ({ filter, limit, cursor } = {}) => this.#send("GET", "/v1/chat/threads", { query: {
82
+ filter,
83
+ limit,
84
+ cursor
85
+ } }),
86
+ get: async (threadId) => this.#send("GET", `/v1/chat/threads/${pathId(threadId, "thr_")}`),
87
+ listMessages: async (threadId, { limit, cursor } = {}) => this.#send("GET", `/v1/chat/threads/${pathId(threadId, "thr_")}/messages`, { query: {
88
+ limit,
89
+ cursor
90
+ } }),
91
+ create: async (input, options = {}) => this.#send("POST", "/v1/chat/threads", {
92
+ body: input,
93
+ key: options.idempotencyKey,
94
+ keyed: true
95
+ }),
96
+ reply: async (threadId, input, options = {}) => this.#send("POST", `/v1/chat/threads/${pathId(threadId, "thr_")}/messages`, {
97
+ body: input,
98
+ key: options.idempotencyKey,
99
+ keyed: true
100
+ }),
101
+ update: async (threadId, changes) => this.#send("PATCH", `/v1/chat/threads/${pathId(threadId, "thr_")}`, { body: changes }),
102
+ getAttachmentDownload: async (attachmentId) => this.#send("GET", `/v1/chat/attachments/${pathId(attachmentId, "att_")}/download`)
103
+ };
104
+ this.config = { evaluate: async (input) => this.#send("POST", "/v1/config/evaluate", { body: input }) };
105
+ this.ideas = {
106
+ list: async ({ filter, sort, limit, cursor } = {}) => this.#send("GET", "/v1/ideas", { query: {
107
+ filter,
108
+ sort,
109
+ limit,
110
+ cursor
111
+ } }),
112
+ get: async (ideaId) => this.#send("GET", `/v1/ideas/${pathId(ideaId, "idea_")}`),
113
+ listVoters: async (ideaId, { limit, cursor } = {}) => this.#send("GET", `/v1/ideas/${pathId(ideaId, "idea_")}/voters`, { query: {
114
+ limit,
115
+ cursor
116
+ } }),
117
+ listDuplicates: async (ideaId, { limit, cursor } = {}) => this.#send("GET", `/v1/ideas/${pathId(ideaId, "idea_")}/duplicates`, { query: {
118
+ limit,
119
+ cursor
120
+ } }),
121
+ create: async (input, options = {}) => this.#send("POST", "/v1/ideas", {
122
+ body: input,
123
+ key: options.idempotencyKey,
124
+ keyed: true
125
+ }),
126
+ update: async (ideaId, changes) => this.#send("PATCH", `/v1/ideas/${pathId(ideaId, "idea_")}`, { body: changes }),
127
+ merge: async (ideaId, input) => this.#send("POST", `/v1/ideas/${pathId(ideaId, "idea_")}/merge`, { body: input })
128
+ };
129
+ }
130
+ async #send(method, path, options = {}) {
131
+ const { key, keyed } = options;
132
+ if (key !== void 0 && (typeof key !== "string" || !idempotencyKeyPattern.test(key))) throw invalidInput("idempotencyKey must be 1 to 128 visible ASCII characters, such as a UUID");
133
+ const sentKey = key ?? (keyed ? crypto.randomUUID() : void 0);
134
+ const payload = options.body === void 0 ? void 0 : serialise(options.body);
135
+ const url = `${this.#origin}${path}${search(options.query)}`;
136
+ for (let attempt = 0;; attempt++) try {
137
+ return await this.#attempt(method, url, payload, sentKey);
138
+ } catch (error) {
139
+ if (!(error instanceof DropByError) || !transient(error) || attempt >= this.#maxRetries) throw error;
140
+ if (error.retryAfter !== void 0 && error.retryAfter > maxRetryAfterSeconds) throw error;
141
+ await sleep(Math.max(backoff(attempt + 1), (error.retryAfter ?? 0) * 1e3));
142
+ }
143
+ }
144
+ async #attempt(method, url, payload, key) {
145
+ const controller = new AbortController();
146
+ const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
147
+ try {
148
+ const response = await this.#fetch(url, {
149
+ method,
150
+ ...this.#cache ? { cache: "no-store" } : {},
151
+ signal: controller.signal,
152
+ headers: {
153
+ accept: "application/json",
154
+ authorization: `Bearer ${this.#secretKey}`,
155
+ ...payload === void 0 ? {} : { "content-type": "application/json" },
156
+ ...key === void 0 ? {} : { "idempotency-key": key }
157
+ },
158
+ ...payload === void 0 ? {} : { body: payload }
159
+ });
160
+ if (!response.ok) throw await apiError(response);
161
+ const text = await response.text();
162
+ try {
163
+ return JSON.parse(text);
164
+ } catch {
165
+ throw new DropByError("invalid_response", `DropBy answered ${response.status} with a body that is not JSON`, { status: response.status });
166
+ }
167
+ } catch (error) {
168
+ if (controller.signal.aborted) throw new DropByError("timeout", `DropBy did not answer within ${this.#timeoutMs} ms`, { cause: error });
169
+ if (error instanceof DropByError) throw error;
170
+ throw new DropByError("network_error", "DropBy could not be reached", { cause: error });
171
+ } finally {
172
+ clearTimeout(timer);
173
+ }
174
+ }
175
+ };
176
+ function createDropByServer(options) {
177
+ return new Server(options);
178
+ }
179
+ function transient(error) {
180
+ if (error.status === void 0) return error.code === "network_error" || error.code === "timeout";
181
+ return error.status === 408 || error.status === 429 || error.status >= 500;
182
+ }
183
+ function backoff(retry) {
184
+ return Math.min(8e3, 500 * 2 ** (retry - 1)) * (.5 + Math.random() / 2);
185
+ }
186
+ function sleep(ms) {
187
+ return new Promise((resolve) => setTimeout(resolve, ms));
188
+ }
189
+ function acceptsCacheOption() {
190
+ try {
191
+ new Request("https://api.dropby.chat", { cache: "no-store" });
192
+ return true;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+ function retries(value) {
198
+ if (!Number.isInteger(value) || value < 0 || value > maxMaxRetries) throw invalidInput(`maxRetries must be a whole number from 0 to ${maxMaxRetries}`);
199
+ return value;
200
+ }
201
+ function requiredSecretKey(value) {
202
+ const key = typeof value === "string" ? value.trim() : "";
203
+ if (secretKeyPattern.test(key)) return key;
204
+ if (!key) throw invalidInput("secretKey is missing");
205
+ if (key.startsWith("pk_") || key.startsWith("pkey_")) throw invalidInput("secretKey is a browser key; the server API takes a secret key (sk_live_… or sk_test_…)");
206
+ throw invalidInput("secretKey is not a DropBy secret key (sk_live_… or sk_test_…)");
207
+ }
208
+ function apiOrigin(value) {
209
+ let url;
210
+ try {
211
+ url = new URL(value);
212
+ } catch {
213
+ throw invalidInput("apiBaseUrl is not a URL");
214
+ }
215
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.pathname !== "/" || url.search || url.hash) throw invalidInput("apiBaseUrl must be an origin, like https://api.dropby.chat");
216
+ if (url.protocol === "http:" && !localHost(url.hostname)) throw invalidInput("apiBaseUrl must use https unless it is a local host");
217
+ return url.origin;
218
+ }
219
+ function localHost(hostname) {
220
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
221
+ }
222
+ function requestTimeout(value) {
223
+ if (!Number.isInteger(value) || value < 1 || value > maxTimeoutMs) throw invalidInput("timeoutMs must be a whole number of milliseconds above zero");
224
+ return value;
225
+ }
226
+ function pathId(value, prefix) {
227
+ if (typeof value !== "string" || !value.startsWith(prefix) || !idBodyPattern.test(value.slice(prefix.length))) throw invalidInput(`Expected a DropBy id starting with ${prefix}`);
228
+ return value;
229
+ }
230
+ function search(query) {
231
+ const params = new URLSearchParams();
232
+ for (const [name, value] of Object.entries(query ?? {})) if (value !== void 0 && value !== "") params.set(name, String(value));
233
+ const encoded = params.toString();
234
+ return encoded ? `?${encoded}` : "";
235
+ }
236
+ function serialise(body) {
237
+ try {
238
+ return JSON.stringify(body);
239
+ } catch (cause) {
240
+ throw new DropByError("invalid_input", "The request body cannot be serialised as JSON", { cause });
241
+ }
242
+ }
243
+ const identityProofAudience = "dropby:browser-session";
244
+ const identityProofType = "dropby-identity+jwt";
245
+ const identitySecretPattern$1 = /^[0-9a-f]{64}$/;
246
+ async function createIdentityProof(options) {
247
+ const { secret, productId, publicKeyId, env } = options;
248
+ if (typeof secret !== "string" || !identitySecretPattern$1.test(secret)) throw invalidInput("The identity secret must be 64 lowercase hexadecimal characters");
249
+ if (!hasPrefix(productId, "prj_")) throw invalidInput("productId must start with prj_");
250
+ if (!hasPrefix(publicKeyId, "pkey_")) throw invalidInput("publicKeyId must start with pkey_");
251
+ if (env !== "live" && env !== "test") throw invalidInput("env must be \"live\" or \"test\"");
252
+ const ttlSeconds = options.ttlSeconds ?? 900;
253
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 900) throw invalidInput(`ttlSeconds must be a whole number from 1 to 900`);
254
+ const user = options.user;
255
+ checkIdentityId(user?.id, "user.id");
256
+ const account = options.account;
257
+ if (account) checkIdentityId(account.id, "account.id");
258
+ const iat = Math.floor(Date.now() / 1e3);
259
+ const claims = {
260
+ iss: productId,
261
+ aud: identityProofAudience,
262
+ env,
263
+ publicKeyId,
264
+ iat,
265
+ exp: iat + ttlSeconds,
266
+ user: known(user, [
267
+ "id",
268
+ "name",
269
+ "email",
270
+ "attributes"
271
+ ]),
272
+ ...account ? { account: known(account, [
273
+ "id",
274
+ "name",
275
+ "domain",
276
+ "attributes"
277
+ ]) } : {}
278
+ };
279
+ const header = {
280
+ alg: "HS256",
281
+ typ: identityProofType
282
+ };
283
+ const encoder = new TextEncoder();
284
+ const signingInput = `${encodeJson(header)}.${encodeJson(claims)}`;
285
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
286
+ name: "HMAC",
287
+ hash: "SHA-256"
288
+ }, false, ["sign"]);
289
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(signingInput));
290
+ const proof = `${signingInput}.${base64url(new Uint8Array(signature))}`;
291
+ if (proof.length > 131072) throw invalidInput("The identity proof is too large; send fewer attributes");
292
+ return proof;
293
+ }
294
+ function hasPrefix(value, prefix) {
295
+ return typeof value === "string" && value.startsWith(prefix) && value.length > prefix.length;
296
+ }
297
+ function checkIdentityId(value, name) {
298
+ const id = typeof value === "string" ? value.trim() : "";
299
+ if (id.length < 1 || id.length > 256) throw invalidInput(`${name} must be 1 to 256 characters`);
300
+ for (const char of id) {
301
+ const code = char.charCodeAt(0);
302
+ if (code < 32 || code === 127) throw invalidInput(`${name} cannot contain control characters`);
303
+ }
304
+ }
305
+ function known(value, keys) {
306
+ const picked = {};
307
+ for (const key of keys) if (value[key] !== void 0) picked[key] = value[key];
308
+ return picked;
309
+ }
310
+ function encodeJson(value) {
311
+ let json;
312
+ try {
313
+ json = JSON.stringify(value);
314
+ } catch (cause) {
315
+ throw new DropByError("invalid_input", "The identity cannot be serialised as JSON", { cause });
316
+ }
317
+ return base64url(new TextEncoder().encode(json));
318
+ }
319
+ function base64url(bytes) {
320
+ let binary = "";
321
+ for (const byte of bytes) binary += String.fromCharCode(byte);
322
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
323
+ }
324
+ const uuid = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
325
+ const productIdPattern = new RegExp(`^prj_${uuid}$`);
326
+ const publicKeyIdPattern = new RegExp(`^pkey_${uuid}$`);
327
+ const identitySecretPattern = /^[0-9a-f]{64}$/;
328
+ function identityProofOptionsFromEnv(vars) {
329
+ return {
330
+ productId: read(vars, "DROPBY_PRODUCT_ID", (value) => productIdPattern.test(value) ? value : null),
331
+ publicKeyId: read(vars, "DROPBY_PUBLIC_KEY_ID", (value) => publicKeyIdPattern.test(value) ? value : null),
332
+ env: read(vars, "DROPBY_ENV", (value) => value === "live" || value === "test" ? value : null),
333
+ secret: read(vars, "DROPBY_IDENTITY_SECRET", (value) => identitySecretPattern.test(value) ? value : null)
334
+ };
335
+ }
336
+ function read(vars, name, parse) {
337
+ const value = vars[name];
338
+ if (value === void 0 || value === "") throw invalidInput(`${name} is missing`);
339
+ const result = typeof value === "string" ? parse(value) : null;
340
+ if (result === null) throw invalidInput(`${name} is invalid`);
341
+ return result;
342
+ }
343
+ export { DropByError, createDropByServer, createIdentityProof, identityProofOptionsFromEnv };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@dropby/server",
3
+ "version": "0.4.0",
4
+ "private": false,
5
+ "description": "DropBy for your backend: browser sessions, identity proofs and the server API for chat, ideas and asks.",
6
+ "keywords": [
7
+ "dropby",
8
+ "customer-support",
9
+ "chat",
10
+ "feedback",
11
+ "feature-requests",
12
+ "surveys",
13
+ "server",
14
+ "sdk"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "Simpllyf Software",
18
+ "homepage": "https://dropby.chat",
19
+ "type": "module",
20
+ "sideEffects": false,
21
+ "engines": {
22
+ "node": ">=22.12"
23
+ },
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "gitHead": "714b80e34a96081832e3c6e66ede69b925c9b851",
38
+ "types": "./dist/index.d.ts",
39
+ "main": "./dist/index.js"
40
+ }