@solutiondesk/events 0.0.0 → 1.0.0-1edea54
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 +21 -0
- package/README.md +187 -36
- package/dist/index.cjs +326 -0
- package/dist/index.d.cts +516 -0
- package/dist/index.d.ts +516 -0
- package/dist/index.js +313 -0
- package/package.json +55 -7
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wemogy GmbH
|
|
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
CHANGED
|
@@ -1,45 +1,196 @@
|
|
|
1
1
|
# @solutiondesk/events
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Typed client for [SolutionDesk](https://solutiondesk.io) event pages. Show your workspace's events on your own website, build your own event page and registration form, show event announcements, and register signed-in users from your server.
|
|
4
|
+
|
|
5
|
+
- No dependencies, ESM and CommonJS, full TypeScript types.
|
|
6
|
+
- Runs wherever `fetch` does: Node.js 18+, Deno, Bun, edge runtimes and browsers.
|
|
7
|
+
- Reading needs no key: only published events are ever returned.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @solutiondesk/events
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The guide [Events on your website](https://help.solutiondesk.io/docs/team/events-embedding) explains the API behind this package and where to set it up in SolutionDesk; the [Host API](https://help.solutiondesk.io/docs/team/host-api) covers the calls that need an API key.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createEventsClient } from "@solutiondesk/events";
|
|
19
|
+
|
|
20
|
+
const events = createEventsClient({ workspace: "acme", lang: "en" });
|
|
21
|
+
|
|
22
|
+
const { events: upcoming } = await events.listEvents();
|
|
23
|
+
const { event } = await events.getEvent("kundentag-2026");
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`workspace` is your workspace's slug, as in the app's URL.
|
|
27
|
+
|
|
28
|
+
## What you can do
|
|
29
|
+
|
|
30
|
+
| Method | API | Returns |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `listEvents({ when, lang })` | `GET /v1/event-pages/{workspace}` | Published events that haven't ended, soonest first; `when: "past"` for the ended ones, latest first. At most 100. Invite-only events are never listed. |
|
|
33
|
+
| `getEvent(slug, { invite, lang })` | `GET /v1/event-pages/{workspace}/{event}` | Everything on the event page: texts, times, location, images, programme (`sessions`), the registration `form` and whether registration is open. |
|
|
34
|
+
| `register(slug, input)` | `POST /v1/event-pages/{workspace}/{event}/registrations` | A registration as the public form sends it; the person confirms by email. |
|
|
35
|
+
| `getAnnouncements({ event, visitor, lang })` | `GET /v1/event-pages/{workspace}/announcement` | The event announcements for your website (bar or window), soonest event first. |
|
|
36
|
+
| `dismissAnnouncement({ event, visitor, round })` | `POST /v1/event-pages/{workspace}/announcement/dismiss` | Remembers that a signed-in visitor closed an announcement. |
|
|
37
|
+
| `imageUrl(url, width)` | | An event image at the next offered width up (320, 640, 960, 1280, 1920). |
|
|
38
|
+
|
|
39
|
+
An unknown workspace, a workspace without events and an unpublished event all answer `404`. Times are ISO 8601 in UTC; show them in the event's `timeZone`. `description` is safe HTML (paragraphs, headings, lists, links, emphasis), `descriptionText` the same as plain text. Online events never expose their link here — it's sent after registering.
|
|
40
|
+
|
|
41
|
+
## Example: event page with registration (Next.js App Router)
|
|
42
|
+
|
|
43
|
+
Render the event on the server:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
// app/events/[slug]/page.tsx
|
|
47
|
+
import { createEventsClient, SolutionDeskError } from "@solutiondesk/events";
|
|
48
|
+
import { notFound } from "next/navigation";
|
|
49
|
+
import { RegistrationForm } from "./registration-form";
|
|
50
|
+
|
|
51
|
+
const events = createEventsClient({ workspace: "acme", lang: "en" });
|
|
52
|
+
|
|
53
|
+
export default async function EventPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
54
|
+
const { slug } = await params;
|
|
55
|
+
try {
|
|
56
|
+
const { event } = await events.getEvent(slug, {}, { fetchOptions: { next: { revalidate: 60 } } });
|
|
57
|
+
return (
|
|
58
|
+
<main lang={event.locale}>
|
|
59
|
+
<h1>{event.title}</h1>
|
|
60
|
+
{event.description && <div dangerouslySetInnerHTML={{ __html: event.description }} />}
|
|
61
|
+
{event.registration.open ? (
|
|
62
|
+
<RegistrationForm slug={event.slug} form={event.form} locale={event.locale} />
|
|
63
|
+
) : (
|
|
64
|
+
<p>Registration is {event.registration.reason}.</p>
|
|
65
|
+
)}
|
|
66
|
+
</main>
|
|
67
|
+
);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof SolutionDeskError && error.isNotFound) notFound();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
and register from the visitor's browser, with the two anti-bot fields:
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
// app/events/[slug]/registration-form.tsx
|
|
79
|
+
"use client";
|
|
80
|
+
import { createEventsClient, type FormDefinition, SolutionDeskError } from "@solutiondesk/events";
|
|
81
|
+
import { useRef, useState } from "react";
|
|
82
|
+
|
|
83
|
+
const events = createEventsClient({ workspace: "acme" });
|
|
84
|
+
|
|
85
|
+
export function RegistrationForm(props: { slug: string; form: FormDefinition; locale: string }) {
|
|
86
|
+
const openedAt = useRef(Date.now());
|
|
87
|
+
const [message, setMessage] = useState("");
|
|
88
|
+
|
|
89
|
+
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
const data = new FormData(e.currentTarget);
|
|
92
|
+
try {
|
|
93
|
+
await events.register(props.slug, {
|
|
94
|
+
firstName: String(data.get("firstName")),
|
|
95
|
+
lastName: String(data.get("lastName")),
|
|
96
|
+
email: String(data.get("email")),
|
|
97
|
+
answers: {}, // your questions from props.form.fields, keyed by field id
|
|
98
|
+
locale: props.locale,
|
|
99
|
+
website: String(data.get("website") ?? ""), // honeypot: hidden, must stay empty
|
|
100
|
+
elapsedMs: Date.now() - openedAt.current, // under 1.5 s counts as a bot
|
|
101
|
+
});
|
|
102
|
+
setMessage("Check your email to confirm.");
|
|
103
|
+
} catch (error) {
|
|
104
|
+
setMessage(error instanceof SolutionDeskError ? error.message : "Please try again.");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<form onSubmit={onSubmit}>
|
|
110
|
+
<input name="firstName" required />
|
|
111
|
+
<input name="lastName" required />
|
|
112
|
+
<input name="email" type="email" required />
|
|
113
|
+
{/* Hidden from people; bots fill it in. */}
|
|
114
|
+
<input name="website" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />
|
|
115
|
+
<button type="submit">Register</button>
|
|
116
|
+
<p aria-live="polite">{message}</p>
|
|
117
|
+
</form>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Registration rules
|
|
123
|
+
|
|
124
|
+
- **Where from:** browsers can register only from the websites listed under **Event settings → Your websites** (CORS); other websites can read but not register. A request from your server works too, but all of them then count against your server's IP address (see limits).
|
|
125
|
+
- **Anti-bot fields:** `website` is a honeypot, the value of a hidden input people never see, so it must stay empty. `elapsedMs` is how long the form was open. A filled honeypot or under 1500 ms is dropped silently: the answer is still `check_email`, nothing is stored. Leave `elapsedMs` out if you don't measure it.
|
|
126
|
+
- **The answer** is always `{ result: "check_email" }`: the person confirms by email. Whether the address was new or already registered stays private.
|
|
127
|
+
- **Answers** are keyed by the form field `id`: text for `text`, `textarea`, `email` and `phone`, a number for `number`, `YYYY-MM-DD` for `date`, the option `id` for `select`, a list of option ids for `multiselect`, a list of session ids for `sessions`, `true` for a ticked `checkbox`, `consent` or `newsletter`. Send `company` / `jobTitle` when the form asks for them (`askCompany`, `askJobTitle`); they are required when `companyRequired` / `jobTitleRequired` say so.
|
|
128
|
+
- **Invitations:** for an invitation link, pass its token as `getEvent(slug, { invite })` and as `invite` in the registration.
|
|
129
|
+
- **Wrong input** rejects with a `SolutionDeskError` with status `400`, `message` and `fields` (field id → message), ready to show next to the inputs.
|
|
130
|
+
- **Limits:** reading 120 requests a minute per IP address; registering 10 in 10 minutes per IP address and 3 an hour per email address. Over the limit: `429`.
|
|
131
|
+
|
|
132
|
+
## Registering signed-in users from your server (API key)
|
|
133
|
+
|
|
134
|
+
When someone is signed in to your product, your server can register them directly. You vouch for the email, so there is no confirmation mail and the registration is placed at once (with the same rules: access, capacity, waiting list, required answers).
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// Server only — never ship the API key to a browser.
|
|
138
|
+
import { createEventsHostClient } from "@solutiondesk/events";
|
|
139
|
+
|
|
140
|
+
const host = createEventsHostClient({ apiKey: process.env.SOLUTIONDESK_API_KEY! });
|
|
141
|
+
|
|
142
|
+
const { result } = await host.register("kundentag-2026", {
|
|
143
|
+
firstName: "Ada",
|
|
144
|
+
lastName: "Lovelace",
|
|
145
|
+
email: "ada@example.com",
|
|
146
|
+
answers: { diet: "vegetarian" },
|
|
147
|
+
locale: "en",
|
|
148
|
+
});
|
|
149
|
+
// result: "confirmed" | "pending_approval" | "waitlisted"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
This calls `POST /v1/integration/events/{event}/registrations` with `Authorization: Bearer <apiKey>`. Create the key in SolutionDesk under **Settings → API keys** (the raw key is shown once). The person gets a mail with a link to change or cancel.
|
|
153
|
+
|
|
154
|
+
## Announcements on your website
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const { announcements } = await events.getAnnouncements({ lang: "en" });
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Each announcement has a `style` (`bar` or `modal`), texts, the button's `url` and a `round`. To keep an announcement closed on all of a signed-in user's devices, send `visitor`: a SHA-256 hex digest of your user id. Answers then carry `dismissed`, and `dismissAnnouncement({ event, visitor, round })` stores a close.
|
|
161
|
+
|
|
162
|
+
## Languages
|
|
163
|
+
|
|
164
|
+
Pass `lang` to the client (default for every call) or per call (any of the event's languages). Without it, the request's `Accept-Language` header decides (forward your visitor's with `headers`), then the event's main language. Every event carries `locale`, the language of its texts, and `languages`, everything it is offered in (main first, e.g. `[{ code: "en", label: "English" }, { code: "de", label: "Deutsch" }]`). Send `locale` with a registration and the person's mails come in that language.
|
|
165
|
+
|
|
166
|
+
## Images
|
|
167
|
+
|
|
168
|
+
`coverImage` and `images` are public addresses cached for a year, with a ready `srcset`, `width`, `height` and `alt`. `imageUrl(url, width)` gives the WebP at the next offered width up, for example for a Next.js custom image loader.
|
|
169
|
+
|
|
170
|
+
## Caching
|
|
4
171
|
|
|
5
|
-
|
|
172
|
+
The event endpoints send no `ETag`, so `etagCache` has no effect here. Cache with your framework instead: every method takes `{ fetchOptions }` as its last argument, which goes to `fetch` unchanged (e.g. Next.js `{ next: { revalidate: 60 } }`).
|
|
6
173
|
|
|
7
|
-
|
|
174
|
+
## Errors
|
|
8
175
|
|
|
9
|
-
|
|
176
|
+
Every failed request rejects with a `SolutionDeskError` with `status` (`0` when no answer arrived), `message` (the API's text), `body`, `url` and, on a `400`, `fields`. `isNotFound` and `isRateLimited` are shortcuts.
|
|
177
|
+
|
|
178
|
+
## Options
|
|
10
179
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
180
|
+
```ts
|
|
181
|
+
createEventsClient({
|
|
182
|
+
workspace: "acme", // required
|
|
183
|
+
lang: "en",
|
|
184
|
+
baseUrl: "https://api.solutiondesk.io",
|
|
185
|
+
fetch: customFetch,
|
|
186
|
+
headers: { "accept-language": visitorLanguages },
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Every method also takes request options as its last argument: `{ signal, headers, fetchOptions }`.
|
|
15
191
|
|
|
16
|
-
|
|
192
|
+
`verifyWebhookSignature` and `SolutionDeskError` are exported here too, the same as in `@solutiondesk/blog` and `@solutiondesk/legal`.
|
|
17
193
|
|
|
18
|
-
|
|
194
|
+
## License
|
|
19
195
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
To properly configure OIDC trusted publishing for this package:
|
|
23
|
-
|
|
24
|
-
1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
|
|
25
|
-
2. Configure the trusted publisher (e.g., GitHub Actions)
|
|
26
|
-
3. Specify the repository and workflow that should be allowed to publish
|
|
27
|
-
4. Use the configured workflow to publish your actual package
|
|
28
|
-
|
|
29
|
-
## DO NOT USE THIS PACKAGE
|
|
30
|
-
|
|
31
|
-
This package is a placeholder for OIDC configuration only. It:
|
|
32
|
-
- Contains no executable code
|
|
33
|
-
- Provides no functionality
|
|
34
|
-
- Should not be installed as a dependency
|
|
35
|
-
- Exists only for administrative purposes
|
|
36
|
-
|
|
37
|
-
## More Information
|
|
38
|
-
|
|
39
|
-
For more details about npm's trusted publishing feature, see:
|
|
40
|
-
- [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
|
|
41
|
-
- [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
|
|
42
|
-
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
**Maintained for OIDC setup purposes only**
|
|
196
|
+
MIT © wemogy GmbH
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
5
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
6
|
+
|
|
7
|
+
// ../sdk-core/src/http.ts
|
|
8
|
+
var DEFAULT_BASE_URL = "https://api.solutiondesk.io";
|
|
9
|
+
var SolutionDeskError = class extends Error {
|
|
10
|
+
constructor(message, init) {
|
|
11
|
+
super(message);
|
|
12
|
+
/** The HTTP status; `0` when no answer arrived (network error, aborted). */
|
|
13
|
+
__publicField(this, "status");
|
|
14
|
+
/** The parsed JSON body of the error answer (usually `{ error: string }`), else its text. */
|
|
15
|
+
__publicField(this, "body");
|
|
16
|
+
/** The requested URL. */
|
|
17
|
+
__publicField(this, "url");
|
|
18
|
+
/** Messages per input field on a `400` (e.g. `{ email: "Enter a valid email address." }`). */
|
|
19
|
+
__publicField(this, "fields");
|
|
20
|
+
this.name = "SolutionDeskError";
|
|
21
|
+
this.status = init.status;
|
|
22
|
+
this.body = init.body ?? null;
|
|
23
|
+
this.url = init.url;
|
|
24
|
+
this.fields = fieldsOf(init.body);
|
|
25
|
+
if (init.cause !== void 0) {
|
|
26
|
+
Object.defineProperty(this, "cause", { value: init.cause, enumerable: false });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** `404`: unknown workspace, module turned off or unknown item — on purpose indistinguishable. */
|
|
30
|
+
get isNotFound() {
|
|
31
|
+
return this.status === 404;
|
|
32
|
+
}
|
|
33
|
+
/** `429`: over the rate limit; try again later. */
|
|
34
|
+
get isRateLimited() {
|
|
35
|
+
return this.status === 429;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
function fieldsOf(body) {
|
|
39
|
+
if (!body || typeof body !== "object") return {};
|
|
40
|
+
const fields = body.fields;
|
|
41
|
+
if (!fields || typeof fields !== "object") return {};
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
44
|
+
if (typeof value === "string") out[key] = value;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function messageOf(body, status) {
|
|
49
|
+
if (body && typeof body === "object") {
|
|
50
|
+
const { error, message, statusCode } = body;
|
|
51
|
+
if (typeof statusCode === "number" && typeof message === "string" && message) return message;
|
|
52
|
+
if (typeof error === "string" && error) return error;
|
|
53
|
+
if (typeof message === "string" && message) return message;
|
|
54
|
+
if (Array.isArray(message) && typeof message[0] === "string") return message[0];
|
|
55
|
+
}
|
|
56
|
+
if (typeof body === "string" && body.trim() && body.length <= 300) return body.trim();
|
|
57
|
+
return `SolutionDesk answered ${status}.`;
|
|
58
|
+
}
|
|
59
|
+
function createMemoryEtagCache(maxEntries = 500) {
|
|
60
|
+
const entries = /* @__PURE__ */ new Map();
|
|
61
|
+
return {
|
|
62
|
+
get: (key) => entries.get(key),
|
|
63
|
+
set(key, value) {
|
|
64
|
+
entries.delete(key);
|
|
65
|
+
entries.set(key, value);
|
|
66
|
+
if (entries.size > maxEntries) {
|
|
67
|
+
const oldest = entries.keys().next().value;
|
|
68
|
+
if (oldest !== void 0) entries.delete(oldest);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function queryString(query) {
|
|
74
|
+
if (!query) return "";
|
|
75
|
+
const parts = [];
|
|
76
|
+
for (const [key, value] of Object.entries(query)) {
|
|
77
|
+
if (value === void 0 || value === null || value === "") continue;
|
|
78
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
79
|
+
}
|
|
80
|
+
return parts.length ? `?${parts.join("&")}` : "";
|
|
81
|
+
}
|
|
82
|
+
var seg = (value) => encodeURIComponent(String(value));
|
|
83
|
+
function parseBody(text) {
|
|
84
|
+
if (!text) return null;
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(text);
|
|
87
|
+
} catch {
|
|
88
|
+
return text;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function createHttp(options = {}) {
|
|
92
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
93
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
94
|
+
const cache = options.etagCache === true ? createMemoryEtagCache() : options.etagCache ? options.etagCache : void 0;
|
|
95
|
+
const url = (path, query) => `${baseUrl}/v1${path}${queryString(query)}`;
|
|
96
|
+
async function requestWithMeta(req) {
|
|
97
|
+
if (!fetchImpl) {
|
|
98
|
+
throw new Error("No global fetch available: pass `fetch` to the client.");
|
|
99
|
+
}
|
|
100
|
+
const target = url(req.path, req.query);
|
|
101
|
+
const headers = {
|
|
102
|
+
accept: req.accept ?? "application/json",
|
|
103
|
+
...options.headers,
|
|
104
|
+
...req.options?.headers
|
|
105
|
+
};
|
|
106
|
+
if (req.body !== void 0) headers["content-type"] = "application/json";
|
|
107
|
+
if (req.apiKey) headers.authorization = `Bearer ${req.apiKey}`;
|
|
108
|
+
const cacheKey = req.method === "GET" && !req.apiKey ? target : void 0;
|
|
109
|
+
const cached = cacheKey && cache ? cache.get(cacheKey) : void 0;
|
|
110
|
+
if (cached && !Object.keys(headers).some((h) => h.toLowerCase() === "if-none-match")) {
|
|
111
|
+
headers["if-none-match"] = cached.etag;
|
|
112
|
+
}
|
|
113
|
+
const init = {
|
|
114
|
+
...req.options?.fetchOptions,
|
|
115
|
+
method: req.method,
|
|
116
|
+
headers
|
|
117
|
+
};
|
|
118
|
+
if (req.body !== void 0) init.body = JSON.stringify(req.body);
|
|
119
|
+
if (req.options?.signal !== void 0) init.signal = req.options.signal;
|
|
120
|
+
let response;
|
|
121
|
+
try {
|
|
122
|
+
response = await fetchImpl(target, init);
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
125
|
+
throw new SolutionDeskError(`The request to SolutionDesk failed: ${reason}`, {
|
|
126
|
+
status: 0,
|
|
127
|
+
url: target,
|
|
128
|
+
cause
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const finalUrl = response.url;
|
|
132
|
+
const meta = {
|
|
133
|
+
status: response.status,
|
|
134
|
+
finalUrl: typeof finalUrl === "string" && finalUrl ? finalUrl : null
|
|
135
|
+
};
|
|
136
|
+
if (response.status === 304 && cached) {
|
|
137
|
+
return { data: cached.body, ...meta };
|
|
138
|
+
}
|
|
139
|
+
const text = await response.text();
|
|
140
|
+
const body = response.ok && req.responseType === "text" ? text : parseBody(text);
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
throw new SolutionDeskError(messageOf(body, response.status), {
|
|
143
|
+
status: response.status,
|
|
144
|
+
body,
|
|
145
|
+
url: target
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const etag = response.headers.get("etag");
|
|
149
|
+
if (cacheKey && cache && etag) cache.set(cacheKey, { etag, body });
|
|
150
|
+
return { data: body, ...meta };
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
baseUrl,
|
|
154
|
+
url,
|
|
155
|
+
requestWithMeta,
|
|
156
|
+
request: async (req) => (await requestWithMeta(req)).data
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function required(value, name) {
|
|
160
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
161
|
+
throw new TypeError(`SolutionDesk: \`${name}\` is required.`);
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ../sdk-core/src/images.ts
|
|
167
|
+
var IMAGE_WIDTHS = [320, 640, 960, 1280, 1920];
|
|
168
|
+
function imageWidthFor(width) {
|
|
169
|
+
return IMAGE_WIDTHS.find((w) => w >= width) ?? 1920;
|
|
170
|
+
}
|
|
171
|
+
function imageUrl(url, width) {
|
|
172
|
+
if (width === void 0) return url;
|
|
173
|
+
const base = url.replace(/([?&])w=\d+(&|$)/, (_m, sep, end) => end ? sep : "");
|
|
174
|
+
const join = base.includes("?") ? /[?&]$/.test(base) ? "" : "&" : "?";
|
|
175
|
+
return `${base}${join}w=${imageWidthFor(width)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ../sdk-core/src/webhook.ts
|
|
179
|
+
var SIGNATURE_HEADER = "x-solutiondesk-signature";
|
|
180
|
+
var EVENT_HEADER = "x-solutiondesk-event";
|
|
181
|
+
function encoder() {
|
|
182
|
+
const Encoder = globalThis.TextEncoder;
|
|
183
|
+
if (!Encoder) throw new Error("TextEncoder is not available in this runtime.");
|
|
184
|
+
return new Encoder();
|
|
185
|
+
}
|
|
186
|
+
function bytesOf(body) {
|
|
187
|
+
if (typeof body === "string") return encoder().encode(body);
|
|
188
|
+
return body instanceof Uint8Array ? body : new Uint8Array(body);
|
|
189
|
+
}
|
|
190
|
+
function webhookBodyText(body) {
|
|
191
|
+
if (typeof body === "string") return body;
|
|
192
|
+
const Decoder = globalThis.TextDecoder;
|
|
193
|
+
if (!Decoder) throw new Error("TextDecoder is not available in this runtime.");
|
|
194
|
+
return new Decoder().decode(bytesOf(body));
|
|
195
|
+
}
|
|
196
|
+
function hex(buffer) {
|
|
197
|
+
let out = "";
|
|
198
|
+
for (const byte of new Uint8Array(buffer)) out += byte.toString(16).padStart(2, "0");
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
function constantTimeEqual(a, b) {
|
|
202
|
+
if (a.length !== b.length) return false;
|
|
203
|
+
let diff = 0;
|
|
204
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
205
|
+
return diff === 0;
|
|
206
|
+
}
|
|
207
|
+
function parseHeader(header) {
|
|
208
|
+
let t = Number.NaN;
|
|
209
|
+
const signatures = [];
|
|
210
|
+
for (const part of header.split(",")) {
|
|
211
|
+
const index = part.indexOf("=");
|
|
212
|
+
if (index < 0) continue;
|
|
213
|
+
const key = part.slice(0, index).trim();
|
|
214
|
+
const value = part.slice(index + 1).trim();
|
|
215
|
+
if (key === "t" && /^\d{1,12}$/.test(value)) t = Number(value);
|
|
216
|
+
else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value.toLowerCase());
|
|
217
|
+
}
|
|
218
|
+
return Number.isFinite(t) && signatures.length > 0 ? { t, signatures } : null;
|
|
219
|
+
}
|
|
220
|
+
async function verifyWebhookSignature(secret, rawBody, header, options = {}) {
|
|
221
|
+
if (!secret || !header || header.length > 1e3) return false;
|
|
222
|
+
const parsed = parseHeader(header);
|
|
223
|
+
if (!parsed) return false;
|
|
224
|
+
const now = options.now instanceof Date ? options.now.getTime() : options.now ?? Date.now();
|
|
225
|
+
const tolerance = options.toleranceSeconds ?? 300;
|
|
226
|
+
if (Math.abs(now / 1e3 - parsed.t) > tolerance) return false;
|
|
227
|
+
const subtle = options.crypto?.subtle ?? globalThis.crypto?.subtle;
|
|
228
|
+
if (!subtle) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
"Web Crypto is not available: pass `crypto` (on Node 18: require('node:crypto').webcrypto)."
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const text = encoder();
|
|
234
|
+
const key = await subtle.importKey(
|
|
235
|
+
"raw",
|
|
236
|
+
text.encode(secret),
|
|
237
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
238
|
+
false,
|
|
239
|
+
["sign"]
|
|
240
|
+
);
|
|
241
|
+
const prefix = text.encode(`${parsed.t}.`);
|
|
242
|
+
const body = bytesOf(rawBody);
|
|
243
|
+
const signed = new Uint8Array(prefix.length + body.length);
|
|
244
|
+
signed.set(prefix, 0);
|
|
245
|
+
signed.set(body, prefix.length);
|
|
246
|
+
const expected = hex(await subtle.sign("HMAC", key, signed));
|
|
247
|
+
let ok = false;
|
|
248
|
+
for (const signature of parsed.signatures) ok = constantTimeEqual(expected, signature) || ok;
|
|
249
|
+
return ok;
|
|
250
|
+
}
|
|
251
|
+
async function parseWebhook(secret, rawBody, header, options = {}) {
|
|
252
|
+
if (!await verifyWebhookSignature(secret, rawBody, header, options)) return null;
|
|
253
|
+
try {
|
|
254
|
+
return JSON.parse(webhookBodyText(rawBody));
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/client.ts
|
|
261
|
+
function createEventsClient(options) {
|
|
262
|
+
const workspace = required(options.workspace, "workspace");
|
|
263
|
+
const http = createHttp(options);
|
|
264
|
+
const base = `/event-pages/${seg(workspace)}`;
|
|
265
|
+
const langOf = (params) => params?.lang ?? options.lang;
|
|
266
|
+
return {
|
|
267
|
+
workspace,
|
|
268
|
+
listEvents: async (params, requestOptions) => http.request({
|
|
269
|
+
method: "GET",
|
|
270
|
+
path: base,
|
|
271
|
+
query: { when: params?.when, lang: langOf(params) },
|
|
272
|
+
options: requestOptions
|
|
273
|
+
}),
|
|
274
|
+
getEvent: async (slug, params, requestOptions) => http.request({
|
|
275
|
+
method: "GET",
|
|
276
|
+
path: `${base}/${seg(required(slug, "slug"))}`,
|
|
277
|
+
query: { invite: params?.invite, lang: langOf(params) },
|
|
278
|
+
options: requestOptions
|
|
279
|
+
}),
|
|
280
|
+
getAnnouncements: async (params, requestOptions) => http.request({
|
|
281
|
+
method: "GET",
|
|
282
|
+
path: `${base}/announcement`,
|
|
283
|
+
query: { event: params?.event, visitor: params?.visitor, lang: langOf(params) },
|
|
284
|
+
options: requestOptions
|
|
285
|
+
}),
|
|
286
|
+
dismissAnnouncement: async (input, requestOptions) => http.request({
|
|
287
|
+
method: "POST",
|
|
288
|
+
path: `${base}/announcement/dismiss`,
|
|
289
|
+
body: { event: input.event, visitor: input.visitor, round: input.round },
|
|
290
|
+
options: requestOptions
|
|
291
|
+
}),
|
|
292
|
+
register: async (slug, input, requestOptions) => http.request({
|
|
293
|
+
method: "POST",
|
|
294
|
+
path: `${base}/${seg(required(slug, "slug"))}/registrations`,
|
|
295
|
+
body: input,
|
|
296
|
+
options: requestOptions
|
|
297
|
+
}),
|
|
298
|
+
imageUrl
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function createEventsHostClient(options) {
|
|
302
|
+
const apiKey = required(options.apiKey, "apiKey");
|
|
303
|
+
const http = createHttp({ ...options, etagCache: false });
|
|
304
|
+
return {
|
|
305
|
+
register: async (slug, input, requestOptions) => http.request({
|
|
306
|
+
method: "POST",
|
|
307
|
+
path: `/integration/events/${seg(required(slug, "slug"))}/registrations`,
|
|
308
|
+
body: input,
|
|
309
|
+
apiKey,
|
|
310
|
+
options: requestOptions
|
|
311
|
+
})
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
|
|
316
|
+
exports.EVENT_HEADER = EVENT_HEADER;
|
|
317
|
+
exports.IMAGE_WIDTHS = IMAGE_WIDTHS;
|
|
318
|
+
exports.SIGNATURE_HEADER = SIGNATURE_HEADER;
|
|
319
|
+
exports.SolutionDeskError = SolutionDeskError;
|
|
320
|
+
exports.createEventsClient = createEventsClient;
|
|
321
|
+
exports.createEventsHostClient = createEventsHostClient;
|
|
322
|
+
exports.createMemoryEtagCache = createMemoryEtagCache;
|
|
323
|
+
exports.imageUrl = imageUrl;
|
|
324
|
+
exports.imageWidthFor = imageWidthFor;
|
|
325
|
+
exports.parseWebhook = parseWebhook;
|
|
326
|
+
exports.verifyWebhookSignature = verifyWebhookSignature;
|