@solutiondesk/legal 0.0.0 → 1.0.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 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,194 @@
1
1
  # @solutiondesk/legal
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ Typed client for the [SolutionDesk](https://solutiondesk.io) Legal Hub. Your team keeps the imprint, privacy policy, terms, DPA and sub-processor list in SolutionDesk, with every earlier version. Your website shows them with this package, and your app asks its users to agree to new versions.
4
4
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
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 versions are ever returned.
6
8
 
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
9
+ ```bash
10
+ npm install @solutiondesk/legal
11
+ ```
8
12
 
9
- ## Purpose
13
+ ## Quick start
10
14
 
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@solutiondesk/legal`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
15
+ ```ts
16
+ import { createLegalClient } from "@solutiondesk/legal";
15
17
 
16
- ## What is OIDC Trusted Publishing?
18
+ const legal = createLegalClient({ workspace: "acme", lang: "en" });
17
19
 
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
20
+ const { document } = await legal.getDocument("imprint");
21
+ document.html; // safe to insert as it is
22
+ ```
19
23
 
20
- ## Setup Instructions
24
+ `workspace` is your workspace's slug, the `acme` in the address shown under **Settings → Legal texts → On your website** (`https://api.solutiondesk.io/v1/legal/acme`).
21
25
 
22
- To properly configure OIDC trusted publishing for this package:
26
+ ## Load the texts on the server
23
27
 
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
+ Fetch legal texts **on the server or at build time** and keep the last answer. Don't load them in the visitor's browser: your imprint must stay reachable when a script is blocked or SolutionDesk can't be reached for a moment. Answers may be cached for a minute and, while SolutionDesk can't be reached, served from a cache for up to a day (`Cache-Control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400`). The [webhook](#webhooks-refresh-when-a-version-changes) tells you when to fetch again.
28
29
 
29
- ## DO NOT USE THIS PACKAGE
30
+ ## What you can read
30
31
 
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
32
+ | Method | API | Returns |
33
+ | --- | --- | --- |
34
+ | `listDocuments({ lang })` | `GET /v1/legal/{workspace}/documents` | Every text with the version that applies today (`current`) and, if one is announced, the next (`upcoming`). |
35
+ | `getDocument(slug, { lang })` | `GET …/documents/{slug}` | The version that applies today in full: `markdown`, `html`, `headings`, `number`, `effectiveAt`, `summary`, `upcoming`. |
36
+ | `listVersions(slug, { lang })` | `GET …/documents/{slug}/versions` | Every published version (earlier, current, announced) with `status` and, for earlier ones, `until`. Only while **Show earlier versions on your website** is on. |
37
+ | `getVersion(slug, number, { lang })` | `GET …/documents/{slug}/versions/{number}` | One version in full: the current one, the announced one, or an earlier one (when earlier versions are shown). |
38
+ | `getSubprocessors({ lang })` | `GET …/subprocessors` | The sub-processor list in force and what the announced list changes (`upcoming.changes`: `added`, `removed`, `changed`). |
39
+ | `getPreview(token, { lang })` | `GET …/preview?token=` | Any version, drafts too, for your preview route. Never cached by the API. |
36
40
 
37
- ## More Information
41
+ Which version applies is decided by its date: the highest number whose date has come. A version announced for next month shows up in `upcoming` until that day, then it is the current one, without anyone pressing a button.
38
42
 
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)
43
+ `html` is safe to insert as it is: no scripts, no images, and links only to web addresses, `mailto:`, paths on your site and anchors. Headings carry `id`s for a table of contents. An unknown slug, a workspace without legal texts and a text without a published version all answer `404`, on purpose indistinguishable. Sub-processors your team marked as not public are left out.
42
44
 
45
+ ## Example: the imprint in Next.js (App Router)
46
+
47
+ ```tsx
48
+ // app/[lang]/legal/[slug]/page.tsx
49
+ import { createLegalClient, SolutionDeskError } from "@solutiondesk/legal";
50
+ import { notFound } from "next/navigation";
51
+
52
+ const legal = createLegalClient({ workspace: "acme" });
53
+
54
+ export default async function LegalPage({ params }: { params: Promise<{ lang: string; slug: string }> }) {
55
+ const { lang, slug } = await params;
56
+ try {
57
+ const { document } = await legal.getDocument(
58
+ slug,
59
+ { lang },
60
+ { fetchOptions: { next: { tags: ["legal"], revalidate: 3600 } } },
61
+ );
62
+ return (
63
+ <article lang={document.locale}>
64
+ <h1>{document.title}</h1>
65
+ <div dangerouslySetInnerHTML={{ __html: document.html }} />
66
+ <p>Version {document.number}, in force since {document.effectiveAt?.slice(0, 10)}</p>
67
+ {document.upcoming && (
68
+ <p>From {document.upcoming.effectiveAt.slice(0, 10)}: {document.upcoming.summary}</p>
69
+ )}
70
+ </article>
71
+ );
72
+ } catch (error) {
73
+ if (error instanceof SolutionDeskError && error.isNotFound) notFound();
74
+ throw error;
75
+ }
76
+ }
77
+ ```
78
+
79
+ With Astro (or any static site generator), fetch at build time and rebuild when the webhook arrives:
80
+
81
+ ```astro
43
82
  ---
83
+ // src/pages/imprint.astro
84
+ import { createLegalClient } from "@solutiondesk/legal";
85
+ const legal = createLegalClient({ workspace: "acme", lang: "de" });
86
+ const { document } = await legal.getDocument("imprint");
87
+ ---
88
+ <article lang={document.locale} set:html={document.html} />
89
+ ```
90
+
91
+ ## Webhooks: refresh when a version changes
92
+
93
+ Under **Settings → Legal texts → On your website** set **Tell your website about changes** to a URL on your site and copy the signing secret (shown once). SolutionDesk then `POST`s whenever readers would see something different, and when someone agrees to a text:
94
+
95
+ ```ts
96
+ // app/api/solutiondesk/legal/route.ts (Next.js)
97
+ import { isAcceptanceRecorded, parseLegalWebhook } from "@solutiondesk/legal";
98
+ import { revalidateTag } from "next/cache";
99
+
100
+ export async function POST(request: Request) {
101
+ const body = await parseLegalWebhook(
102
+ process.env.SOLUTIONDESK_LEGAL_WEBHOOK_SECRET!,
103
+ await request.text(), // the raw body, exactly as received
104
+ request.headers.get("x-solutiondesk-signature"),
105
+ );
106
+ if (!body) return new Response("Invalid signature", { status: 401 });
107
+
108
+ if (isAcceptanceRecorded(body)) {
109
+ // Someone agreed: body.acceptance.externalId (your user id), .company, .channel, .acceptedAt
110
+ await markAgreed(body.acceptance.externalId, body.document, body.number);
111
+ } else if (body.event !== "ping") {
112
+ revalidateTag("legal"); // version.published, version.effective, version.withdrawn
113
+ }
114
+ return new Response(null, { status: 204 });
115
+ }
116
+ ```
117
+
118
+ | `event` | When |
119
+ | --- | --- |
120
+ | `version.published` | a new version was published, also one that only applies later (it shows up in `upcoming`) |
121
+ | `version.effective` | a version applies from now on |
122
+ | `version.withdrawn` | an announced version was withdrawn |
123
+ | `acceptance.recorded` | someone agreed to a version (`acceptance` names who, in your ids); `locales` is empty |
124
+ | `ping` | **Send a test** |
125
+
126
+ `parseLegalWebhook` returns `null` for a wrong secret, a changed body, a malformed header or a signature older than five minutes. If you only need the check, use `verifyWebhookSignature(secret, rawBody, header)`, which resolves to `true` or `false`. It uses Web Crypto. On Node 18 pass `{ crypto: require("node:crypto").webcrypto }` as the fourth argument; Node 19 and later need nothing. Answer with a `2xx` status; anything else is retried, up to five attempts in all.
127
+
128
+ ## Agreement in your app (server only, API key)
129
+
130
+ When a text needs agreement (typically your terms), ask which versions a user still has to agree to, show your own dialog, and report the click:
131
+
132
+ ```ts
133
+ // Server only — never ship the API key to a browser.
134
+ import { createLegalHostClient } from "@solutiondesk/legal";
135
+
136
+ const host = createLegalHostClient({ apiKey: process.env.SOLUTIONDESK_API_KEY! });
137
+
138
+ const { pending } = await host.pending({ externalId: "user-42", lang: "en" });
139
+ // [{ document: "terms", number: 3, title, summary, url, inForce, effectiveAt, locale, companyExternalId }]
140
+
141
+ // … the user clicked "I agree" in your dialog:
142
+ const { created, acceptance } = await host.recordAcceptance({
143
+ externalId: "user-42",
144
+ document: "terms",
145
+ number: 3,
146
+ acceptedAt: new Date().toISOString(),
147
+ ip: "203.0.113.7",
148
+ userAgent: request.headers.get("user-agent") ?? undefined,
149
+ });
150
+ ```
151
+
152
+ These call `GET /v1/integration/legal/pending` and `POST /v1/integration/legal/acceptances` with `Authorization: Bearer <apiKey>` (create the key under **Settings → API keys**).
153
+
154
+ - Agreement has to be active: no pre-ticked box; closing the dialog is not agreeing. Report only a real click.
155
+ - Reporting the same agreement again is harmless (`created: false`).
156
+ - For texts agreed per company by an administrator, send `companyExternalId`. For a user you haven't synced yet, also send `email` and `name`, and they are created on the way.
157
+ - An unknown user (without `email`/`name`), document or version is a `404`; a version that isn't open for the user is a `409`; a time in the future or before publication is a `400`.
158
+
159
+ ## Languages
160
+
161
+ Pass `lang` to the client (default for every call) or per call. You get exactly that language if the text exists in it, otherwise the same language of another region (`de-AT` → `de`), otherwise the main language. `locale` always says which language the text is really in and `languages` lists every language it exists in; use `locale` for the page's `lang` attribute and `hreflang`.
162
+
163
+ ## Caching and ETags
164
+
165
+ Every public answer carries an `ETag`. With `etagCache: true` the client sends `If-None-Match` and returns the remembered body on a `304`:
166
+
167
+ ```ts
168
+ const legal = createLegalClient({ workspace: "acme", etagCache: true }); // or your own Map
169
+ ```
170
+
171
+ Per call, `fetchOptions` goes to `fetch` unchanged, for your framework's caching (`{ next: { tags: ["legal"] } }`) or `{ cache: "no-store" }` for previews. Integration calls are never cached.
172
+
173
+ ## Errors
174
+
175
+ 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` (`429`) are shortcuts. Each public address may be asked 600 times a minute per IP address, the preview 60 times.
176
+
177
+ ## Options
178
+
179
+ ```ts
180
+ createLegalClient({
181
+ workspace: "acme", // required
182
+ lang: "en",
183
+ baseUrl: "https://api.solutiondesk.io",
184
+ fetch: customFetch,
185
+ headers: {},
186
+ etagCache: true,
187
+ });
188
+ ```
189
+
190
+ Every method also takes request options as its last argument: `{ signal, headers, fetchOptions }`.
191
+
192
+ ## License
44
193
 
45
- **Maintained for OIDC setup purposes only**
194
+ MIT © wemogy GmbH
package/dist/index.cjs ADDED
@@ -0,0 +1,309 @@
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: "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 body = parseBody(await response.text());
140
+ if (!response.ok) {
141
+ throw new SolutionDeskError(messageOf(body, response.status), {
142
+ status: response.status,
143
+ body,
144
+ url: target
145
+ });
146
+ }
147
+ const etag = response.headers.get("etag");
148
+ if (cacheKey && cache && etag) cache.set(cacheKey, { etag, body });
149
+ return { data: body, ...meta };
150
+ }
151
+ return {
152
+ baseUrl,
153
+ url,
154
+ requestWithMeta,
155
+ request: async (req) => (await requestWithMeta(req)).data
156
+ };
157
+ }
158
+ function required(value, name) {
159
+ if (typeof value !== "string" || !value.trim()) {
160
+ throw new TypeError(`SolutionDesk: \`${name}\` is required.`);
161
+ }
162
+ return value;
163
+ }
164
+
165
+ // ../sdk-core/src/webhook.ts
166
+ var SIGNATURE_HEADER = "x-solutiondesk-signature";
167
+ var EVENT_HEADER = "x-solutiondesk-event";
168
+ function encoder() {
169
+ const Encoder = globalThis.TextEncoder;
170
+ if (!Encoder) throw new Error("TextEncoder is not available in this runtime.");
171
+ return new Encoder();
172
+ }
173
+ function bytesOf(body) {
174
+ if (typeof body === "string") return encoder().encode(body);
175
+ return body instanceof Uint8Array ? body : new Uint8Array(body);
176
+ }
177
+ function webhookBodyText(body) {
178
+ if (typeof body === "string") return body;
179
+ const Decoder = globalThis.TextDecoder;
180
+ if (!Decoder) throw new Error("TextDecoder is not available in this runtime.");
181
+ return new Decoder().decode(bytesOf(body));
182
+ }
183
+ function hex(buffer) {
184
+ let out = "";
185
+ for (const byte of new Uint8Array(buffer)) out += byte.toString(16).padStart(2, "0");
186
+ return out;
187
+ }
188
+ function constantTimeEqual(a, b) {
189
+ if (a.length !== b.length) return false;
190
+ let diff = 0;
191
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
192
+ return diff === 0;
193
+ }
194
+ function parseHeader(header) {
195
+ let t = Number.NaN;
196
+ const signatures = [];
197
+ for (const part of header.split(",")) {
198
+ const index = part.indexOf("=");
199
+ if (index < 0) continue;
200
+ const key = part.slice(0, index).trim();
201
+ const value = part.slice(index + 1).trim();
202
+ if (key === "t" && /^\d{1,12}$/.test(value)) t = Number(value);
203
+ else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value.toLowerCase());
204
+ }
205
+ return Number.isFinite(t) && signatures.length > 0 ? { t, signatures } : null;
206
+ }
207
+ async function verifyWebhookSignature(secret, rawBody, header, options = {}) {
208
+ if (!secret || !header || header.length > 1e3) return false;
209
+ const parsed = parseHeader(header);
210
+ if (!parsed) return false;
211
+ const now = options.now instanceof Date ? options.now.getTime() : options.now ?? Date.now();
212
+ const tolerance = options.toleranceSeconds ?? 300;
213
+ if (Math.abs(now / 1e3 - parsed.t) > tolerance) return false;
214
+ const subtle = options.crypto?.subtle ?? globalThis.crypto?.subtle;
215
+ if (!subtle) {
216
+ throw new Error(
217
+ "Web Crypto is not available: pass `crypto` (on Node 18: require('node:crypto').webcrypto)."
218
+ );
219
+ }
220
+ const text = encoder();
221
+ const key = await subtle.importKey(
222
+ "raw",
223
+ text.encode(secret),
224
+ { name: "HMAC", hash: "SHA-256" },
225
+ false,
226
+ ["sign"]
227
+ );
228
+ const prefix = text.encode(`${parsed.t}.`);
229
+ const body = bytesOf(rawBody);
230
+ const signed = new Uint8Array(prefix.length + body.length);
231
+ signed.set(prefix, 0);
232
+ signed.set(body, prefix.length);
233
+ const expected = hex(await subtle.sign("HMAC", key, signed));
234
+ let ok = false;
235
+ for (const signature of parsed.signatures) ok = constantTimeEqual(expected, signature) || ok;
236
+ return ok;
237
+ }
238
+ async function parseWebhook(secret, rawBody, header, options = {}) {
239
+ if (!await verifyWebhookSignature(secret, rawBody, header, options)) return null;
240
+ try {
241
+ return JSON.parse(webhookBodyText(rawBody));
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ // src/client.ts
248
+ function createLegalClient(options) {
249
+ const workspace = required(options.workspace, "workspace");
250
+ const http = createHttp(options);
251
+ const base = `/legal/${seg(workspace)}`;
252
+ const langOf = (params) => ({ lang: params?.lang ?? options.lang });
253
+ const get = (path, query, o) => http.request({ method: "GET", path: `${base}${path}`, query, options: o });
254
+ return {
255
+ workspace,
256
+ listDocuments: async (params, o) => get("/documents", langOf(params), o),
257
+ getDocument: async (slug, params, o) => get(`/documents/${seg(required(slug, "slug"))}`, langOf(params), o),
258
+ listVersions: async (slug, params, o) => get(`/documents/${seg(required(slug, "slug"))}/versions`, langOf(params), o),
259
+ getVersion: async (slug, number, params, o) => {
260
+ if (!Number.isInteger(number) || number < 1) {
261
+ throw new TypeError("SolutionDesk: `number` must be a positive whole number.");
262
+ }
263
+ return get(
264
+ `/documents/${seg(required(slug, "slug"))}/versions/${seg(number)}`,
265
+ langOf(params),
266
+ o
267
+ );
268
+ },
269
+ getSubprocessors: async (params, o) => get("/subprocessors", langOf(params), o),
270
+ getPreview: async (token, params, o) => get("/preview", { token: required(token, "token"), ...langOf(params) }, o)
271
+ };
272
+ }
273
+ function createLegalHostClient(options) {
274
+ const apiKey = required(options.apiKey, "apiKey");
275
+ const http = createHttp({ ...options, etagCache: false });
276
+ return {
277
+ pending: async (params, o) => http.request({
278
+ method: "GET",
279
+ path: "/integration/legal/pending",
280
+ query: { externalId: required(params.externalId, "externalId"), lang: params.lang },
281
+ apiKey,
282
+ options: o
283
+ }),
284
+ recordAcceptance: async (input, o) => http.request({
285
+ method: "POST",
286
+ path: "/integration/legal/acceptances",
287
+ body: input,
288
+ apiKey,
289
+ options: o
290
+ })
291
+ };
292
+ }
293
+ function parseLegalWebhook(secret, rawBody, signatureHeader, options) {
294
+ return parseWebhook(secret, rawBody, signatureHeader, options);
295
+ }
296
+ function isAcceptanceRecorded(body) {
297
+ return body.event === "acceptance.recorded" && !!body.acceptance && body.document !== null;
298
+ }
299
+
300
+ exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
301
+ exports.EVENT_HEADER = EVENT_HEADER;
302
+ exports.SIGNATURE_HEADER = SIGNATURE_HEADER;
303
+ exports.SolutionDeskError = SolutionDeskError;
304
+ exports.createLegalClient = createLegalClient;
305
+ exports.createLegalHostClient = createLegalHostClient;
306
+ exports.createMemoryEtagCache = createMemoryEtagCache;
307
+ exports.isAcceptanceRecorded = isAcceptanceRecorded;
308
+ exports.parseLegalWebhook = parseLegalWebhook;
309
+ exports.verifyWebhookSignature = verifyWebhookSignature;