@checkcourt/sdk 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +244 -2
- package/dist/auth.d.ts +54 -0
- package/dist/auth.js +96 -0
- package/dist/client.d.ts +38 -0
- package/dist/client.js +71 -0
- package/dist/errors.d.ts +74 -0
- package/dist/errors.js +123 -0
- package/dist/events.d.ts +72 -0
- package/dist/events.js +24 -0
- package/dist/extensions.d.ts +124 -0
- package/dist/extensions.js +138 -0
- package/dist/generated/schema.d.ts +5895 -0
- package/dist/generated/spec-hash.d.ts +1 -0
- package/dist/generated/spec-hash.js +2 -0
- package/dist/iframe.d.ts +68 -0
- package/dist/iframe.js +171 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/installation.d.ts +15 -0
- package/dist/installation.js +16 -0
- package/dist/internal/base-url.d.ts +3 -0
- package/dist/internal/base-url.js +5 -0
- package/dist/internal/encoding.d.ts +8 -0
- package/dist/internal/encoding.js +55 -0
- package/dist/internal/hmac.d.ts +4 -0
- package/dist/internal/hmac.js +17 -0
- package/dist/manifest.d.ts +119 -0
- package/dist/manifest.js +79 -0
- package/dist/oauth.d.ts +96 -0
- package/dist/oauth.js +140 -0
- package/dist/ui.d.ts +196 -0
- package/dist/ui.js +101 -0
- package/dist/webhooks.d.ts +34 -0
- package/dist/webhooks.js +82 -0
- package/package.json +76 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CheckCourt
|
|
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,3 +1,245 @@
|
|
|
1
|
-
#
|
|
1
|
+
# CheckCourt TypeScript SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@checkcourt/sdk` is the official TypeScript SDK for the [CheckCourt](https://checkcourt.de)
|
|
4
|
+
app platform. It takes care of the parts of an integration that are easy to get wrong:
|
|
5
|
+
|
|
6
|
+
- a typed client for every endpoint under `/api/v1`, generated from the OpenAPI spec
|
|
7
|
+
- API key, installation token and member token authentication, with token caching and
|
|
8
|
+
safe refresh rotation
|
|
9
|
+
- OAuth 2.1 with PKCE for member apps
|
|
10
|
+
- webhook signature verification with typed events
|
|
11
|
+
- UI extensions: context token and request verification, a builder for declarative UI
|
|
12
|
+
documents, and a browser helper for iframe extensions
|
|
13
|
+
- a fully typed app manifest
|
|
14
|
+
|
|
15
|
+
Everything the SDK does can also be built with plain HTTP. The protocol is described in the
|
|
16
|
+
[developer documentation](https://docs.checkcourt.de/docs/developer).
|
|
17
|
+
|
|
18
|
+
The SDK is an ES module for Node.js 20 or later and ships its own type definitions. It uses
|
|
19
|
+
WebCrypto, so webhook and extension verification also run on edge runtimes such as
|
|
20
|
+
Cloudflare Workers, Vercel Edge or Deno. Its only runtime dependency is `openapi-fetch`.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install @checkcourt/sdk
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Every release is also tagged on [GitHub](https://github.com/CheckCourt/sdk/releases).
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
### API client with installation auth (club apps)
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { createCheckCourtClient, getInstallation, installationAuth, unwrap } from "@checkcourt/sdk";
|
|
36
|
+
|
|
37
|
+
const client = createCheckCourtClient({
|
|
38
|
+
auth: installationAuth({
|
|
39
|
+
clientId: process.env.CHECKCOURT_CLIENT_ID!, // cca_app_…
|
|
40
|
+
clientSecret: process.env.CHECKCOURT_CLIENT_SECRET!, // ccas_…
|
|
41
|
+
installationId: "inst_123", // from the app.installed event
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const installation = await getInstallation(client);
|
|
46
|
+
const { courts } = await unwrap(client.GET("/courts"));
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`installationAuth` fetches a short-lived installation token when needed, caches it and
|
|
50
|
+
renews it shortly before it expires. Use `apiKeyAuth(key)` for your own club's API key and
|
|
51
|
+
`userAuth({ … })` for member tokens from OAuth. Calls return `{ data, error, response }`;
|
|
52
|
+
`unwrap()` returns `data` or throws a `CheckCourtApiError`. Requests are retried once
|
|
53
|
+
after a `401` with a fresh token and with backoff after a `429`.
|
|
54
|
+
|
|
55
|
+
### Verify a webhook
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { SIGNATURE_HEADER, isEventType, verifyWebhook } from "@checkcourt/sdk";
|
|
59
|
+
|
|
60
|
+
export async function POST(request: Request) {
|
|
61
|
+
const event = await verifyWebhook({
|
|
62
|
+
secret: process.env.CHECKCOURT_WEBHOOK_SECRET!, // whsec_…
|
|
63
|
+
rawBody: await request.text(),
|
|
64
|
+
signatureHeader: request.headers.get(SIGNATURE_HEADER),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (isEventType(event, "booking.created")) {
|
|
68
|
+
console.log("new booking", event.data.booking_id);
|
|
69
|
+
}
|
|
70
|
+
return new Response(null, { status: 200 });
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`verifyWebhook` throws a `WebhookSignatureError` when the signature or timestamp does not
|
|
75
|
+
check out. Deduplicate on `event.id`: retries carry the same id.
|
|
76
|
+
|
|
77
|
+
### Declarative extensions and the `ui` builder
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { ExtensionVerificationError, toast, ui, verifyExtensionRequest } from "@checkcourt/sdk";
|
|
81
|
+
|
|
82
|
+
export async function POST(request: Request) {
|
|
83
|
+
let ext;
|
|
84
|
+
try {
|
|
85
|
+
ext = await verifyExtensionRequest({
|
|
86
|
+
secret: process.env.CHECKCOURT_WEBHOOK_SECRET!,
|
|
87
|
+
rawBody: await request.text(),
|
|
88
|
+
headers: request.headers,
|
|
89
|
+
});
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (err instanceof ExtensionVerificationError) return new Response(null, { status: 401 });
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { context } = ext;
|
|
96
|
+
if (context.point !== "booking.detail.panel") return new Response(null, { status: 404 });
|
|
97
|
+
|
|
98
|
+
if (!(await courtHasDoor(context.subject.id))) {
|
|
99
|
+
// Nothing relevant for this booking: CheckCourt shows no card at all.
|
|
100
|
+
return Response.json(ui.hidden());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (ext.kind === "action" && ext.actionId === "door.open") {
|
|
104
|
+
await openDoor(context.subject.id);
|
|
105
|
+
return Response.json(ui.doc([ui.text("The door is open.")], { toast: toast.success("Door opened") }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return Response.json(
|
|
109
|
+
ui.doc([
|
|
110
|
+
ui.heading("Court door"),
|
|
111
|
+
ui.row([ui.stat("Opened today", "12"), ui.badge("Online", "secondary")]),
|
|
112
|
+
ui.button("Open door", "door.open"),
|
|
113
|
+
]),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
CheckCourt keeps a successful render for 30 seconds. Pass `maxAge` (seconds, at most 300,
|
|
119
|
+
sent as `cache.max_age`) to change that for one answer, or `0` when the panel must always
|
|
120
|
+
be fresh:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
return Response.json(ui.doc([ui.stat("Battery", `${level} %`)], { maxAge: 0 }));
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A `Cache-Control` response header (`no-store`, `max-age=N`) works too; `maxAge` wins when
|
|
127
|
+
both are set. In your sandbox club nothing is cached.
|
|
128
|
+
|
|
129
|
+
Compare `context.installation_id` and `context.tenant_id` with what you stored from
|
|
130
|
+
`app.installed` before you act on a request. For the context token alone (for example in
|
|
131
|
+
the backend of an iframe extension), use `verifyExtensionContext(token, secret)`.
|
|
132
|
+
|
|
133
|
+
### OAuth with PKCE (member apps)
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { buildAuthorizeUrl, createPkcePair, createState, exchangeCode, parseAuthorizeCallback } from "@checkcourt/sdk";
|
|
137
|
+
|
|
138
|
+
// 1. Redirect the member to CheckCourt
|
|
139
|
+
const pkce = await createPkcePair();
|
|
140
|
+
const state = createState();
|
|
141
|
+
// keep pkce.verifier and state in the member's session
|
|
142
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
143
|
+
clientId: process.env.CHECKCOURT_CLIENT_ID!,
|
|
144
|
+
redirectUri: "https://calendar.example.com/callback",
|
|
145
|
+
state,
|
|
146
|
+
codeChallenge: pkce.challenge,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// 2. In the callback, check state and exchange the code
|
|
150
|
+
const { code } = parseAuthorizeCallback(new URL(callbackUrl), state);
|
|
151
|
+
const tokens = await exchangeCode({
|
|
152
|
+
clientId: process.env.CHECKCOURT_CLIENT_ID!,
|
|
153
|
+
clientSecret: process.env.CHECKCOURT_CLIENT_SECRET!,
|
|
154
|
+
code,
|
|
155
|
+
redirectUri: "https://calendar.example.com/callback",
|
|
156
|
+
codeVerifier: pkce.verifier,
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Pass the stored tokens to `userAuth({ tokens, onTokens, … })`; it refreshes them with
|
|
161
|
+
rotation and calls `onTokens` with every new set, which you must persist.
|
|
162
|
+
|
|
163
|
+
### iframe extensions (browser)
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { connectExtensionFrame } from "@checkcourt/sdk/iframe";
|
|
167
|
+
|
|
168
|
+
const frame = connectExtensionFrame(); // keeps the height in sync and applies the theme
|
|
169
|
+
|
|
170
|
+
// Send frame.context to your backend and verify it there, never in the browser.
|
|
171
|
+
await fetch("/api/session", { method: "POST", body: JSON.stringify({ context: frame.context }) });
|
|
172
|
+
|
|
173
|
+
frame.toast("success", "Saved");
|
|
174
|
+
frame.navigate("/booking?date=2026-10-07");
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Import the browser entry point `@checkcourt/sdk/iframe` only; it needs no secrets.
|
|
178
|
+
|
|
179
|
+
## Entry points
|
|
180
|
+
|
|
181
|
+
| Import | Runs in | Contents |
|
|
182
|
+
|---|---|---|
|
|
183
|
+
| `@checkcourt/sdk` | Server | Everything except the iframe part |
|
|
184
|
+
| `@checkcourt/sdk/oauth` | Server | OAuth and installation tokens |
|
|
185
|
+
| `@checkcourt/sdk/webhooks` | Server, edge | Webhook verification and event types |
|
|
186
|
+
| `@checkcourt/sdk/extensions` | Server, edge | Context tokens, request verification, UI builder |
|
|
187
|
+
| `@checkcourt/sdk/manifest` | Anywhere | `defineManifest` and constants |
|
|
188
|
+
| `@checkcourt/sdk/iframe` | Browser | `connectExtensionFrame` and messages |
|
|
189
|
+
|
|
190
|
+
Client secrets, webhook secrets and refresh tokens belong on your server only.
|
|
191
|
+
|
|
192
|
+
## Documentation
|
|
193
|
+
|
|
194
|
+
Full guides and the API reference: <https://docs.checkcourt.de/docs/developer/sdk>
|
|
195
|
+
|
|
196
|
+
## Versioning
|
|
197
|
+
|
|
198
|
+
The SDK follows semantic versioning but is still in `0.x`: minor releases may contain
|
|
199
|
+
breaking changes until 1.0. Pin a version and read the [changelog](CHANGELOG.md) before you
|
|
200
|
+
upgrade.
|
|
201
|
+
|
|
202
|
+
The exported constant `OPENAPI_SPEC_SHA256` is the SHA-256 of the spec the bundled types
|
|
203
|
+
were generated from, in the form served at `https://app.checkcourt.de/api/v1/openapi`.
|
|
204
|
+
Compare it with a hash of that response to see whether the platform has changed since this
|
|
205
|
+
release.
|
|
206
|
+
|
|
207
|
+
## Security
|
|
208
|
+
|
|
209
|
+
Please do not report security issues in public GitHub issues. Report them privately to
|
|
210
|
+
CheckCourt support at [support@checkcourt.de](mailto:support@checkcourt.de).
|
|
211
|
+
|
|
212
|
+
## Contributing
|
|
213
|
+
|
|
214
|
+
Bug reports and pull requests are welcome.
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
npm install
|
|
218
|
+
npm run typecheck
|
|
219
|
+
npm test
|
|
220
|
+
npm run build
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The API types in `src/generated/` are generated from the live OpenAPI spec:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
npm run generate # https://app.checkcourt.de/api/v1/openapi
|
|
227
|
+
CHECKCOURT_OPENAPI=./openapi.json npm run generate # a local file or another URL
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The generated types follow the platform. A release may ship types for endpoints that are
|
|
231
|
+
about to be deployed, so do not regenerate them in an unrelated pull request.
|
|
232
|
+
|
|
233
|
+
### Releasing
|
|
234
|
+
|
|
235
|
+
1. Bump `version` in `package.json` (`npm version <x.y.z> --no-git-tag-version`).
|
|
236
|
+
2. Add the release to `CHANGELOG.md`.
|
|
237
|
+
3. Run `npm run build` and commit, including the rebuilt `dist/`.
|
|
238
|
+
4. Tag the commit `vX.Y.Z` and push the tag (`git push origin vX.Y.Z`).
|
|
239
|
+
|
|
240
|
+
The release workflow checks that the tag matches the package version and that `dist/` is
|
|
241
|
+
up to date, then publishes to npm with provenance via trusted publishing.
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
[MIT](LICENSE)
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type FetchLike, type InstallationToken, type UserTokenSet } from "./oauth.js";
|
|
2
|
+
export interface AuthContext {
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
fetch?: FetchLike;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* How the client gets its bearer token. Share one strategy object between clients (for example
|
|
8
|
+
* one per club via `tenantId`) to share its token cache and refresh lock.
|
|
9
|
+
*/
|
|
10
|
+
export interface AuthStrategy {
|
|
11
|
+
getAccessToken(context?: AuthContext): Promise<string>;
|
|
12
|
+
/** Called after a 401 with the token that failed; true if a retry with a fresh token can help. */
|
|
13
|
+
invalidate?(token: string): boolean;
|
|
14
|
+
}
|
|
15
|
+
/** A management (`ck_mgmt_…`) or personal (`ck_user_…`) API key. */
|
|
16
|
+
export declare function apiKeyAuth(apiKey: string): AuthStrategy;
|
|
17
|
+
interface CredentialOptions {
|
|
18
|
+
clientId: string;
|
|
19
|
+
clientSecret: string;
|
|
20
|
+
baseUrl?: string;
|
|
21
|
+
fetch?: FetchLike;
|
|
22
|
+
/** Renew this long before expiry. Default 60. */
|
|
23
|
+
refreshMarginSeconds?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface InstallationAuth extends AuthStrategy {
|
|
26
|
+
/** The cached token, fetching one if needed. */
|
|
27
|
+
getToken(context?: AuthContext): Promise<InstallationToken>;
|
|
28
|
+
}
|
|
29
|
+
/** Club installation: exchanges client credentials for `cca_` tokens, cached until shortly before expiry. */
|
|
30
|
+
export declare function installationAuth(options: CredentialOptions & {
|
|
31
|
+
installationId: string;
|
|
32
|
+
}): InstallationAuth;
|
|
33
|
+
/** What you need to persist per member; a full `UserTokenSet` fits too. */
|
|
34
|
+
export type StoredUserTokens = Pick<UserTokenSet, "accessToken" | "refreshToken" | "expiresAt"> & Partial<Pick<UserTokenSet, "scope" | "installations">>;
|
|
35
|
+
export interface UserAuth extends AuthStrategy {
|
|
36
|
+
/** The tokens currently in use (after any rotation). */
|
|
37
|
+
getTokens(): StoredUserTokens;
|
|
38
|
+
/** Forces a refresh now; concurrent calls share one request. */
|
|
39
|
+
refresh(context?: AuthContext): Promise<StoredUserTokens>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Member connection (`ccu_` / `ccr_`). Refreshes shortly before expiry with rotation; concurrent
|
|
43
|
+
* requests share a single refresh so the chain is never reused. `onTokens` runs after every
|
|
44
|
+
* rotation and before the new token is used: persist there, the old refresh token is spent.
|
|
45
|
+
*
|
|
46
|
+
* Errors: `TokenRevokedError` (`invalid_grant`, also after the 180-day chain limit) is final for
|
|
47
|
+
* this strategy. `AppTemporarilyUnavailableError` leaves the tokens untouched; try again later.
|
|
48
|
+
* The lock is per process: refresh one member in one place at a time.
|
|
49
|
+
*/
|
|
50
|
+
export declare function userAuth(options: CredentialOptions & {
|
|
51
|
+
tokens: StoredUserTokens;
|
|
52
|
+
onTokens: (tokens: UserTokenSet) => void | Promise<void>;
|
|
53
|
+
}): UserAuth;
|
|
54
|
+
export {};
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { TokenRevokedError } from "./errors.js";
|
|
2
|
+
import { fetchInstallationToken, refreshTokens, } from "./oauth.js";
|
|
3
|
+
/** A management (`ck_mgmt_…`) or personal (`ck_user_…`) API key. */
|
|
4
|
+
export function apiKeyAuth(apiKey) {
|
|
5
|
+
return { getAccessToken: async () => apiKey };
|
|
6
|
+
}
|
|
7
|
+
/** Club installation: exchanges client credentials for `cca_` tokens, cached until shortly before expiry. */
|
|
8
|
+
export function installationAuth(options) {
|
|
9
|
+
const margin = (options.refreshMarginSeconds ?? 60) * 1000;
|
|
10
|
+
let cached = null;
|
|
11
|
+
let inflight = null;
|
|
12
|
+
const getToken = async (context = {}) => {
|
|
13
|
+
if (cached && cached.expiresAt - margin > Date.now())
|
|
14
|
+
return cached;
|
|
15
|
+
inflight ??= fetchInstallationToken({
|
|
16
|
+
clientId: options.clientId,
|
|
17
|
+
clientSecret: options.clientSecret,
|
|
18
|
+
installationId: options.installationId,
|
|
19
|
+
baseUrl: options.baseUrl ?? context.baseUrl,
|
|
20
|
+
fetch: options.fetch ?? context.fetch,
|
|
21
|
+
})
|
|
22
|
+
.then((token) => (cached = token))
|
|
23
|
+
.finally(() => {
|
|
24
|
+
inflight = null;
|
|
25
|
+
});
|
|
26
|
+
return inflight;
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
getToken,
|
|
30
|
+
getAccessToken: async (context) => (await getToken(context)).accessToken,
|
|
31
|
+
invalidate(token) {
|
|
32
|
+
if (cached?.accessToken === token)
|
|
33
|
+
cached = null;
|
|
34
|
+
return true;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Member connection (`ccu_` / `ccr_`). Refreshes shortly before expiry with rotation; concurrent
|
|
40
|
+
* requests share a single refresh so the chain is never reused. `onTokens` runs after every
|
|
41
|
+
* rotation and before the new token is used: persist there, the old refresh token is spent.
|
|
42
|
+
*
|
|
43
|
+
* Errors: `TokenRevokedError` (`invalid_grant`, also after the 180-day chain limit) is final for
|
|
44
|
+
* this strategy. `AppTemporarilyUnavailableError` leaves the tokens untouched; try again later.
|
|
45
|
+
* The lock is per process: refresh one member in one place at a time.
|
|
46
|
+
*/
|
|
47
|
+
export function userAuth(options) {
|
|
48
|
+
const margin = (options.refreshMarginSeconds ?? 60) * 1000;
|
|
49
|
+
let current = { ...options.tokens };
|
|
50
|
+
let revoked = null;
|
|
51
|
+
let inflight = null;
|
|
52
|
+
const refresh = (context = {}) => {
|
|
53
|
+
if (revoked)
|
|
54
|
+
return Promise.reject(revoked);
|
|
55
|
+
inflight ??= (async () => {
|
|
56
|
+
try {
|
|
57
|
+
const next = await refreshTokens({
|
|
58
|
+
clientId: options.clientId,
|
|
59
|
+
clientSecret: options.clientSecret,
|
|
60
|
+
refreshToken: current.refreshToken,
|
|
61
|
+
baseUrl: options.baseUrl ?? context.baseUrl,
|
|
62
|
+
fetch: options.fetch ?? context.fetch,
|
|
63
|
+
});
|
|
64
|
+
current = next;
|
|
65
|
+
await options.onTokens(next);
|
|
66
|
+
return next;
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (err instanceof TokenRevokedError)
|
|
70
|
+
revoked = err;
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
})().finally(() => {
|
|
74
|
+
inflight = null;
|
|
75
|
+
});
|
|
76
|
+
return inflight;
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
getTokens: () => current,
|
|
80
|
+
refresh,
|
|
81
|
+
async getAccessToken(context) {
|
|
82
|
+
if (revoked)
|
|
83
|
+
throw revoked;
|
|
84
|
+
if (current.expiresAt - margin > Date.now())
|
|
85
|
+
return current.accessToken;
|
|
86
|
+
return (await refresh(context)).accessToken;
|
|
87
|
+
},
|
|
88
|
+
invalidate(token) {
|
|
89
|
+
if (revoked)
|
|
90
|
+
return false;
|
|
91
|
+
if (current.accessToken === token)
|
|
92
|
+
current = { ...current, expiresAt: 0 };
|
|
93
|
+
return true;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Client } from "openapi-fetch";
|
|
2
|
+
import type { AuthStrategy } from "./auth.js";
|
|
3
|
+
import type { FetchLike } from "./oauth.js";
|
|
4
|
+
import type { paths } from "./generated/schema.js";
|
|
5
|
+
export declare const TENANT_HEADER = "X-Tenant-Id";
|
|
6
|
+
export interface RetryOptions {
|
|
7
|
+
/** Retries after a 429. Default 2. */
|
|
8
|
+
maxRetries?: number;
|
|
9
|
+
/** Wait without `Retry-After`: this doubled per attempt. Default 1000. */
|
|
10
|
+
baseDelayMs?: number;
|
|
11
|
+
/** Longer waits are not attempted; the 429 is returned instead. Default 60000. */
|
|
12
|
+
maxDelayMs?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface CheckCourtClientOptions {
|
|
15
|
+
/** Defaults to https://app.checkcourt.de; a trailing `/api/v1` is accepted. */
|
|
16
|
+
baseUrl?: string;
|
|
17
|
+
auth: AuthStrategy;
|
|
18
|
+
/** Sent as `X-Tenant-Id` unless a request sets its own. Needed for member tokens with several clubs and personal keys. */
|
|
19
|
+
tenantId?: string;
|
|
20
|
+
fetch?: FetchLike;
|
|
21
|
+
/** `false` disables retries on 429. */
|
|
22
|
+
retry?: RetryOptions | false;
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
export type CheckCourtClient = Client<paths>;
|
|
26
|
+
export declare function retryAfterMs(value: string | null, now?: number): number | null;
|
|
27
|
+
/**
|
|
28
|
+
* Typed `/api/v1` client (openapi-fetch). Calls return `{ data, error, response }`; wrap them in
|
|
29
|
+
* `unwrap()` to get `data` or a thrown `CheckCourtApiError`. A 401 triggers one retry with a fresh
|
|
30
|
+
* token; 429 is retried with backoff, honouring `Retry-After`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createCheckCourtClient(options: CheckCourtClientOptions): CheckCourtClient;
|
|
33
|
+
/** Returns `data` of an openapi-fetch call or throws `CheckCourtApiError` with status, code and message. */
|
|
34
|
+
export declare function unwrap<T>(call: Promise<{
|
|
35
|
+
data?: T;
|
|
36
|
+
error?: unknown;
|
|
37
|
+
response: Response;
|
|
38
|
+
}>): Promise<T>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import createClient, {} from "openapi-fetch";
|
|
2
|
+
import { CheckCourtApiError } from "./errors.js";
|
|
3
|
+
import { normalizeBaseUrl } from "./internal/base-url.js";
|
|
4
|
+
export const TENANT_HEADER = "X-Tenant-Id";
|
|
5
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
export function retryAfterMs(value, now = Date.now()) {
|
|
7
|
+
if (!value)
|
|
8
|
+
return null;
|
|
9
|
+
if (/^\d+$/.test(value.trim()))
|
|
10
|
+
return Number(value.trim()) * 1000;
|
|
11
|
+
const date = Date.parse(value);
|
|
12
|
+
return Number.isNaN(date) ? null : Math.max(0, date - now);
|
|
13
|
+
}
|
|
14
|
+
// Never awaited: under Next.js' patched fetch the body is a tee branch whose cancel() never settles.
|
|
15
|
+
function discard(response) {
|
|
16
|
+
try {
|
|
17
|
+
response.body?.cancel().catch(() => { });
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Already consumed or locked: nothing to free.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Typed `/api/v1` client (openapi-fetch). Calls return `{ data, error, response }`; wrap them in
|
|
25
|
+
* `unwrap()` to get `data` or a thrown `CheckCourtApiError`. A 401 triggers one retry with a fresh
|
|
26
|
+
* token; 429 is retried with backoff, honouring `Retry-After`.
|
|
27
|
+
*/
|
|
28
|
+
export function createCheckCourtClient(options) {
|
|
29
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
30
|
+
const doFetch = options.fetch ?? ((request) => fetch(request));
|
|
31
|
+
const retry = options.retry === false ? null : options.retry ?? {};
|
|
32
|
+
const maxRetries = retry?.maxRetries ?? 2;
|
|
33
|
+
const baseDelay = retry?.baseDelayMs ?? 1000;
|
|
34
|
+
const maxDelay = retry?.maxDelayMs ?? 60_000;
|
|
35
|
+
const context = { baseUrl, fetch: doFetch };
|
|
36
|
+
const authedFetch = async (request) => {
|
|
37
|
+
let retries = 0;
|
|
38
|
+
let reauthenticated = false;
|
|
39
|
+
for (;;) {
|
|
40
|
+
const attempt = request.clone();
|
|
41
|
+
const token = await options.auth.getAccessToken(context);
|
|
42
|
+
attempt.headers.set("Authorization", `Bearer ${token}`);
|
|
43
|
+
if (options.tenantId && !attempt.headers.has(TENANT_HEADER))
|
|
44
|
+
attempt.headers.set(TENANT_HEADER, options.tenantId);
|
|
45
|
+
const response = await doFetch(attempt);
|
|
46
|
+
if (response.status === 401 && !reauthenticated && options.auth.invalidate?.(token)) {
|
|
47
|
+
reauthenticated = true;
|
|
48
|
+
discard(response);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (response.status === 429 && retry && retries < maxRetries) {
|
|
52
|
+
const delay = retryAfterMs(response.headers.get("Retry-After")) ?? baseDelay * 2 ** retries;
|
|
53
|
+
if (delay <= maxDelay) {
|
|
54
|
+
retries += 1;
|
|
55
|
+
discard(response);
|
|
56
|
+
await sleep(delay);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return response;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
return createClient({ baseUrl: `${baseUrl}/api/v1`, fetch: authedFetch, headers: options.headers });
|
|
64
|
+
}
|
|
65
|
+
/** Returns `data` of an openapi-fetch call or throws `CheckCourtApiError` with status, code and message. */
|
|
66
|
+
export async function unwrap(call) {
|
|
67
|
+
const { data, error, response } = await call;
|
|
68
|
+
if (error !== undefined || !response.ok)
|
|
69
|
+
throw CheckCourtApiError.fromBody(response.status, error, response);
|
|
70
|
+
return data;
|
|
71
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type CheckCourtErrorKind = "CheckCourtError" | "CheckCourtApiError" | "OAuthError" | "TokenRevokedError" | "AppTemporarilyUnavailableError" | "WebhookSignatureError" | "ExtensionVerificationError";
|
|
2
|
+
declare const KIND: unique symbol;
|
|
3
|
+
/**
|
|
4
|
+
* Base class for every error the SDK throws on purpose. `instanceof` works across copies of the
|
|
5
|
+
* SDK (it checks a brand, not the prototype); `isCheckCourtError(err, kind)` does the same as a function.
|
|
6
|
+
*/
|
|
7
|
+
export declare class CheckCourtError extends Error {
|
|
8
|
+
static readonly [KIND]: CheckCourtErrorKind;
|
|
9
|
+
static [Symbol.hasInstance](this: {
|
|
10
|
+
[KIND]?: CheckCourtErrorKind;
|
|
11
|
+
}, value: unknown): boolean;
|
|
12
|
+
constructor(message: string, options?: {
|
|
13
|
+
cause?: unknown;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export type ApiErrorCode = "UNAUTHORIZED" | "VALIDATION" | "FORBIDDEN" | "NOT_FOUND" | "BUSINESS_RULE" | "RATE_LIMITED" | (string & {});
|
|
17
|
+
/** A non-2xx answer from `/api/v1`. Branch on `code`; `message` is German and may change. */
|
|
18
|
+
export declare class CheckCourtApiError extends CheckCourtError {
|
|
19
|
+
static readonly [KIND]: CheckCourtErrorKind;
|
|
20
|
+
readonly status: number;
|
|
21
|
+
readonly code: ApiErrorCode;
|
|
22
|
+
readonly response?: Response;
|
|
23
|
+
constructor(status: number, code: ApiErrorCode, message: string, response?: Response);
|
|
24
|
+
static fromBody(status: number, body: unknown, response?: Response): CheckCourtApiError;
|
|
25
|
+
}
|
|
26
|
+
/** An RFC 6749 error from `/api/oauth/*`, e.g. `invalid_client` or `slow_down`. */
|
|
27
|
+
export declare class OAuthError extends CheckCourtError {
|
|
28
|
+
static readonly [KIND]: CheckCourtErrorKind;
|
|
29
|
+
readonly status: number;
|
|
30
|
+
readonly error: string;
|
|
31
|
+
readonly description: string;
|
|
32
|
+
constructor(status: number, error: string, description: string);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `invalid_grant`: the refresh token or code is dead (expired, revoked, reused, chain older than
|
|
36
|
+
* 180 days, member disconnected everywhere). Only a new authorization helps.
|
|
37
|
+
*/
|
|
38
|
+
export declare class TokenRevokedError extends OAuthError {
|
|
39
|
+
static readonly [KIND]: CheckCourtErrorKind;
|
|
40
|
+
constructor(status: number, error: string, description: string);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `temporarily_unavailable` on refresh: the grant is still valid but nothing may run right now
|
|
44
|
+
* (connection paused, app suspended, account banned). The refresh token was not consumed.
|
|
45
|
+
*/
|
|
46
|
+
export declare class AppTemporarilyUnavailableError extends OAuthError {
|
|
47
|
+
static readonly [KIND]: CheckCourtErrorKind;
|
|
48
|
+
constructor(status: number, error: string, description: string);
|
|
49
|
+
}
|
|
50
|
+
export declare function oauthErrorFrom(status: number, body: unknown): OAuthError;
|
|
51
|
+
export type WebhookSignatureFailure = "missing_header" | "malformed_header" | "timestamp_out_of_tolerance" | "signature_mismatch" | "invalid_payload";
|
|
52
|
+
export declare class WebhookSignatureError extends CheckCourtError {
|
|
53
|
+
static readonly [KIND]: CheckCourtErrorKind;
|
|
54
|
+
readonly reason: WebhookSignatureFailure;
|
|
55
|
+
constructor(reason: WebhookSignatureFailure, message: string);
|
|
56
|
+
}
|
|
57
|
+
export type ExtensionVerificationFailure = "malformed_token" | "unsupported_algorithm" | "invalid_signature" | "invalid_claims" | "expired" | "not_yet_valid" | "request_signature" | "invalid_body" | "context_mismatch";
|
|
58
|
+
export declare class ExtensionVerificationError extends CheckCourtError {
|
|
59
|
+
static readonly [KIND]: CheckCourtErrorKind;
|
|
60
|
+
readonly reason: ExtensionVerificationFailure;
|
|
61
|
+
constructor(reason: ExtensionVerificationFailure, message: string);
|
|
62
|
+
}
|
|
63
|
+
interface ErrorKinds {
|
|
64
|
+
CheckCourtError: CheckCourtError;
|
|
65
|
+
CheckCourtApiError: CheckCourtApiError;
|
|
66
|
+
OAuthError: OAuthError;
|
|
67
|
+
TokenRevokedError: TokenRevokedError;
|
|
68
|
+
AppTemporarilyUnavailableError: AppTemporarilyUnavailableError;
|
|
69
|
+
WebhookSignatureError: WebhookSignatureError;
|
|
70
|
+
ExtensionVerificationError: ExtensionVerificationError;
|
|
71
|
+
}
|
|
72
|
+
/** True for errors from any copy of the SDK; with `kind`, also for that class or a subclass of it. */
|
|
73
|
+
export declare function isCheckCourtError<K extends CheckCourtErrorKind = "CheckCourtError">(error: unknown, kind?: K): error is ErrorKinds[K];
|
|
74
|
+
export {};
|