@solutiondesk/legal 0.0.0 → 1.1.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 +21 -0
- package/README.md +178 -27
- package/dist/index.cjs +310 -0
- package/dist/index.d.cts +456 -0
- package/dist/index.d.ts +456 -0
- package/dist/index.js +299 -0
- package/package.json +58 -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/legal
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
+
```bash
|
|
10
|
+
npm install @solutiondesk/legal
|
|
11
|
+
```
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
The guide [Legal texts on your website](https://help.solutiondesk.io/docs/team/legal-website) 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.
|
|
10
14
|
|
|
11
|
-
|
|
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
|
+
## Quick start
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
```ts
|
|
18
|
+
import { createLegalClient } from "@solutiondesk/legal";
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
const legal = createLegalClient({ workspace: "acme", lang: "en" });
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
const { document } = await legal.getDocument("imprint");
|
|
23
|
+
document.html; // safe to insert as it is
|
|
24
|
+
```
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
`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`).
|
|
23
27
|
|
|
24
|
-
|
|
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
|
+
## Load the texts on the server
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
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.
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
- Contains no executable code
|
|
33
|
-
- Provides no functionality
|
|
34
|
-
- Should not be installed as a dependency
|
|
35
|
-
- Exists only for administrative purposes
|
|
32
|
+
## What you can read
|
|
36
33
|
|
|
37
|
-
|
|
34
|
+
| Method | API | Returns |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `listDocuments({ lang })` | `GET /v1/legal/{workspace}/documents` | Every text with the version that applies today (`current`) and, if one is announced, the next (`upcoming`). |
|
|
37
|
+
| `getDocument(slug, { lang })` | `GET …/documents/{slug}` | The version that applies today in full: `markdown`, `html`, `headings`, `number`, `effectiveAt`, `summary`, `upcoming`. |
|
|
38
|
+
| `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. |
|
|
39
|
+
| `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). |
|
|
40
|
+
| `getSubprocessors({ lang })` | `GET …/subprocessors` | The sub-processor list in force and what the announced list changes (`upcoming.changes`: `added`, `removed`, `changed`). |
|
|
41
|
+
| `getPreview(token, { lang })` | `GET …/preview?token=` | Any version, drafts too, for your preview route. Never cached by the API. |
|
|
38
42
|
|
|
39
|
-
|
|
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
|
+
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.
|
|
42
44
|
|
|
45
|
+
`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.
|
|
46
|
+
|
|
47
|
+
## Example: the imprint in Next.js (App Router)
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
// app/[lang]/legal/[slug]/page.tsx
|
|
51
|
+
import { createLegalClient, SolutionDeskError } from "@solutiondesk/legal";
|
|
52
|
+
import { notFound } from "next/navigation";
|
|
53
|
+
|
|
54
|
+
const legal = createLegalClient({ workspace: "acme" });
|
|
55
|
+
|
|
56
|
+
export default async function LegalPage({ params }: { params: Promise<{ lang: string; slug: string }> }) {
|
|
57
|
+
const { lang, slug } = await params;
|
|
58
|
+
try {
|
|
59
|
+
const { document } = await legal.getDocument(
|
|
60
|
+
slug,
|
|
61
|
+
{ lang },
|
|
62
|
+
{ fetchOptions: { next: { tags: ["legal"], revalidate: 3600 } } },
|
|
63
|
+
);
|
|
64
|
+
return (
|
|
65
|
+
<article lang={document.locale}>
|
|
66
|
+
<h1>{document.title}</h1>
|
|
67
|
+
<div dangerouslySetInnerHTML={{ __html: document.html }} />
|
|
68
|
+
<p>Version {document.number}, in force since {document.effectiveAt?.slice(0, 10)}</p>
|
|
69
|
+
{document.upcoming && (
|
|
70
|
+
<p>From {document.upcoming.effectiveAt.slice(0, 10)}: {document.upcoming.summary}</p>
|
|
71
|
+
)}
|
|
72
|
+
</article>
|
|
73
|
+
);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof SolutionDeskError && error.isNotFound) notFound();
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
With Astro (or any static site generator), fetch at build time and rebuild when the webhook arrives:
|
|
82
|
+
|
|
83
|
+
```astro
|
|
84
|
+
---
|
|
85
|
+
// src/pages/imprint.astro
|
|
86
|
+
import { createLegalClient } from "@solutiondesk/legal";
|
|
87
|
+
const legal = createLegalClient({ workspace: "acme", lang: "de" });
|
|
88
|
+
const { document } = await legal.getDocument("imprint");
|
|
43
89
|
---
|
|
90
|
+
<article lang={document.locale} set:html={document.html} />
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Webhooks: refresh when a version changes
|
|
94
|
+
|
|
95
|
+
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:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
// app/api/solutiondesk/legal/route.ts (Next.js)
|
|
99
|
+
import { isAcceptanceRecorded, parseLegalWebhook } from "@solutiondesk/legal";
|
|
100
|
+
import { revalidateTag } from "next/cache";
|
|
101
|
+
|
|
102
|
+
export async function POST(request: Request) {
|
|
103
|
+
const body = await parseLegalWebhook(
|
|
104
|
+
process.env.SOLUTIONDESK_LEGAL_WEBHOOK_SECRET!,
|
|
105
|
+
await request.text(), // the raw body, exactly as received
|
|
106
|
+
request.headers.get("x-solutiondesk-signature"),
|
|
107
|
+
);
|
|
108
|
+
if (!body) return new Response("Invalid signature", { status: 401 });
|
|
109
|
+
|
|
110
|
+
if (isAcceptanceRecorded(body)) {
|
|
111
|
+
// Someone agreed: body.acceptance.externalId (your user id), .company, .channel, .acceptedAt
|
|
112
|
+
await markAgreed(body.acceptance.externalId, body.document, body.number);
|
|
113
|
+
} else if (body.event !== "ping") {
|
|
114
|
+
revalidateTag("legal"); // version.published, version.effective, version.withdrawn
|
|
115
|
+
}
|
|
116
|
+
return new Response(null, { status: 204 });
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| `event` | When |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| `version.published` | a new version was published, also one that only applies later (it shows up in `upcoming`) |
|
|
123
|
+
| `version.effective` | a version applies from now on |
|
|
124
|
+
| `version.withdrawn` | an announced version was withdrawn |
|
|
125
|
+
| `acceptance.recorded` | someone agreed to a version (`acceptance` names who, in your ids); `locales` is empty |
|
|
126
|
+
| `ping` | **Send a test** |
|
|
127
|
+
|
|
128
|
+
`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.
|
|
129
|
+
|
|
130
|
+
## Agreement in your app (server only, API key)
|
|
131
|
+
|
|
132
|
+
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:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// Server only — never ship the API key to a browser.
|
|
136
|
+
import { createLegalHostClient } from "@solutiondesk/legal";
|
|
137
|
+
|
|
138
|
+
const host = createLegalHostClient({ apiKey: process.env.SOLUTIONDESK_API_KEY! });
|
|
139
|
+
|
|
140
|
+
const { pending } = await host.pending({ externalId: "user-42", lang: "en" });
|
|
141
|
+
// [{ document: "terms", number: 3, title, summary, url, inForce, effectiveAt, locale, companyExternalId }]
|
|
142
|
+
|
|
143
|
+
// … the user clicked "I agree" in your dialog:
|
|
144
|
+
const { created, acceptance } = await host.recordAcceptance({
|
|
145
|
+
externalId: "user-42",
|
|
146
|
+
document: "terms",
|
|
147
|
+
number: 3,
|
|
148
|
+
acceptedAt: new Date().toISOString(),
|
|
149
|
+
ip: "203.0.113.7",
|
|
150
|
+
userAgent: request.headers.get("user-agent") ?? undefined,
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
These call `GET /v1/integration/legal/pending` and `POST /v1/integration/legal/acceptances` with `Authorization: Bearer <apiKey>` (create the key under **Settings → API keys**).
|
|
155
|
+
|
|
156
|
+
- Agreement has to be active: no pre-ticked box; closing the dialog is not agreeing. Report only a real click.
|
|
157
|
+
- Reporting the same agreement again is harmless (`created: false`).
|
|
158
|
+
- 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.
|
|
159
|
+
- 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`.
|
|
160
|
+
|
|
161
|
+
## Languages
|
|
162
|
+
|
|
163
|
+
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`.
|
|
164
|
+
|
|
165
|
+
## Caching and ETags
|
|
166
|
+
|
|
167
|
+
Every public answer carries an `ETag`. With `etagCache: true` the client sends `If-None-Match` and returns the remembered body on a `304`:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
const legal = createLegalClient({ workspace: "acme", etagCache: true }); // or your own Map
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
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.
|
|
174
|
+
|
|
175
|
+
## Errors
|
|
176
|
+
|
|
177
|
+
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.
|
|
178
|
+
|
|
179
|
+
## Options
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
createLegalClient({
|
|
183
|
+
workspace: "acme", // required
|
|
184
|
+
lang: "en",
|
|
185
|
+
baseUrl: "https://api.solutiondesk.io",
|
|
186
|
+
fetch: customFetch,
|
|
187
|
+
headers: {},
|
|
188
|
+
etagCache: true,
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Every method also takes request options as its last argument: `{ signal, headers, fetchOptions }`.
|
|
193
|
+
|
|
194
|
+
## License
|
|
44
195
|
|
|
45
|
-
|
|
196
|
+
MIT © wemogy GmbH
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
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/webhook.ts
|
|
167
|
+
var SIGNATURE_HEADER = "x-solutiondesk-signature";
|
|
168
|
+
var EVENT_HEADER = "x-solutiondesk-event";
|
|
169
|
+
function encoder() {
|
|
170
|
+
const Encoder = globalThis.TextEncoder;
|
|
171
|
+
if (!Encoder) throw new Error("TextEncoder is not available in this runtime.");
|
|
172
|
+
return new Encoder();
|
|
173
|
+
}
|
|
174
|
+
function bytesOf(body) {
|
|
175
|
+
if (typeof body === "string") return encoder().encode(body);
|
|
176
|
+
return body instanceof Uint8Array ? body : new Uint8Array(body);
|
|
177
|
+
}
|
|
178
|
+
function webhookBodyText(body) {
|
|
179
|
+
if (typeof body === "string") return body;
|
|
180
|
+
const Decoder = globalThis.TextDecoder;
|
|
181
|
+
if (!Decoder) throw new Error("TextDecoder is not available in this runtime.");
|
|
182
|
+
return new Decoder().decode(bytesOf(body));
|
|
183
|
+
}
|
|
184
|
+
function hex(buffer) {
|
|
185
|
+
let out = "";
|
|
186
|
+
for (const byte of new Uint8Array(buffer)) out += byte.toString(16).padStart(2, "0");
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
function constantTimeEqual(a, b) {
|
|
190
|
+
if (a.length !== b.length) return false;
|
|
191
|
+
let diff = 0;
|
|
192
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
193
|
+
return diff === 0;
|
|
194
|
+
}
|
|
195
|
+
function parseHeader(header) {
|
|
196
|
+
let t = Number.NaN;
|
|
197
|
+
const signatures = [];
|
|
198
|
+
for (const part of header.split(",")) {
|
|
199
|
+
const index = part.indexOf("=");
|
|
200
|
+
if (index < 0) continue;
|
|
201
|
+
const key = part.slice(0, index).trim();
|
|
202
|
+
const value = part.slice(index + 1).trim();
|
|
203
|
+
if (key === "t" && /^\d{1,12}$/.test(value)) t = Number(value);
|
|
204
|
+
else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value.toLowerCase());
|
|
205
|
+
}
|
|
206
|
+
return Number.isFinite(t) && signatures.length > 0 ? { t, signatures } : null;
|
|
207
|
+
}
|
|
208
|
+
async function verifyWebhookSignature(secret, rawBody, header, options = {}) {
|
|
209
|
+
if (!secret || !header || header.length > 1e3) return false;
|
|
210
|
+
const parsed = parseHeader(header);
|
|
211
|
+
if (!parsed) return false;
|
|
212
|
+
const now = options.now instanceof Date ? options.now.getTime() : options.now ?? Date.now();
|
|
213
|
+
const tolerance = options.toleranceSeconds ?? 300;
|
|
214
|
+
if (Math.abs(now / 1e3 - parsed.t) > tolerance) return false;
|
|
215
|
+
const subtle = options.crypto?.subtle ?? globalThis.crypto?.subtle;
|
|
216
|
+
if (!subtle) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
"Web Crypto is not available: pass `crypto` (on Node 18: require('node:crypto').webcrypto)."
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const text = encoder();
|
|
222
|
+
const key = await subtle.importKey(
|
|
223
|
+
"raw",
|
|
224
|
+
text.encode(secret),
|
|
225
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
226
|
+
false,
|
|
227
|
+
["sign"]
|
|
228
|
+
);
|
|
229
|
+
const prefix = text.encode(`${parsed.t}.`);
|
|
230
|
+
const body = bytesOf(rawBody);
|
|
231
|
+
const signed = new Uint8Array(prefix.length + body.length);
|
|
232
|
+
signed.set(prefix, 0);
|
|
233
|
+
signed.set(body, prefix.length);
|
|
234
|
+
const expected = hex(await subtle.sign("HMAC", key, signed));
|
|
235
|
+
let ok = false;
|
|
236
|
+
for (const signature of parsed.signatures) ok = constantTimeEqual(expected, signature) || ok;
|
|
237
|
+
return ok;
|
|
238
|
+
}
|
|
239
|
+
async function parseWebhook(secret, rawBody, header, options = {}) {
|
|
240
|
+
if (!await verifyWebhookSignature(secret, rawBody, header, options)) return null;
|
|
241
|
+
try {
|
|
242
|
+
return JSON.parse(webhookBodyText(rawBody));
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/client.ts
|
|
249
|
+
function createLegalClient(options) {
|
|
250
|
+
const workspace = required(options.workspace, "workspace");
|
|
251
|
+
const http = createHttp(options);
|
|
252
|
+
const base = `/legal/${seg(workspace)}`;
|
|
253
|
+
const langOf = (params) => ({ lang: params?.lang ?? options.lang });
|
|
254
|
+
const get = (path, query, o) => http.request({ method: "GET", path: `${base}${path}`, query, options: o });
|
|
255
|
+
return {
|
|
256
|
+
workspace,
|
|
257
|
+
listDocuments: async (params, o) => get("/documents", langOf(params), o),
|
|
258
|
+
getDocument: async (slug, params, o) => get(`/documents/${seg(required(slug, "slug"))}`, langOf(params), o),
|
|
259
|
+
listVersions: async (slug, params, o) => get(`/documents/${seg(required(slug, "slug"))}/versions`, langOf(params), o),
|
|
260
|
+
getVersion: async (slug, number, params, o) => {
|
|
261
|
+
if (!Number.isInteger(number) || number < 1) {
|
|
262
|
+
throw new TypeError("SolutionDesk: `number` must be a positive whole number.");
|
|
263
|
+
}
|
|
264
|
+
return get(
|
|
265
|
+
`/documents/${seg(required(slug, "slug"))}/versions/${seg(number)}`,
|
|
266
|
+
langOf(params),
|
|
267
|
+
o
|
|
268
|
+
);
|
|
269
|
+
},
|
|
270
|
+
getSubprocessors: async (params, o) => get("/subprocessors", langOf(params), o),
|
|
271
|
+
getPreview: async (token, params, o) => get("/preview", { token: required(token, "token"), ...langOf(params) }, o)
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function createLegalHostClient(options) {
|
|
275
|
+
const apiKey = required(options.apiKey, "apiKey");
|
|
276
|
+
const http = createHttp({ ...options, etagCache: false });
|
|
277
|
+
return {
|
|
278
|
+
pending: async (params, o) => http.request({
|
|
279
|
+
method: "GET",
|
|
280
|
+
path: "/integration/legal/pending",
|
|
281
|
+
query: { externalId: required(params.externalId, "externalId"), lang: params.lang },
|
|
282
|
+
apiKey,
|
|
283
|
+
options: o
|
|
284
|
+
}),
|
|
285
|
+
recordAcceptance: async (input, o) => http.request({
|
|
286
|
+
method: "POST",
|
|
287
|
+
path: "/integration/legal/acceptances",
|
|
288
|
+
body: input,
|
|
289
|
+
apiKey,
|
|
290
|
+
options: o
|
|
291
|
+
})
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function parseLegalWebhook(secret, rawBody, signatureHeader, options) {
|
|
295
|
+
return parseWebhook(secret, rawBody, signatureHeader, options);
|
|
296
|
+
}
|
|
297
|
+
function isAcceptanceRecorded(body) {
|
|
298
|
+
return body.event === "acceptance.recorded" && !!body.acceptance && body.document !== null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
|
|
302
|
+
exports.EVENT_HEADER = EVENT_HEADER;
|
|
303
|
+
exports.SIGNATURE_HEADER = SIGNATURE_HEADER;
|
|
304
|
+
exports.SolutionDeskError = SolutionDeskError;
|
|
305
|
+
exports.createLegalClient = createLegalClient;
|
|
306
|
+
exports.createLegalHostClient = createLegalHostClient;
|
|
307
|
+
exports.createMemoryEtagCache = createMemoryEtagCache;
|
|
308
|
+
exports.isAcceptanceRecorded = isAcceptanceRecorded;
|
|
309
|
+
exports.parseLegalWebhook = parseLegalWebhook;
|
|
310
|
+
exports.verifyWebhookSignature = verifyWebhookSignature;
|