coss 0.0.5 → 0.3.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 Cal.com, Inc.
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,395 @@
1
+ # coss
2
+
3
+ The official TypeScript SDK for COSS: one API key for email, and soon SMS, video, scheduling and more.
4
+
5
+ ```sh
6
+ npm install coss
7
+ ```
8
+
9
+ Published from [cosscom/coss](https://github.com/cosscom/coss) by CI, with
10
+ [provenance](https://docs.npmjs.com/generating-provenance-statements). Versions 0.0.1–0.0.5 of the npm package
11
+ are an earlier, unrelated package by a previous owner (deprecated).
12
+
13
+ - Zero dependencies, ESM-only, fully typed.
14
+ - `coss.email`, `coss.domains`, `coss.webhookEndpoints` and `coss.reputation` behind one API key.
15
+ - Every method returns `{ data, error }`. API and network failures are returned, never thrown.
16
+ - Retries transient failures automatically, and sends are idempotent.
17
+
18
+ ## Quick start
19
+
20
+ ```ts
21
+ import { Coss } from "coss";
22
+
23
+ const coss = new Coss({ apiKey: process.env.COSS_API_KEY });
24
+
25
+ const { data, error } = await coss.email.send({
26
+ from: "Acme <onboarding@example.com>",
27
+ to: "delivered@example.com",
28
+ subject: "Hello from COSS",
29
+ html: "<p>It works!</p>",
30
+ });
31
+
32
+ if (error) {
33
+ console.error(error.type, error.code, error.message, error.request_id);
34
+ } else {
35
+ console.log(data.id, data.status); // "email_01k…", "queued" (sent moments later)
36
+ }
37
+
38
+ const { data: email } = await coss.email.get("email_01k…");
39
+ console.log(email?.recipients); // [{ address: "user@example.com", type: "to", status: "delivered", … }]
40
+
41
+ const { data: events } = await coss.email.listEvents("email_01k…"); // email.sent, email.delivered, …
42
+ ```
43
+
44
+ Keys come in two kinds. `coss_live_…` keys belong to a project's **production** environment and
45
+ deliver email from your organization's verified domains. `coss_test_…` keys belong to
46
+ **development**: nothing is delivered, every email is recorded with `sandbox: true`, and delivery
47
+ events are simulated (send to `bounce@simulator.amazonses.com` or `complaint@…` to test failures).
48
+
49
+ Request and response fields use the API's snake_case names, such as `reply_to` and `created_at`.
50
+
51
+ A runnable example lives in [`examples/send-email.ts`](./examples/send-email.ts):
52
+
53
+ ```sh
54
+ COSS_API_KEY=coss_test_… COSS_BASE_URL=http://localhost:3001 bun packages/sdk/examples/send-email.ts
55
+ ```
56
+
57
+ ## Options
58
+
59
+ ```ts
60
+ new Coss({
61
+ apiKey: "coss_test_…", // default: process.env.COSS_API_KEY
62
+ baseUrl: "https://api.coss.com", // default: process.env.COSS_BASE_URL, then https://api.coss.com
63
+ timeout: 30_000, // per attempt, in ms
64
+ maxRetries: 2, // 0 disables retries
65
+ fetch: customFetch, // default: the global fetch
66
+ apiVersion: "2026-10-10", // default: API_VERSION, the version these types describe
67
+ dangerouslyAllowBrowser: false,
68
+ });
69
+ ```
70
+
71
+ The constructor throws in only two cases, both programmer errors:
72
+ - No API key is found.
73
+ - It runs where a `document` global exists (a browser, or a jsdom or happy-dom test environment)
74
+ without `dangerouslyAllowBrowser: true`.
75
+
76
+ API keys are secrets, so call COSS from your server.
77
+
78
+ Each method also takes per-request options:
79
+
80
+ ```ts
81
+ await coss.email.send(params, { idempotencyKey: "welcome-user_123", signal: controller.signal });
82
+ await coss.email.get(id, { signal });
83
+ ```
84
+
85
+ ## API versions
86
+
87
+ The API is versioned by date. Every request sends a `Coss-Version` header: by default `API_VERSION`,
88
+ the version this SDK's types describe, so upgrading the SDK is how you upgrade the API version.
89
+ Your organization's pinned version (which applies to plain HTTP requests without the header) never
90
+ affects the SDK. Every response says which version it was rendered in, in its `Coss-Version` header.
91
+
92
+ Webhook endpoints have their own `api_version`. Created through the SDK, it defaults to the client's
93
+ version, so events match the `WebhookEvent` types. Each event carries it as `api_version`. After an
94
+ SDK upgrade that moves `API_VERSION`, update your endpoints too:
95
+
96
+ ```ts
97
+ import { API_VERSION } from "coss";
98
+
99
+ await coss.webhookEndpoints.update("whep_01k…", { api_version: API_VERSION });
100
+ ```
101
+
102
+ ## Attachments
103
+
104
+ Attach up to 20 files (25 MB combined) as base64. The content type defaults from the extension.
105
+ A `content_id` makes an attachment inline, so the HTML can reference it as `cid:…`:
106
+
107
+ ```ts
108
+ import { readFile } from "node:fs/promises";
109
+
110
+ await coss.email.send({
111
+ from: "Acme <billing@acme.com>",
112
+ to: "user@example.com",
113
+ subject: "Your invoice",
114
+ html: '<img src="cid:logo@acme" alt="Acme"><p>Your invoice is attached.</p>',
115
+ attachments: [
116
+ { filename: "invoice.pdf", content: (await readFile("invoice.pdf")).toString("base64") },
117
+ { filename: "logo.png", content: logoBase64, content_id: "logo@acme" },
118
+ ],
119
+ });
120
+ ```
121
+
122
+ Emails only return attachment metadata (`id`, `filename`, `content_type`, `content_id`, `size`).
123
+ COSS discards the content once the email is sent. Executables and other file types the delivery
124
+ provider refuses (`.exe`, `.js`, `.bat`, …) are rejected with a `validation_error`.
125
+
126
+ ## Domains
127
+
128
+ Live keys can only send from a verified domain. Domains belong to the **organization**, so a
129
+ `coss_test_…` key manages the same list a `coss_live_…` key does.
130
+
131
+ ```ts
132
+ const { data: domain } = await coss.domains.create({ name: "acme.com" });
133
+
134
+ // Publish these, then check them. `records` carries the result of the last check.
135
+ for (const record of domain?.records ?? []) {
136
+ console.log(record.type, record.name, record.value, record.status); // "CNAME", "…", "…", "missing"
137
+ }
138
+
139
+ const { data: checked } = await coss.domains.verify(domain!.id);
140
+ console.log(checked?.status, checked?.can_send); // "verified", true
141
+
142
+ await coss.domains.list();
143
+ await coss.domains.get(domain!.id);
144
+ await coss.domains.delete(domain!.id);
145
+ ```
146
+
147
+ `verify` asks the provider and DNS right away instead of waiting for the background check, so it is
148
+ rate limited: a second call within a few seconds answers `429` (the SDK waits out `Retry-After` and
149
+ retries). Verification gives up after 72 hours and the domain becomes `failed`; calling `verify`
150
+ again then starts over with fresh DKIM records, which have to be published too. The `dmarc` record
151
+ is recommended rather than required (`required: false`).
152
+
153
+ ## Webhook endpoints
154
+
155
+ Endpoints belong to the API key's own **environment**, so sandbox and production receivers stay
156
+ separate. The signing secret is returned only when the endpoint is created.
157
+
158
+ ```ts
159
+ const { data: endpoint } = await coss.webhookEndpoints.create({
160
+ url: "https://example.com/webhooks/coss",
161
+ description: "Billing service",
162
+ event_types: ["email.bounced", "email.complained"], // omit for every event type
163
+ });
164
+ console.log(endpoint?.secret); // "whsec_…" — store it now, it is not returned again
165
+
166
+ await coss.webhookEndpoints.update(endpoint!.id, { enabled: false }); // pause without deleting
167
+ await coss.webhookEndpoints.list();
168
+ await coss.webhookEndpoints.get(endpoint!.id);
169
+ await coss.webhookEndpoints.delete(endpoint!.id);
170
+ ```
171
+
172
+ `update` only changes the fields you pass, and never the signing secret, so receivers keep
173
+ verifying deliveries. `event_types: []` means every event type, including ones added later.
174
+
175
+ ## Scheduled sends
176
+
177
+ Pass `scheduled_at` (ISO 8601 with an offset, at most 30 days ahead) to send later. The email
178
+ comes back with the status `scheduled` and is sent at that time; a time that is not in the future
179
+ sends immediately.
180
+
181
+ ```ts
182
+ const { data: email } = await coss.email.send({
183
+ ...params,
184
+ scheduled_at: "2026-10-11T09:00:00Z", // or new Date(…).toISOString()
185
+ }); // { status: "scheduled", scheduled_at: "2026-10-11T09:00:00.000Z", … }
186
+
187
+ // Until it is sent, move it to another time (at most 30 days ahead; a past time sends it now)…
188
+ await coss.email.reschedule(email.id, { scheduled_at: "2026-10-12T09:00:00Z" });
189
+ // …or cancel it.
190
+ const { error } = await coss.email.cancel(email.id); // status "canceled"
191
+ // error?.code === "email_not_cancelable" (or "email_not_reschedulable") once it is being sent,
192
+ // was sent or, for reschedule, was canceled.
193
+ ```
194
+
195
+ Scheduling, rescheduling and canceling are recorded as `email.scheduled`, `email.rescheduled`
196
+ (with the new and previous times in `data.reschedule`) and `email.canceled` events (and webhooks).
197
+
198
+ ## Suppressions
199
+
200
+ Each environment has a suppression list. Sends skip suppressed recipients: they get the status
201
+ `suppressed` and an `email.suppressed` event, and the rest of the email is still sent. Permanent
202
+ bounces and spam complaints add an address for every email (`scope: "*"`) automatically.
203
+
204
+ An entry can also cover a single category. That makes the list a good backend for unsubscribe
205
+ links:
206
+
207
+ ```ts
208
+ // The recipient unsubscribed from marketing email.
209
+ await coss.email.suppressions.add({ address: "jane@example.com", scope: "marketing" });
210
+
211
+ // Skipped: the email has the suppressed category.
212
+ await coss.email.send({ ...params, to: "jane@example.com", category: "marketing" });
213
+ // Delivered: receipts are not suppressed.
214
+ await coss.email.send({ ...params, to: "jane@example.com", category: "receipts" });
215
+
216
+ const { data: check } = await coss.email.suppressions.check({
217
+ address: "jane@example.com",
218
+ category: "marketing",
219
+ }); // { suppressed: true, suppressions: [{ id: "sup_01k…", scope: "marketing", … }] }
220
+
221
+ const { data: page } = await coss.email.suppressions.list({ limit: 50 }); // newest first
222
+ // Next page: list({ after: page.data.at(-1).id }) while page.has_more.
223
+ await coss.email.suppressions.remove("sup_01k…"); // e.g. when they resubscribe
224
+ ```
225
+
226
+ Move whole lists in and out in bulk. `import` sends 1,000 entries per request and adds up the
227
+ results; invalid entries are skipped and reported by their position, and entries already on the
228
+ list are left unchanged, so importing again is safe:
229
+
230
+ ```ts
231
+ const { data: result } = await coss.email.suppressions.import({
232
+ suppressions: [{ address: "jane@example.com" }, { address: "joe@example.com", scope: "marketing" }],
233
+ }); // { created: 2, existing: 0, invalid: [] }
234
+
235
+ // CSV with the columns address,scope,reason,created_at,email_id,id (oldest first).
236
+ const { data: csv } = await coss.email.suppressions.export({ reason: "bounce" });
237
+ ```
238
+
239
+ Imported entries have the reason `import`. The dashboard imports and exports the same CSV.
240
+
241
+ ### Open and click tracking
242
+
243
+ Tracking is off unless the environment turns it on in the dashboard (a default, plus rules per
244
+ category) or a send asks for it. COSS then adds an invisible image to the HTML body and sends its
245
+ links through a redirect. Links with a `data-coss-no-track` attribute are left as they are.
246
+
247
+ ```ts
248
+ const { data: sent } = await coss.email.send({
249
+ ...params,
250
+ html: '<p><a href="https://acme.com/start">Get started</a></p>',
251
+ tracking: { opens: true, clicks: true }, // overrides the environment's settings for this email
252
+ });
253
+
254
+ const { data: email } = await coss.email.get(sent.id);
255
+ console.log(email?.tracking); // { opens: true, clicks: true, open_count: 1, click_count: 0, … }
256
+
257
+ const { data: links } = await coss.email.listLinks(sent.id);
258
+ // [{ url: "https://acme.com/start", click_count: 0, … }]
259
+ ```
260
+
261
+ Opens and clicks arrive as `email.opened` and `email.clicked` events (in `listEvents` and in
262
+ webhooks), with `data.open` / `data.click`. They name a recipient only when the email had exactly
263
+ one. Mail clients that preload images and link scanners can inflate the numbers.
264
+
265
+ ## Reputation
266
+
267
+ COSS watches your live bounce and complaint rates. When one gets too high your organization is
268
+ warned, and past the suspension threshold live sending is suspended automatically (sends fail with
269
+ `permission_error` / `account_suspended`; sandbox keys keep working) until COSS support reinstates it.
270
+
271
+ ```ts
272
+ const { data } = await coss.reputation.get();
273
+ // { status: "warning", reason: "bounce_rate", checks: [{ trigger: "bounce_rate", rate: 0.051, … }], … }
274
+ ```
275
+
276
+
277
+ ## Webhooks
278
+
279
+ Webhook endpoints receive `email.sent`, `email.delivered`, `email.delivery_delayed`,
280
+ `email.bounced`, `email.complained`, `email.failed`, `email.suppressed`, `email.opened` and
281
+ `email.clicked` events, plus `account.reputation_warning`, `account.reputation_recovered`,
282
+ `account.suspended` and `account.reinstated` for live environments, signed with
283
+ [Standard Webhooks](https://www.standardwebhooks.com) headers. Verify them with the raw body:
284
+
285
+ ```ts
286
+ import { verifyWebhook, WebhookVerificationError } from "coss";
287
+
288
+ export async function POST(request: Request) {
289
+ try {
290
+ const event = await verifyWebhook(await request.text(), request.headers, process.env.COSS_WEBHOOK_SECRET!);
291
+ if (event.type === "email.bounced") console.log(event.data.recipient, event.data.bounce);
292
+ if (event.type === "account.suspended") console.log(event.data.reason, event.data.checks);
293
+ return new Response("ok");
294
+ } catch (error) {
295
+ if (error instanceof WebhookVerificationError) return new Response("invalid", { status: 400 });
296
+ throw error;
297
+ }
298
+ }
299
+ ```
300
+
301
+ Unlike other methods, `verifyWebhook` throws. Deliveries are retried for about a day and may arrive
302
+ out of order (use `data.occurred_at`) or more than once (deduplicate by `event.id`).
303
+
304
+ ## Errors
305
+
306
+ `error` is a plain object:
307
+
308
+ ```ts
309
+ interface CossError {
310
+ type: CossErrorType;
311
+ code: string; // e.g. "invalid_api_key", "validation_error"
312
+ message: string;
313
+ status: number | null; // null when no response was received
314
+ request_id: string | null; // include it when contacting support
315
+ param?: string;
316
+ issues?: { path: string; message: string }[]; // per-field validation problems
317
+ }
318
+ ```
319
+
320
+ | `type` | Status | Meaning |
321
+ | ----------------------- | ----------------- | ----------------------------------------------------------------- |
322
+ | `invalid_request_error` | 400 / 413 / 422 | Malformed JSON, payload too large, or failed validation (`issues`) |
323
+ | `authentication_error` | 401 | `missing_api_key` or `invalid_api_key` |
324
+ | `permission_error` | 403 | The key lacks the permission (`insufficient_permissions`) |
325
+ | `not_found_error` | 404 | No such resource in the key's environment |
326
+ | `idempotency_error` | 409 / 422 | `request_in_progress` (retried automatically), or `idempotency_key_reused` with a different body |
327
+ | `rate_limit_error` | 429 | Too many requests with the key (`rate_limited`), the send quota is used up (`quota_exceeded`), or a domain was checked moments ago (`domain_recently_checked`) |
328
+ | `api_error` | 5xx, other | Server error. Also used for responses that are not a COSS error envelope (`unexpected_response`) |
329
+ | `connection_error` | — | Network failure (`network_error`), or you aborted (`aborted`) |
330
+ | `timeout_error` | — | An attempt exceeded `timeout` (`request_timeout`) |
331
+
332
+ ## Retries and idempotency
333
+
334
+ The SDK retries failed requests up to `maxRetries` times (default 2) on:
335
+ - connection errors and timeouts;
336
+ - `409`, `429` and `5xx` responses.
337
+
338
+ Other errors are returned immediately.
339
+
340
+ `webhookEndpoints.create()` never retries automatically, regardless of `maxRetries`: the API does
341
+ not deduplicate endpoint creation, even with an `Idempotency-Key`. If the response is lost, creation
342
+ may still have succeeded. List endpoints and delete the endpoint with the unknown secret before
343
+ creating a replacement.
344
+
345
+ The wait between attempts grows exponentially (500 ms, 1 s, 2 s, … up to 8 s) with random jitter. When
346
+ the response has a `Retry-After` header, the SDK waits exactly that long instead. If `Retry-After` asks
347
+ for more than 60 s, the SDK returns the error rather than waiting.
348
+
349
+ Every `POST` carries an `Idempotency-Key` header. It is either the key you pass or a random UUID, and
350
+ the same key is reused on every retry of that call. For email sends, the API stores the result for 24 hours, so a
351
+ retried send is never delivered twice:
352
+ - A repeat with the same key and the same body returns the original email.
353
+ - A repeat with the same key and a different body fails with `idempotency_key_reused`.
354
+
355
+ To make a send safe across process restarts too, pass your own key, derived from your data
356
+ (for example `welcome-${userId}`).
357
+
358
+ ## Timeouts and cancellation
359
+
360
+ `timeout` applies to each attempt and covers reading the response body. Pass an `AbortSignal` to
361
+ cancel a call. Aborting stops the request in flight and any pending retry, and returns
362
+ `{ type: "connection_error", code: "aborted" }`.
363
+
364
+ ## Runtime support
365
+
366
+ The SDK works on Node.js ≥ 22, Bun, Deno and Cloudflare Workers. It only needs the standard `fetch`,
367
+ `AbortSignal.timeout`/`AbortSignal.any` and `crypto.randomUUID`. It never uses Node built-ins.
368
+
369
+ The package is **ESM-only**:
370
+ - `import { Coss } from "coss"` works everywhere.
371
+ - On Node 22.12+ (and 20.19+), CommonJS can also `require("coss")` because Node supports `require(esm)` there.
372
+
373
+ Environment fallbacks read `process.env` where it exists. On Workers or Deno without env access,
374
+ pass `apiKey` (and `baseUrl`) explicitly.
375
+
376
+ ## Development
377
+
378
+ ```sh
379
+ bun run --cwd packages/sdk typecheck # src (isolatedDeclarations) + tests
380
+ bun run --cwd packages/sdk test # bun:test with a mocked fetch; no network
381
+ bun run --cwd packages/sdk build # tsdown → dist/, then publint and attw
382
+ ```
383
+
384
+ `src/version.ts` must match `version` in `package.json`, and a test enforces it. Releases are
385
+ prepared with [Changesets](https://github.com/changesets/changesets): add one with `bun run changeset`
386
+ for every user-facing change. `bun run version-packages` bumps `package.json`, `src/version.ts` and
387
+ `CHANGELOG.md` together, and on `main` CI opens a "Version packages" PR that does the same. Nothing
388
+ is published to npm.
389
+
390
+ `src/api-version.ts` mirrors the API's versions. When the API adds one, add it there, move
391
+ `API_VERSION` and update the wire types in the same change. The contract tests in `apps/api` check
392
+ both.
393
+
394
+ `tsdown` rewrites the `exports`, `main`, `module` and `types` fields in `package.json` on every
395
+ build. The committed values are exactly what it generates.
@@ -0,0 +1,11 @@
1
+ //#region src/device.d.ts
2
+ export interface CollectDeviceOptions {
3
+ /**
4
+ * Where the device id is kept: `local` (default) remembers it in `localStorage`; `memory`
5
+ * keeps it only for this page (for example until the user consents to storage).
6
+ */
7
+ storage?: "local" | "memory";
8
+ }
9
+ /** Returns a token for `device.token` in `coss.risk.assess()`. Make a new one per submission. */
10
+ export declare function collectDevice(options?: CollectDeviceOptions): string;
11
+ //#endregion
package/dist/device.js ADDED
@@ -0,0 +1,71 @@
1
+ //#region src/device.ts
2
+ /**
3
+ * `coss/device`: recognizes the browser behind risk events. Call `collectDevice()` in the browser
4
+ * when the user submits a form, send the token to your server with it, and pass it to
5
+ * `coss.risk.assess({ device: { token } })`.
6
+ *
7
+ * The token carries a random id this browser keeps in `localStorage` (`coss_device_id`, no
8
+ * cookies) and coarse facts: time zone, languages, platform, screen size, touch support, and
9
+ * whether the browser is automated (WebDriver, headless). No canvas, WebGL, font or audio
10
+ * fingerprinting: a browser whose storage is cleared looks like a new one. COSS keeps only a keyed
11
+ * hash of the id.
12
+ *
13
+ * Collects nothing until called, and never throws: outside a browser the token simply carries
14
+ * fewer facts.
15
+ */
16
+ /** The token format version, checked by the API. */
17
+ const TOKEN_PREFIX = "cd1.";
18
+ const STORAGE_KEY = "coss_device_id";
19
+ const HEADLESS = /HeadlessChrome|PhantomJS|SlimerJS/;
20
+ let memoryId;
21
+ function randomId() {
22
+ const bytes = /* @__PURE__ */ new Uint8Array(16);
23
+ globalThis.crypto.getRandomValues(bytes);
24
+ return base64Url(bytes);
25
+ }
26
+ function base64Url(bytes) {
27
+ let binary = "";
28
+ for (const byte of bytes) binary += String.fromCharCode(byte);
29
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
30
+ }
31
+ function deviceId(storage) {
32
+ if (storage === "local") try {
33
+ const store = globalThis.localStorage;
34
+ if (store) {
35
+ const stored = store.getItem(STORAGE_KEY);
36
+ if (stored && /^[A-Za-z0-9_-]{16,64}$/.test(stored)) return stored;
37
+ const id = randomId();
38
+ store.setItem(STORAGE_KEY, id);
39
+ return id;
40
+ }
41
+ } catch {}
42
+ memoryId ??= randomId();
43
+ return memoryId;
44
+ }
45
+ function safely(read, fallback) {
46
+ try {
47
+ return read() ?? fallback;
48
+ } catch {
49
+ return fallback;
50
+ }
51
+ }
52
+ /** Returns a token for `device.token` in `coss.risk.assess()`. Make a new one per submission. */
53
+ function collectDevice(options = {}) {
54
+ const nav = globalThis.navigator;
55
+ const screen = globalThis.screen;
56
+ const userAgent = safely(() => nav?.userAgent ?? "", "");
57
+ const payload = {
58
+ id: deviceId(options.storage ?? "local"),
59
+ t: Date.now(),
60
+ tz: safely(() => Intl.DateTimeFormat().resolvedOptions().timeZone.slice(0, 64) || null, null),
61
+ l: safely(() => [...nav?.languages ?? []].slice(0, 10).map((l) => l.slice(0, 35)), []),
62
+ p: safely(() => (nav?.userAgentData?.platform || nav?.platform || "").slice(0, 64) || null, null),
63
+ s: safely(() => screen ? [Math.round(screen.width), Math.round(screen.height)] : null, null),
64
+ tp: safely(() => typeof nav?.maxTouchPoints === "number" ? Math.round(nav.maxTouchPoints) : null, null),
65
+ wd: safely(() => nav?.webdriver === true, false),
66
+ hl: HEADLESS.test(userAgent)
67
+ };
68
+ return `${TOKEN_PREFIX}${base64Url(new TextEncoder().encode(JSON.stringify(payload)))}`;
69
+ }
70
+ //#endregion
71
+ export { collectDevice };