@dropby/server 0.4.0 → 0.5.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 CHANGED
@@ -94,6 +94,28 @@ workflows. Ids are plain strings: one in a URL path is checked before anything i
94
94
  sent (`invalid_input`), and one in a request body is checked by the API (a 400
95
95
  naming the field).
96
96
 
97
+ ## Asks, chat and ideas
98
+
99
+ Ask a user a question, reply to their conversation, or tell them an idea is planned:
100
+
101
+ <!-- snippet: import type { DropByServer } from "@dropby/server"; declare const dropby: DropByServer; declare const threadId: string; declare const ideaId: string; -->
102
+
103
+ ```ts
104
+ await dropby.asks.create({
105
+ title: "how's the new inbox?",
106
+ fields: [{ type: "long_text", label: "what would make it better?", required: true }],
107
+ targets: [{ type: "user", id: "user_123" }],
108
+ });
109
+
110
+ await dropby.chat.reply(threadId, { text: "fixed in today's release" });
111
+
112
+ await dropby.ideas.update(ideaId, { status: "planned", teamResponse: { text: "on the roadmap" } });
113
+ ```
114
+
115
+ The ask shows in that user's widget, or wherever your own UI lists pending asks.
116
+ Creating one needs a secret key with `ask:create`, replying `chat:write`, and
117
+ updating an idea `ideas:write`.
118
+
97
119
  ## Feature flags
98
120
 
99
121
  `config.evaluate` resolves every flag for a user and account, as the browser
@@ -123,7 +145,9 @@ Every call retries by itself after a network error, a timeout, a 408, a 429 or
123
145
  a 5xx: twice by default, waiting up to half a second, then up to a second (set
124
146
  `maxRetries`, 0 to 10). A `Retry-After` of up to 10 seconds is waited out; a
125
147
  longer one ends the call at once, and the error's `retryAfter` says how long the
126
- API asked for. `timeoutMs` (30 seconds by default) bounds each attempt.
148
+ API asked for. `timeoutMs` (30 seconds by default) bounds each attempt. When a
149
+ call still fails, the error's `retryable` says whether the same call is worth
150
+ trying again later, such as from a job queue.
127
151
 
128
152
  Retrying is safe for every call. Updates and merges change nothing when
