@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simpllyf Software
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,235 @@
1
+ # `@dropby/server`
2
+
3
+ Server-side client for calling DropBy with your product's secret key. Use it from
4
+ your authenticated backend to mint browser sessions and to operate on chat,
5
+ ideas, asks, and evaluated configuration.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add @dropby/server
11
+ ```
12
+
13
+ It has no dependencies and uses only the standard `fetch` and Web Crypto APIs.
14
+ On Node.js it needs version 22.12 or newer.
15
+
16
+ ## Keys
17
+
18
+ DropBy gives each product and environment three kinds of credential, all from
19
+ the dashboard's API keys page:
20
+
21
+ - **The secret key** (`sk_live_...` or `sk_test_...`) is for your backend only:
22
+ never ship it to a browser or a mobile app. It can do what you allowed when
23
+ you created it, such as `browser_session:create` to create browser sessions or
24
+ `config:read` to evaluate flags. Revoking it also ends the browser sessions it
25
+ created: open pages using them get `forbidden` until they reload, so switch
26
+ your backend to a new key before you revoke the old one.
27
+ - **The public key** (`pk_live_...` or `pk_test_...`) is for the browser. It names
28
+ the product, the environment and the allowed origins, and grants nothing on
29
+ its own.
30
+ - **The identity secret** belongs to one browser key and signs identity proofs
31
+ on your backend (below). Keep it there too; the proof it signs is readable in
32
+ the browser, so it carries no secrets.
33
+
34
+ ## Mint a browser session
35
+
36
+ Derive the user and account from the host application's authenticated session,
37
+ then create the DropBy session on the server. Do not copy a secret key to the
38
+ browser, and do not let the browser choose the identity sent to this method.
39
+
40
+ ```ts
41
+ import { createDropByServer } from "@dropby/server";
42
+
43
+ type HostSession = {
44
+ user: { id: string; email?: string; name?: string };
45
+ account?: { id: string; name?: string };
46
+ };
47
+
48
+ // Implement this with the host application's session or request authentication.
49
+ declare function authenticate(request: Request): Promise<HostSession | null>;
50
+
51
+ export async function createDropBySessionResponse(request: Request): Promise<Response> {
52
+ const noStore = (body: string, status: number, extraHeaders: Record<string, string> = {}) =>
53
+ new Response(body, {
54
+ status,
55
+ headers: { "cache-control": "no-store", ...extraHeaders },
56
+ });
57
+
58
+ if (request.method !== "POST") {
59
+ return noStore("Method Not Allowed", 405, { allow: "POST" });
60
+ }
61
+
62
+ try {
63
+ const host = await authenticate(request);
64
+ if (!host) return noStore("Unauthorized", 401);
65
+
66
+ const secretKey = process.env.DROPBY_SECRET_KEY;
67
+ if (!secretKey) return noStore("DropBy is not configured", 500);
68
+
69
+ const dropby = createDropByServer({ secretKey });
70
+
71
+ const { session } = await dropby.browserSessions.create({
72
+ user: host.user,
73
+ ...(host.account ? { account: host.account } : {}),
74
+ });
75
+
76
+ // `session` is `{ sessionToken, expiresAt }`, what the browser's `auth` returns.
77
+ return Response.json(session, { headers: { "cache-control": "no-store" } });
78
+ } catch {
79
+ return noStore("DropBy session creation failed", 500);
80
+ }
81
+ }
82
+ ```
83
+
84
+ The endpoint is POST-only, returns `Allow: POST` with a 405 for other methods,
85
+ and sets `Cache-Control: no-store` on success and every explicit error response.
86
+
87
+ Omit `account` for a user-only product: the session then has no account.
88
+ Everything the user does without one stays theirs under any account they later
89
+ belong to. Deliver the session only over your authenticated application
90
+ response.
91
+
92
+ The client also exposes `ideas`, `asks`, `chat`, and `config` for server-side
93
+ workflows. Ids are plain strings: one in a URL path is checked before anything is
94
+ sent (`invalid_input`), and one in a request body is checked by the API (a 400
95
+ naming the field).
96
+
97
+ ## Feature flags
98
+
99
+ `config.evaluate` resolves every flag for a user and account, as the browser
100
+ does for its session:
101
+
102
+ <!-- snippet: import type { DropByServer } from "@dropby/server"; declare const dropby: DropByServer; -->
103
+
104
+ ```ts
105
+ const { config } = await dropby.config.evaluate({
106
+ user: { id: "user_123", plan: "pro" },
107
+ account: { id: "acct_42" },
108
+ });
109
+ if (config.new_exports === true) {
110
+ // show the new exports
111
+ }
112
+ ```
113
+
114
+ It takes no `context` or `device`, so rules on them see those fields as missing:
115
+ a condition that needs a value (`eq`, `in`, `exists`) never matches, and a
116
+ negative one (`neq`, or `not` around another) always does. The server and the
117
+ browser can therefore disagree for the same user. It needs a secret key with
118
+ `config:read`.
119
+
120
+ ## Retries
121
+
122
+ Every call retries by itself after a network error, a timeout, a 408, a 429 or
123
+ a 5xx: twice by default, waiting up to half a second, then up to a second (set
124
+ `maxRetries`, 0 to 10). A `Retry-After` of up to 10 seconds is waited out; a
125
+ 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.
127
+
128
+ Retrying is safe for every call. Updates and merges change nothing when
129
+ repeated (though an update retried after a lost answer can overwrite a change
130
+ a teammate made in between; updates are last-writer-wins), and `asks.create`, `chat.create`, `chat.reply` and `ideas.create` send
131
+ an idempotency key, one per call, generated when you give none: a repeat returns
132
+ what was created instead of posting again. If your backend retries a whole call
133
+ itself, for example from a job queue, pass your own key so those attempts are
134
+ one too:
135
+
136
+ <!-- snippet: import type { DropByServer } from "@dropby/server"; declare const dropby: DropByServer; declare const threadId: string; declare const job: { replyId: string }; -->
137
+
138
+ ```ts
139
+ // One key per write, stored with the job that retries it.
140
+ await dropby.chat.reply(threadId, { text: "we're on it" }, { idempotencyKey: job.replyId });
141
+ ```
142
+
143
+ - A key is 1 to 128 visible ASCII characters; use a UUID. Reusing one for a
144
+ different request is a 409 `idempotency_key_reused`.
145
+ - An ask's and an idea's key are shared across your product and environment,
146
+ every secret key and the dashboard included. A reply's key is shared across
147
+ every secret key of the product and environment (the dashboard's replies keep
148
+ their own). A new thread's key is its first message's, scoped to the user and
149
+ account, and shared with that user's widget sends; the user's widget can see
150
+ it.
151
+ - A repeat returns what was created as it is now: an ask may since be closed.
152
+ An ask is compared by what it asks and whom, so compute its window once and
153
+ keep it with the job; a recomputed `expiresAt` is a different request.
154
+ - Keys never expire.
155
+
156
+ ## Errors
157
+
158
+ Every error the client throws is a `DropByError` with a `code`, a `message`,
159
+ the HTTP `status` (undefined when no response arrived), the `retryAfter` seconds
160
+ when the API asked for a wait, and the underlying `cause`. An error that can pass
161
+ is thrown once the retries are spent; any other at once:
162
+
163
+ <!-- snippet: import type { DropByServer } from "@dropby/server"; declare const dropby: DropByServer; declare const threadId: string; declare const text: string; -->
164
+
165
+ ```ts
166
+ import { DropByError } from "@dropby/server";
167
+
168
+ try {
169
+ await dropby.chat.reply(threadId, { text });
170
+ } catch (error) {
171
+ if (error instanceof DropByError && error.code === "rate_limited") {
172
+ // error.retryAfter says how long the API asked to wait
173
+ }
174
+ throw error;
175
+ }
176
+ ```
177
+
178
+ API codes are `bad_request`, `unauthorized`, `forbidden`, `not_found`,
179
+ `conflict`, `idea_archived`, `idea_merged`, `idempotency_key_reused`,
180
+ `rate_limited`, `unavailable` and `internal_error`; the API may add more. The
181
+ client adds `invalid_input` (a bad argument or option, raised before anything
182
+ 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.
189
+
190
+ ## Identity proofs
191
+
192
+ `createIdentityProof` is an alternative server-side credential flow for a
193
+ browser session endpoint. Sign it locally with your browser key's identity
194
+ secret and return only the short-lived proof to the browser. Never use your
195
+ secret key for this, and never send the identity secret to the browser.
196
+
197
+ <!-- snippet: declare const user: { id: string; email?: string }; declare const account: { id: string; name?: string } | undefined; -->
198
+
199
+ ```ts
200
+ import { createIdentityProof, identityProofOptionsFromEnv } from "@dropby/server";
201
+
202
+ // Reads DROPBY_PRODUCT_ID, DROPBY_PUBLIC_KEY_ID, DROPBY_ENV and
203
+ // DROPBY_IDENTITY_SECRET; throws at startup naming the first that is missing or invalid.
204
+ const dropby = identityProofOptionsFromEnv(process.env);
205
+
206
+ const identityProof = await createIdentityProof({
207
+ ...dropby,
208
+ user: { id: user.id, email: user.email },
209
+ ...(account ? { account: { id: account.id, name: account.name } } : {}),
210
+ });
211
+ // return { identityProof } from your authenticated session endpoint
212
+ ```
213
+
214
+ On Cloudflare Workers, pass the request's `env` instead of `process.env`. Setting
215
+ the four variables per environment lets one codebase serve test and live.
216
+
217
+ Get these values from the dashboard: **settings → api keys**, then **reveal
218
+ identity secret** on the browser key's actions menu (or from the identity proof
219
+ option under "connect your app", whose server setup lists them). The dialog shows
220
+ `DROPBY_PRODUCT_ID`, `DROPBY_PUBLIC_KEY_ID`, `DROPBY_ENV` and
221
+ `DROPBY_IDENTITY_SECRET`. Only the secret is shown
222
+ once. If you lose it, create a new browser key, update your backend and browser
223
+ configuration, then revoke the old key.
224
+
225
+ The proof carries only the user's `id`, `name`, `email` and `attributes` and the
226
+ account's `id`, `name`, `domain` and `attributes`; other properties of the objects
227
+ you pass are left out, since the browser can read the proof. Proofs expire within
228
+ the supported short lifetime (900 seconds at most). The host backend remains the
229
+ source of identity truth; request-body fields from the browser are not an
230
+ authorization boundary.
231
+
232
+ `createIdentityProof` checks the secret, ids and lifetime before signing. The API
233
+ checks the rest (email, names, domain, attributes) when the browser exchanges
234
+ the proof: a proof that breaks one of those rules is answered with a 400 naming
235
+ the claim, such as `Invalid identity proof claims: user.email: …`.