129
153
  repeated (though an update retried after a lost answer can overwrite a change
@@ -168,8 +192,8 @@ import { DropByError } from "@dropby/server";
168
192
  try {
169
193
  await dropby.chat.reply(threadId, { text });
170
194
  } catch (error) {
171
- if (error instanceof DropByError && error.code === "rate_limited") {
172
- // error.retryAfter says how long the API asked to wait
195
+ if (error instanceof DropByError && error.retryable) {
196
+ // worth another try later; error.retryAfter says how long the API asked to wait
173
197
  }
174
198
  throw error;
175
199
  }
@@ -180,12 +204,15 @@ API codes are `bad_request`, `unauthorized`, `forbidden`, `not_found`,
180
204
  `rate_limited`, `unavailable` and `internal_error`; the API may add more. The
181
205
  client adds `invalid_input` (a bad argument or option, raised before anything
182
206
  is sent), `network_error`, `timeout` (30 seconds per attempt by default;
183
- set `timeoutMs`), `invalid_response` and `request_failed` (an error response without
184
- a DropBy error body). No message includes your keys.
185
-
186
- Responses are returned as the API sends them. New fields and values can appear
187
- as the API grows, so treat enums such as a status as open and keep a default
188
- branch when you switch over one.
207
+ set `timeoutMs`), `invalid_response` (a success that is not JSON, or lacks what
208
+ the method returns: usually an `apiBaseUrl` that is not DropBy's) and
209
+ `request_failed` (an error response without a DropBy error body). `retryable` is
210
+ true for `network_error`, `timeout`, and a 408, 429 or 5xx. No message includes
211
+ your keys.
212
+
213
+ Responses are returned as the API sends them, once they carry what the method
214
+ returns. New fields and values can appear as the API grows, so treat enums such
215
+ as a status as open and keep a default branch when you switch over one.
189
216
 
190
217
  ## Identity proofs
191
218
 
package/dist/index.d.ts CHANGED
@@ -48,7 +48,15 @@ type Attributes = Record<string, AttributeValue>;
48
48
  */
49
49
  interface SessionUser {
50
50
  id: string;
51
+ /**
52
+ * Shown to your team in the dropby dashboard. Empty or blank means no email, and
53
+ * then the one DropBy already has is kept; any other value must be an email address.
54
+ */
51
55
  email?: string | undefined;
56
+ /**
57
+ * Shown to your team in the dropby dashboard. Trimmed and cut at 500 characters;
58
+ * empty or blank means no name, and then the name DropBy already has is kept.
59
+ */
52
60
  name?: string | undefined;
53
61
  attributes?: Attributes | undefined;
54
62
  [key: string]: AttributeValue | Attributes | undefined;
@@ -61,6 +69,10 @@ interface SessionUser {
61
69
  interface SessionAccount {
62
70
  id: string;
63
71
  domain?: string | undefined;
72
+ /**
73
+ * Shown to your team in the dropby dashboard. Trimmed and cut at 500 characters;
74
+ * empty or blank means no name, and then the name DropBy already has is kept.
75
+ */
64
76
  name?: string | undefined;
65
77
  attributes?: Attributes | undefined;
66
78
  [key: string]: AttributeValue | Attributes | undefined;
@@ -765,7 +777,9 @@ interface DropByConfigClient {
765
777
  /**
766
778
  * A client for DropBy's server API, from `createDropByServer`. Calls retry network
767
779
  * errors, timeouts, 408, 429 and 5xx up to `maxRetries` times, and reject with a
768
- * `DropByError`; a `Retry-After` over 10 seconds rejects at once.
780
+ * `DropByError`; a `Retry-After` over 10 seconds rejects at once. A success that
781
+ * lacks what its method returns rejects as `invalid_response`, usually an
782
+ * `apiBaseUrl` that is not DropBy's.
769
783
  */
770
784
  interface DropByServer {
771
785
  /** Asks: questions and surveys, and their answers. */
@@ -809,6 +823,13 @@ export declare class DropByError extends Error {
809
823
  readonly status: number | undefined;
810
824
  /** The seconds the API asked to wait before retrying, when it said. */
811
825
  readonly retryAfter: number | undefined;
826
+ /**
827
+ * Whether this kind of failure is worth trying again: no response arrived, or the
828
+ * status was 408, 429 or 5xx. By the time it reaches you the client has done any
829
+ * retrying it will do, so true means try later, such as from a queue, and not
830
+ * before `retryAfter` when it is set.
831
+ */
832
+ get retryable(): boolean;
812
833
  constructor(code: DropByErrorCode, message: string, options?: DropByErrorOptions);
813
834
  }
814
835
  /**
@@ -818,9 +839,15 @@ export declare class DropByError extends Error {
818
839
  interface IdentityProofUser {
819
840
  /** Your own id for the user, 1 to 256 characters. */
820
841
  id: string;
821
- /** Shown to your team. */
842
+ /**
843
+ * Shown to your team in the dropby dashboard. Trimmed and cut at 500 characters;
844
+ * empty or blank means no name, and then the name DropBy already has is kept.
845
+ */
822
846
  name?: string | undefined;
823
- /** Shown to your team. */
847
+ /**
848
+ * Shown to your team in the dropby dashboard. Empty or blank means no email, and
849
+ * then the one DropBy already has is kept; any other value must be an email address.
850
+ */
824
851
  email?: string | undefined;
825
852
  /** Saved on the user, for audiences. */
826
853
  attributes?: Attributes | undefined;
@@ -832,7 +859,10 @@ interface IdentityProofUser {
832
859
  interface IdentityProofAccount {
833
860
  /** Your own id for the account, 1 to 256 characters. */
834
861
  id: string;
835
- /** Shown to your team. */
862
+ /**
863
+ * Shown to your team in the dropby dashboard. Trimmed and cut at 500 characters;
864
+ * empty or blank means no name, and then the name DropBy already has is kept.
865
+ */
836
866
  name?: string | undefined;
837
867
  /** The account's web domain. */
838
868
  domain?: string | undefined;
package/dist/index.js CHANGED
@@ -3,6 +3,9 @@ var DropByError = class extends Error {
3
3
  code;
4
4
  status;
5
5
  retryAfter;
6
+ get retryable() {
7
+ return retryable(this);
8
+ }
6
9
  constructor(code, message, options = {}) {
7
10
  super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
8
11
  this.code = code;
@@ -10,6 +13,10 @@ var DropByError = class extends Error {
10
13
  this.retryAfter = options.retryAfter;
11
14
  }
12
15
  };
16
+ function retryable(error) {
17
+ if (error.status === void 0) return error.code === "network_error" || error.code === "timeout";
18
+ return error.status === 408 || error.status === 429 || error.status >= 500;
19
+ }
13
20
  function invalidInput(message) {
14
21
  return new DropByError("invalid_input", message);
15
22
  }
@@ -30,6 +37,58 @@ function retryAfter(response) {
30
37
  function isRecord(value) {
31
38
  return typeof value === "object" && value !== null;
32
39
  }
40
+ const replies = {
41
+ "asks.create": { ask: "object" },
42
+ "asks.list": { items: "list" },
43
+ "asks.get": { ask: "object" },
44
+ "asks.listResponses": { items: "list" },
45
+ "browserSessions.create": { session: "object" },
46
+ "chat.list": { items: "list" },
47
+ "chat.get": { thread: "object" },
48
+ "chat.listMessages": { items: "list" },
49
+ "chat.create": {
50
+ thread: "object",
51
+ message: "object"
52
+ },
53
+ "chat.reply": {
54
+ thread: "object",
55
+ message: "object"
56
+ },
57
+ "chat.update": { thread: "object" },
58
+ "chat.getAttachmentDownload": { download: "object" },
59
+ "config.evaluate": { config: "object" },
60
+ "ideas.list": { items: "list" },
61
+ "ideas.get": { idea: "object" },
62
+ "ideas.listVoters": { items: "list" },
63
+ "ideas.listDuplicates": { items: "list" },
64
+ "ideas.create": { idea: "object" },
65
+ "ideas.update": { idea: "object" },
66
+ "ideas.merge": {
67
+ idea: "object",
68
+ canonicalIdea: "object"
69
+ }
70
+ };
71
+ const isoDateTime = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
72
+ function checkedReply(name, body, status) {
73
+ if (!isObject(body)) throw refused(name, "is not a JSON object", status);
74
+ for (const [field, kind] of Object.entries(replies[name])) {
75
+ const value = body[field];
76
+ if (kind === "list" ? !Array.isArray(value) : !isObject(value)) throw refused(name, `has no ${field} ${kind}`, status);
77
+ }
78
+ if (name === "browserSessions.create") checkSession(body.session, status);
79
+ return body;
80
+ }
81
+ function checkSession(session, status) {
82
+ const { sessionToken, expiresAt } = isObject(session) ? session : {};
83
+ if (typeof sessionToken !== "string" || !sessionToken.startsWith("dbs_")) throw refused("browserSessions.create", "has no session.sessionToken", status);
84
+ if (typeof expiresAt !== "string" || !isoDateTime.test(expiresAt) || Number.isNaN(Date.parse(expiresAt))) throw refused("browserSessions.create", "has no session.expiresAt date-time", status);
85
+ }
86
+ function refused(name, problem, status) {
87
+ return new DropByError("invalid_response", `${name}: DropBy's reply ${problem}`, { status });
88
+ }
89
+ function isObject(value) {
90
+ return typeof value === "object" && value !== null && !Array.isArray(value);
91
+ }
33
92
  const defaultApiBaseUrl = "https://api.dropby.chat";
34
93
  const defaultTimeoutMs = 3e4;
35
94
  const defaultMaxRetries = 2;
@@ -59,89 +118,89 @@ var Server = class {
59
118
  this.#maxRetries = retries(options.maxRetries ?? defaultMaxRetries);
60
119
  this.#cache = acceptsCacheOption();
61
120
  this.asks = {
62
- create: async (input, options = {}) => this.#send("POST", "/v1/asks", {
121
+ create: async (input, options = {}) => this.#send("asks.create", "POST", "/v1/asks", {
63
122
  body: input,
64
123
  key: options.idempotencyKey,
65
124
  keyed: true
66
125
  }),
67
- list: async ({ filter, sort, limit, cursor } = {}) => this.#send("GET", "/v1/asks", { query: {
126
+ list: async ({ filter, sort, limit, cursor } = {}) => this.#send("asks.list", "GET", "/v1/asks", { query: {
68
127
  filter,
69
128
  sort,
70
129
  limit,
71
130
  cursor
72
131
  } }),
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: {
132
+ get: async (askId) => this.#send("asks.get", "GET", `/v1/asks/${pathId(askId, "ask_")}`),
133
+ listResponses: async (askId, { limit, cursor } = {}) => this.#send("asks.listResponses", "GET", `/v1/asks/${pathId(askId, "ask_")}/responses`, { query: {
75
134
  limit,
76
135
  cursor
77
136
  } })
78
137
  };
79
- this.browserSessions = { create: async (input) => this.#send("POST", "/v1/browser-sessions", { body: input }) };
138
+ this.browserSessions = { create: async (input) => this.#send("browserSessions.create", "POST", "/v1/browser-sessions", { body: input }) };
80
139
  this.chat = {
81
- list: async ({ filter, limit, cursor } = {}) => this.#send("GET", "/v1/chat/threads", { query: {
140
+ list: async ({ filter, limit, cursor } = {}) => this.#send("chat.list", "GET", "/v1/chat/threads", { query: {
82
141
  filter,
83
142
  limit,
84
143
  cursor
85
144
  } }),
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: {
145
+ get: async (threadId) => this.#send("chat.get", "GET", `/v1/chat/threads/${pathId(threadId, "thr_")}`),
146
+ listMessages: async (threadId, { limit, cursor } = {}) => this.#send("chat.listMessages", "GET", `/v1/chat/threads/${pathId(threadId, "thr_")}/messages`, { query: {
88
147
  limit,
89
148
  cursor
90
149
  } }),
91
- create: async (input, options = {}) => this.#send("POST", "/v1/chat/threads", {
150
+ create: async (input, options = {}) => this.#send("chat.create", "POST", "/v1/chat/threads", {
92
151
  body: input,
93
152
  key: options.idempotencyKey,
94
153
  keyed: true
95
154
  }),
96
- reply: async (threadId, input, options = {}) => this.#send("POST", `/v1/chat/threads/${pathId(threadId, "thr_")}/messages`, {
155
+ reply: async (threadId, input, options = {}) => this.#send("chat.reply", "POST", `/v1/chat/threads/${pathId(threadId, "thr_")}/messages`, {
97
156
  body: input,
98
157
  key: options.idempotencyKey,
99
158
  keyed: true
100
159
  }),
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`)
160
+ update: async (threadId, changes) => this.#send("chat.update", "PATCH", `/v1/chat/threads/${pathId(threadId, "thr_")}`, { body: changes }),
161
+ getAttachmentDownload: async (attachmentId) => this.#send("chat.getAttachmentDownload", "GET", `/v1/chat/attachments/${pathId(attachmentId, "att_")}/download`)
103
162
  };
104
- this.config = { evaluate: async (input) => this.#send("POST", "/v1/config/evaluate", { body: input }) };
163
+ this.config = { evaluate: async (input) => this.#send("config.evaluate", "POST", "/v1/config/evaluate", { body: input }) };
105
164
  this.ideas = {
106
- list: async ({ filter, sort, limit, cursor } = {}) => this.#send("GET", "/v1/ideas", { query: {
165
+ list: async ({ filter, sort, limit, cursor } = {}) => this.#send("ideas.list", "GET", "/v1/ideas", { query: {
107
166
  filter,
108
167
  sort,
109
168
  limit,
110
169
  cursor
111
170
  } }),
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: {
171
+ get: async (ideaId) => this.#send("ideas.get", "GET", `/v1/ideas/${pathId(ideaId, "idea_")}`),
172
+ listVoters: async (ideaId, { limit, cursor } = {}) => this.#send("ideas.listVoters", "GET", `/v1/ideas/${pathId(ideaId, "idea_")}/voters`, { query: {
114
173
  limit,
115
174
  cursor
116
175
  } }),
117
- listDuplicates: async (ideaId, { limit, cursor } = {}) => this.#send("GET", `/v1/ideas/${pathId(ideaId, "idea_")}/duplicates`, { query: {
176
+ listDuplicates: async (ideaId, { limit, cursor } = {}) => this.#send("ideas.listDuplicates", "GET", `/v1/ideas/${pathId(ideaId, "idea_")}/duplicates`, { query: {
118
177
  limit,
119
178
  cursor
120
179
  } }),
121
- create: async (input, options = {}) => this.#send("POST", "/v1/ideas", {
180
+ create: async (input, options = {}) => this.#send("ideas.create", "POST", "/v1/ideas", {
122
181
  body: input,
123
182
  key: options.idempotencyKey,
124
183
  keyed: true
125
184
  }),
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 })
185
+ update: async (ideaId, changes) => this.#send("ideas.update", "PATCH", `/v1/ideas/${pathId(ideaId, "idea_")}`, { body: changes }),
186
+ merge: async (ideaId, input) => this.#send("ideas.merge", "POST", `/v1/ideas/${pathId(ideaId, "idea_")}/merge`, { body: input })
128
187
  };
129
188
  }
130
- async #send(method, path, options = {}) {
189
+ async #send(name, method, path, options = {}) {
131
190
  const { key, keyed } = options;
132
191
  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
192
  const sentKey = key ?? (keyed ? crypto.randomUUID() : void 0);
134
193
  const payload = options.body === void 0 ? void 0 : serialise(options.body);
135
194
  const url = `${this.#origin}${path}${search(options.query)}`;
136
195
  for (let attempt = 0;; attempt++) try {
137
- return await this.#attempt(method, url, payload, sentKey);
196
+ return await this.#attempt(name, method, url, payload, sentKey);
138
197
  } catch (error) {
139
- if (!(error instanceof DropByError) || !transient(error) || attempt >= this.#maxRetries) throw error;
198
+ if (!(error instanceof DropByError) || !retryable(error) || attempt >= this.#maxRetries) throw error;
140
199
  if (error.retryAfter !== void 0 && error.retryAfter > maxRetryAfterSeconds) throw error;
141
200
  await sleep(Math.max(backoff(attempt + 1), (error.retryAfter ?? 0) * 1e3));
142
201
  }
143
202
  }
144
- async #attempt(method, url, payload, key) {
203
+ async #attempt(name, method, url, payload, key) {
145
204
  const controller = new AbortController();
146
205
  const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
147
206
  try {
@@ -159,11 +218,13 @@ var Server = class {
159
218
  });
160
219
  if (!response.ok) throw await apiError(response);
161
220
  const text = await response.text();
221
+ let body;
162
222
  try {
163
- return JSON.parse(text);
223
+ body = JSON.parse(text);
164
224
  } catch {
165
- throw new DropByError("invalid_response", `DropBy answered ${response.status} with a body that is not JSON`, { status: response.status });
225
+ throw new DropByError("invalid_response", `${name}: DropBy answered ${response.status} with a body that is not JSON`, { status: response.status });
166
226
  }
227
+ return checkedReply(name, body, response.status);
167
228
  } catch (error) {
168
229
  if (controller.signal.aborted) throw new DropByError("timeout", `DropBy did not answer within ${this.#timeoutMs} ms`, { cause: error });
169
230
  if (error instanceof DropByError) throw error;
@@ -176,10 +237,6 @@ var Server = class {
176
237
  function createDropByServer(options) {
177
238
  return new Server(options);
178
239
  }
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
240
  function backoff(retry) {
184
241
  return Math.min(8e3, 500 * 2 ** (retry - 1)) * (.5 + Math.random() / 2);
185
242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dropby/server",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "DropBy for your backend: browser sessions, identity proofs and the server API for chat, ideas and asks.",
6
6
  "keywords": [
@@ -34,7 +34,7 @@
34
34
  "publishConfig": {
35
35
  "access": "public"
36
36
  },
37
- "gitHead": "714b80e34a96081832e3c6e66ede69b925c9b851",
37
+ "gitHead": "e08819c87c99727dbd8f8e6c3c697985c69c4d12",
38
38
  "types": "./dist/index.d.ts",
39
39
  "main": "./dist/index.js"
40
40
  }