@checkcourt/sdk 0.0.0-stage → 0.3.1

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.
@@ -0,0 +1,96 @@
1
+ export { AppTemporarilyUnavailableError, OAuthError, TokenRevokedError, } from "./errors.js";
2
+ export declare const AUTHORIZE_PATH = "/api/oauth/authorize";
3
+ export declare const TOKEN_PATH = "/api/oauth/token";
4
+ export declare const REVOKE_PATH = "/api/oauth/revoke";
5
+ export declare const INSTALLATION_TOKEN_PATH = "/api/oauth/installation-token";
6
+ export type FetchLike = (input: Request) => Promise<Response>;
7
+ export interface ClientCredentials {
8
+ /** `cca_app_…` */
9
+ clientId: string;
10
+ /** `ccas_…`, only ever on your server. */
11
+ clientSecret: string;
12
+ /** Defaults to https://app.checkcourt.de. */
13
+ baseUrl?: string;
14
+ fetch?: FetchLike;
15
+ }
16
+ export interface PkcePair {
17
+ /** Keep it in the member's session until the callback. */
18
+ verifier: string;
19
+ challenge: string;
20
+ method: "S256";
21
+ }
22
+ /** 32 random bytes as verifier (43 characters) and its S256 challenge. */
23
+ export declare function createPkcePair(): Promise<PkcePair>;
24
+ export declare function pkceChallenge(verifier: string): Promise<string>;
25
+ /** A random `state` value for the authorize redirect. */
26
+ export declare function createState(): string;
27
+ export interface AuthorizeUrlOptions {
28
+ baseUrl?: string;
29
+ clientId: string;
30
+ /** Exactly one of the redirect URIs registered in the developer portal. */
31
+ redirectUri: string;
32
+ /** Comes back unchanged, at most 500 characters. */
33
+ state: string;
34
+ codeChallenge: string;
35
+ /** `consent` shows the consent screen even when the member already agreed. */
36
+ prompt?: "consent";
37
+ /** Club id to preselect in the consent screen. */
38
+ tenant?: string;
39
+ }
40
+ export declare function buildAuthorizeUrl(options: AuthorizeUrlOptions): string;
41
+ /**
42
+ * Reads `code` from the redirect back to your app after checking `state`.
43
+ * Throws `OAuthError` (e.g. `access_denied`) when CheckCourt sent an error instead.
44
+ */
45
+ export declare function parseAuthorizeCallback(callbackUrl: string | URL, expectedState: string): {
46
+ code: string;
47
+ };
48
+ export interface UserTokenSet {
49
+ /** `ccu_…`, valid for one hour. */
50
+ accessToken: string;
51
+ /** `ccr_…`, works exactly once; always store the newest. */
52
+ refreshToken: string;
53
+ /** Epoch milliseconds. */
54
+ expiresAt: number;
55
+ scope: string[];
56
+ /** Every running connection of the member with your app, i.e. the clubs this token works in. */
57
+ installations: {
58
+ installationId: string;
59
+ tenantId: string;
60
+ }[];
61
+ }
62
+ export interface InstallationToken {
63
+ /** `cca_…`, valid for one hour, only for this installation. */
64
+ accessToken: string;
65
+ /** Epoch milliseconds. */
66
+ expiresAt: number;
67
+ installationId: string;
68
+ scopes: string[];
69
+ }
70
+ /** POSTs a form to an OAuth endpoint and returns the parsed JSON, or throws the mapped `OAuthError`. */
71
+ export declare function postOAuthForm(credentials: ClientCredentials, path: string, form: Record<string, string>): Promise<{
72
+ body: unknown;
73
+ response: Response;
74
+ }>;
75
+ /** Swaps the one-time code (valid 60 s) for member tokens. */
76
+ export declare function exchangeCode(options: ClientCredentials & {
77
+ code: string;
78
+ redirectUri: string;
79
+ codeVerifier: string;
80
+ }): Promise<UserTokenSet>;
81
+ /**
82
+ * One refresh with rotation. Throws `TokenRevokedError` (`invalid_grant`) or
83
+ * `AppTemporarilyUnavailableError` (token not consumed, retry later with the same one).
84
+ * Never run two refreshes with the same token in parallel: reuse revokes the whole chain.
85
+ */
86
+ export declare function refreshTokens(options: ClientCredentials & {
87
+ refreshToken: string;
88
+ }): Promise<UserTokenSet>;
89
+ /** RFC 7009. A refresh token takes its whole chain along; the member stays connected. */
90
+ export declare function revokeToken(options: ClientCredentials & {
91
+ token: string;
92
+ }): Promise<void>;
93
+ /** Client credentials for one club installation. There is no refresh token: fetch a new one. */
94
+ export declare function fetchInstallationToken(options: ClientCredentials & {
95
+ installationId: string;
96
+ }): Promise<InstallationToken>;
package/dist/oauth.js ADDED
@@ -0,0 +1,140 @@
1
+ import { CheckCourtError, OAuthError, oauthErrorFrom } from "./errors.js";
2
+ import { base64UrlEncode, randomBytes, utf8 } from "./internal/encoding.js";
3
+ import { sha256 } from "./internal/hmac.js";
4
+ import { normalizeBaseUrl } from "./internal/base-url.js";
5
+ export { AppTemporarilyUnavailableError, OAuthError, TokenRevokedError, } from "./errors.js";
6
+ export const AUTHORIZE_PATH = "/api/oauth/authorize";
7
+ export const TOKEN_PATH = "/api/oauth/token";
8
+ export const REVOKE_PATH = "/api/oauth/revoke";
9
+ export const INSTALLATION_TOKEN_PATH = "/api/oauth/installation-token";
10
+ /** 32 random bytes as verifier (43 characters) and its S256 challenge. */
11
+ export async function createPkcePair() {
12
+ const verifier = base64UrlEncode(randomBytes(32));
13
+ return { verifier, challenge: await pkceChallenge(verifier), method: "S256" };
14
+ }
15
+ export async function pkceChallenge(verifier) {
16
+ if (!/^[A-Za-z0-9\-._~]{43,128}$/.test(verifier)) {
17
+ throw new CheckCourtError("code_verifier must be 43 to 128 characters from A-Z a-z 0-9 - . _ ~");
18
+ }
19
+ return base64UrlEncode(await sha256(utf8(verifier)));
20
+ }
21
+ /** A random `state` value for the authorize redirect. */
22
+ export function createState() {
23
+ return base64UrlEncode(randomBytes(16));
24
+ }
25
+ export function buildAuthorizeUrl(options) {
26
+ const url = new URL(AUTHORIZE_PATH, normalizeBaseUrl(options.baseUrl) + "/");
27
+ const params = {
28
+ response_type: "code",
29
+ client_id: options.clientId,
30
+ redirect_uri: options.redirectUri,
31
+ code_challenge: options.codeChallenge,
32
+ code_challenge_method: "S256",
33
+ state: options.state,
34
+ prompt: options.prompt,
35
+ tenant: options.tenant,
36
+ };
37
+ for (const [key, value] of Object.entries(params))
38
+ if (value !== undefined)
39
+ url.searchParams.set(key, value);
40
+ return url.toString();
41
+ }
42
+ /**
43
+ * Reads `code` from the redirect back to your app after checking `state`.
44
+ * Throws `OAuthError` (e.g. `access_denied`) when CheckCourt sent an error instead.
45
+ */
46
+ export function parseAuthorizeCallback(callbackUrl, expectedState) {
47
+ const params = new URL(callbackUrl).searchParams;
48
+ if (params.get("state") !== expectedState) {
49
+ throw new OAuthError(400, "state_mismatch", "state does not match the value sent to authorize");
50
+ }
51
+ const error = params.get("error");
52
+ if (error)
53
+ throw new OAuthError(400, error, params.get("error_description") ?? error);
54
+ const code = params.get("code");
55
+ if (!code)
56
+ throw new OAuthError(400, "invalid_request", "Callback has neither code nor error");
57
+ return { code };
58
+ }
59
+ function basicAuth(clientId, clientSecret) {
60
+ const raw = `${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`;
61
+ return `Basic ${btoa(String.fromCharCode(...utf8(raw)))}`;
62
+ }
63
+ /** POSTs a form to an OAuth endpoint and returns the parsed JSON, or throws the mapped `OAuthError`. */
64
+ export async function postOAuthForm(credentials, path, form) {
65
+ const doFetch = credentials.fetch ?? ((req) => fetch(req));
66
+ const request = new Request(normalizeBaseUrl(credentials.baseUrl) + path, {
67
+ method: "POST",
68
+ headers: {
69
+ Authorization: basicAuth(credentials.clientId, credentials.clientSecret),
70
+ "Content-Type": "application/x-www-form-urlencoded",
71
+ Accept: "application/json",
72
+ },
73
+ body: new URLSearchParams(form).toString(),
74
+ });
75
+ const response = await doFetch(request);
76
+ const text = await response.text();
77
+ let body = null;
78
+ if (text) {
79
+ try {
80
+ body = JSON.parse(text);
81
+ }
82
+ catch {
83
+ body = null;
84
+ }
85
+ }
86
+ if (!response.ok)
87
+ throw oauthErrorFrom(response.status, body);
88
+ return { body, response };
89
+ }
90
+ function toUserTokenSet(raw, now) {
91
+ return {
92
+ accessToken: raw.access_token,
93
+ refreshToken: raw.refresh_token,
94
+ expiresAt: now + raw.expires_in * 1000,
95
+ scope: raw.scope ? raw.scope.split(" ").filter(Boolean) : [],
96
+ installations: (raw.installations ?? []).map((i) => ({
97
+ installationId: i.installation_id,
98
+ tenantId: i.tenant_id,
99
+ })),
100
+ };
101
+ }
102
+ /** Swaps the one-time code (valid 60 s) for member tokens. */
103
+ export async function exchangeCode(options) {
104
+ const { body } = await postOAuthForm(options, TOKEN_PATH, {
105
+ grant_type: "authorization_code",
106
+ code: options.code,
107
+ redirect_uri: options.redirectUri,
108
+ code_verifier: options.codeVerifier,
109
+ });
110
+ return toUserTokenSet(body, Date.now());
111
+ }
112
+ /**
113
+ * One refresh with rotation. Throws `TokenRevokedError` (`invalid_grant`) or
114
+ * `AppTemporarilyUnavailableError` (token not consumed, retry later with the same one).
115
+ * Never run two refreshes with the same token in parallel: reuse revokes the whole chain.
116
+ */
117
+ export async function refreshTokens(options) {
118
+ const { body } = await postOAuthForm(options, TOKEN_PATH, {
119
+ grant_type: "refresh_token",
120
+ refresh_token: options.refreshToken,
121
+ });
122
+ return toUserTokenSet(body, Date.now());
123
+ }
124
+ /** RFC 7009. A refresh token takes its whole chain along; the member stays connected. */
125
+ export async function revokeToken(options) {
126
+ await postOAuthForm(options, REVOKE_PATH, { token: options.token });
127
+ }
128
+ /** Client credentials for one club installation. There is no refresh token: fetch a new one. */
129
+ export async function fetchInstallationToken(options) {
130
+ const { body } = await postOAuthForm(options, INSTALLATION_TOKEN_PATH, {
131
+ installation_id: options.installationId,
132
+ });
133
+ const raw = body;
134
+ return {
135
+ accessToken: raw.access_token,
136
+ expiresAt: Date.now() + raw.expires_in * 1000,
137
+ installationId: raw.installation_id,
138
+ scopes: raw.scopes ?? [],
139
+ };
140
+ }
package/dist/ui.d.ts ADDED
@@ -0,0 +1,183 @@
1
+ export declare const UI_VERSION = "v1";
2
+ export declare const MAX_UI_BLOCKS = 50;
3
+ /** Top-level blocks sit at depth 1; a container's children one deeper. */
4
+ export declare const MAX_UI_DEPTH = 3;
5
+ export declare const BADGE_VARIANTS: readonly ["default", "secondary", "outline", "destructive"];
6
+ export declare const BUTTON_VARIANTS: readonly ["default", "secondary", "outline", "destructive"];
7
+ export type BadgeVariant = (typeof BADGE_VARIANTS)[number];
8
+ export type ButtonVariant = (typeof BUTTON_VARIANTS)[number];
9
+ export type UiTextBlock = {
10
+ type: "text";
11
+ text: string;
12
+ tone?: "muted";
13
+ };
14
+ export type UiHeadingBlock = {
15
+ type: "heading";
16
+ text: string;
17
+ level: 2 | 3;
18
+ };
19
+ export type UiStatBlock = {
20
+ type: "stat";
21
+ label: string;
22
+ value: string;
23
+ hint?: string;
24
+ };
25
+ export type UiBadgeBlock = {
26
+ type: "badge";
27
+ label: string;
28
+ variant?: BadgeVariant;
29
+ };
30
+ export type UiListItem = {
31
+ title: string;
32
+ description?: string;
33
+ };
34
+ export type UiListBlock = {
35
+ type: "list";
36
+ items: UiListItem[];
37
+ };
38
+ export type UiKeyValuePair = {
39
+ label: string;
40
+ value: string;
41
+ };
42
+ export type UiKeyValueBlock = {
43
+ type: "key_value";
44
+ pairs: UiKeyValuePair[];
45
+ };
46
+ export type UiLinkBlock = {
47
+ type: "link";
48
+ label: string;
49
+ url: string;
50
+ };
51
+ export type UiButtonBlock = {
52
+ type: "button";
53
+ label: string;
54
+ action_id: string;
55
+ variant?: ButtonVariant;
56
+ };
57
+ export type UiTextField = {
58
+ type: "text";
59
+ name: string;
60
+ label: string;
61
+ placeholder?: string;
62
+ default?: string;
63
+ required?: boolean;
64
+ max_length?: number;
65
+ };
66
+ export type UiNumberField = {
67
+ type: "number";
68
+ name: string;
69
+ label: string;
70
+ default?: number;
71
+ min?: number;
72
+ max?: number;
73
+ required?: boolean;
74
+ };
75
+ export type UiSelectField = {
76
+ type: "select";
77
+ name: string;
78
+ label: string;
79
+ options: {
80
+ value: string;
81
+ label: string;
82
+ }[];
83
+ default?: string;
84
+ required?: boolean;
85
+ };
86
+ export type UiSwitchField = {
87
+ type: "switch";
88
+ name: string;
89
+ label: string;
90
+ default?: boolean;
91
+ };
92
+ export type UiFormField = UiTextField | UiNumberField | UiSelectField | UiSwitchField;
93
+ export type UiFormBlock = {
94
+ type: "form";
95
+ fields: UiFormField[];
96
+ submit_label: string;
97
+ action_id: string;
98
+ };
99
+ export type UiDividerBlock = {
100
+ type: "divider";
101
+ };
102
+ export type UiStackBlock = {
103
+ type: "stack";
104
+ children: UiBlock[];
105
+ };
106
+ export type UiRowBlock = {
107
+ type: "row";
108
+ children: UiBlock[];
109
+ };
110
+ export type UiBlock = UiTextBlock | UiHeadingBlock | UiStatBlock | UiBadgeBlock | UiListBlock | UiKeyValueBlock | UiLinkBlock | UiButtonBlock | UiFormBlock | UiDividerBlock | UiStackBlock | UiRowBlock;
111
+ export type UiToast = {
112
+ kind: "success" | "error";
113
+ message: string;
114
+ };
115
+ /** The response of a declarative extension: `{ ui: "v1", blocks, toast? }`. */
116
+ export interface UiDocument {
117
+ ui: typeof UI_VERSION;
118
+ blocks: UiBlock[];
119
+ toast?: UiToast;
120
+ }
121
+ /** Tells CheckCourt to show no card for this subject and viewer; on an action, removes the panel. */
122
+ export interface UiHiddenDocument {
123
+ ui: typeof UI_VERSION;
124
+ hidden: true;
125
+ toast?: UiToast;
126
+ }
127
+ /** Anything a declarative extension may answer with. */
128
+ export type UiResponse = UiDocument | UiHiddenDocument;
129
+ /** Values a submitted form sends back, keyed by field name. Empty optional fields are omitted. */
130
+ export type UiFormValues = Record<string, string | number | boolean>;
131
+ type Opt<T> = {
132
+ [K in keyof T]?: T[K];
133
+ };
134
+ /**
135
+ * Builds `ui: "v1"` documents. Strings are plain text (no HTML, no Markdown); CheckCourt
136
+ * renders them with its own design system.
137
+ */
138
+ export declare const ui: {
139
+ readonly doc: (blocks: UiBlock[], options?: {
140
+ toast?: UiToast;
141
+ }) => UiDocument;
142
+ /** Nothing relevant here: no card at all. Answering `204 No Content` does the same. */
143
+ readonly hidden: (options?: {
144
+ toast?: UiToast;
145
+ }) => UiHiddenDocument;
146
+ readonly text: (text: string, options?: {
147
+ tone?: "muted";
148
+ }) => UiTextBlock;
149
+ readonly heading: (text: string, level?: 2 | 3) => UiHeadingBlock;
150
+ readonly stat: (label: string, value: string, options?: {
151
+ hint?: string;
152
+ }) => UiStatBlock;
153
+ readonly badge: (label: string, variant?: BadgeVariant) => UiBadgeBlock;
154
+ readonly list: (items: UiListItem[]) => UiListBlock;
155
+ /** Pairs keep their order; a plain object is turned into pairs in key order. */
156
+ readonly keyValue: (pairs: UiKeyValuePair[] | Record<string, string>) => UiKeyValueBlock;
157
+ /** https only; opens in a new tab. */
158
+ readonly link: (label: string, url: string) => UiLinkBlock;
159
+ readonly button: (label: string, actionId: string, options?: {
160
+ variant?: ButtonVariant;
161
+ }) => UiButtonBlock;
162
+ readonly form: (options: {
163
+ actionId: string;
164
+ submitLabel: string;
165
+ fields: UiFormField[];
166
+ }) => UiFormBlock;
167
+ readonly field: {
168
+ text(name: string, label: string, options?: Opt<Omit<UiTextField, "type" | "name" | "label">>): UiTextField;
169
+ number(name: string, label: string, options?: Opt<Omit<UiNumberField, "type" | "name" | "label">>): UiNumberField;
170
+ select(name: string, label: string, options: UiSelectField["options"], extra?: Opt<Pick<UiSelectField, "default" | "required">>): UiSelectField;
171
+ switch(name: string, label: string, options?: Opt<Pick<UiSwitchField, "default">>): UiSwitchField;
172
+ };
173
+ readonly divider: () => UiDividerBlock;
174
+ readonly stack: (children: UiBlock[]) => UiStackBlock;
175
+ /** Horizontal, wraps on narrow screens. */
176
+ readonly row: (children: UiBlock[]) => UiRowBlock;
177
+ };
178
+ /** Toasts for `ui.doc(blocks, { toast })`, at most 200 characters. */
179
+ export declare const toast: {
180
+ readonly success: (message: string) => UiToast;
181
+ readonly error: (message: string) => UiToast;
182
+ };
183
+ export {};
package/dist/ui.js ADDED
@@ -0,0 +1,91 @@
1
+ export const UI_VERSION = "v1";
2
+ export const MAX_UI_BLOCKS = 50;
3
+ /** Top-level blocks sit at depth 1; a container's children one deeper. */
4
+ export const MAX_UI_DEPTH = 3;
5
+ export const BADGE_VARIANTS = ["default", "secondary", "outline", "destructive"];
6
+ export const BUTTON_VARIANTS = ["default", "secondary", "outline", "destructive"];
7
+ const field = {
8
+ text(name, label, options = {}) {
9
+ return { type: "text", name, label, ...options };
10
+ },
11
+ number(name, label, options = {}) {
12
+ return { type: "number", name, label, ...options };
13
+ },
14
+ select(name, label, options, extra = {}) {
15
+ return { type: "select", name, label, options, ...extra };
16
+ },
17
+ switch(name, label, options = {}) {
18
+ return { type: "switch", name, label, ...options };
19
+ },
20
+ };
21
+ function compact(value) {
22
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
23
+ }
24
+ /**
25
+ * Builds `ui: "v1"` documents. Strings are plain text (no HTML, no Markdown); CheckCourt
26
+ * renders them with its own design system.
27
+ */
28
+ export const ui = {
29
+ doc(blocks, options = {}) {
30
+ return compact({ ui: UI_VERSION, blocks, toast: options.toast });
31
+ },
32
+ /** Nothing relevant here: no card at all. Answering `204 No Content` does the same. */
33
+ hidden(options = {}) {
34
+ return compact({ ui: UI_VERSION, hidden: true, toast: options.toast });
35
+ },
36
+ text(text, options = {}) {
37
+ return compact({ type: "text", text, tone: options.tone });
38
+ },
39
+ heading(text, level = 2) {
40
+ return { type: "heading", text, level };
41
+ },
42
+ stat(label, value, options = {}) {
43
+ return compact({ type: "stat", label, value, hint: options.hint });
44
+ },
45
+ badge(label, variant) {
46
+ return compact({ type: "badge", label, variant });
47
+ },
48
+ list(items) {
49
+ return { type: "list", items: items.map((item) => compact({ ...item })) };
50
+ },
51
+ /** Pairs keep their order; a plain object is turned into pairs in key order. */
52
+ keyValue(pairs) {
53
+ const list = Array.isArray(pairs) ? pairs : Object.entries(pairs).map(([label, value]) => ({ label, value }));
54
+ return { type: "key_value", pairs: list };
55
+ },
56
+ /** https only; opens in a new tab. */
57
+ link(label, url) {
58
+ return { type: "link", label, url };
59
+ },
60
+ button(label, actionId, options = {}) {
61
+ return compact({ type: "button", label, action_id: actionId, variant: options.variant });
62
+ },
63
+ form(options) {
64
+ return {
65
+ type: "form",
66
+ fields: options.fields.map((f) => compact({ ...f })),
67
+ submit_label: options.submitLabel,
68
+ action_id: options.actionId,
69
+ };
70
+ },
71
+ field,
72
+ divider() {
73
+ return { type: "divider" };
74
+ },
75
+ stack(children) {
76
+ return { type: "stack", children };
77
+ },
78
+ /** Horizontal, wraps on narrow screens. */
79
+ row(children) {
80
+ return { type: "row", children };
81
+ },
82
+ };
83
+ /** Toasts for `ui.doc(blocks, { toast })`, at most 200 characters. */
84
+ export const toast = {
85
+ success(message) {
86
+ return { kind: "success", message };
87
+ },
88
+ error(message) {
89
+ return { kind: "error", message };
90
+ },
91
+ };
@@ -0,0 +1,34 @@
1
+ import type { WebhookEvent } from "./events.js";
2
+ export type { AppLifecycleEventData, BookingEventData, CourtLockEventData, EventDataMap, EventObjectType, EventType, MemberEventData, SubscribableEventType, AppLifecycleEventType, WebhookEvent, WebhookEventOf, } from "./events.js";
3
+ export { APP_LIFECYCLE_EVENT_TYPES, EVENT_TYPES, SUBSCRIBABLE_EVENT_TYPES } from "./events.js";
4
+ export { WebhookSignatureError, type WebhookSignatureFailure } from "./errors.js";
5
+ export declare const SIGNATURE_HEADER = "CheckCourt-Signature";
6
+ export declare const EVENT_ID_HEADER = "CheckCourt-Event-Id";
7
+ export declare const EVENT_TYPE_HEADER = "CheckCourt-Event-Type";
8
+ export declare const INSTALLATION_ID_HEADER = "CheckCourt-Installation-Id";
9
+ export declare const DEFAULT_TOLERANCE_SECONDS = 300;
10
+ export type RawBody = string | Uint8Array | ArrayBuffer;
11
+ export interface VerifySignatureOptions {
12
+ /** The whole `whsec_…` string, prefix included. */
13
+ secret: string;
14
+ /** The body exactly as received, before any JSON parsing. */
15
+ rawBody: RawBody;
16
+ /** Value of the `CheckCourt-Signature` header. */
17
+ signatureHeader: string | null | undefined;
18
+ toleranceSeconds?: number;
19
+ /** Override the clock: a `Date` or unix seconds. */
20
+ now?: Date | number;
21
+ }
22
+ /** Checks `t=<unix>,v1=<hex>` over the raw body, as CheckCourt sends it for webhooks and extension requests. */
23
+ export declare function verifySignature(options: VerifySignatureOptions): Promise<void>;
24
+ /**
25
+ * Verifies a webhook delivery and returns the parsed event. Throws `WebhookSignatureError`.
26
+ * Answer 2xx quickly and deduplicate on `event.id`: retries carry the same id.
27
+ */
28
+ export declare function verifyWebhook<E extends WebhookEvent = WebhookEvent>(options: VerifySignatureOptions): Promise<E>;
29
+ /** Builds a valid `CheckCourt-Signature` value, for tests of your own receiver. */
30
+ export declare function signWebhookPayload(secret: string, rawBody: RawBody, timestamp?: number): Promise<string>;
31
+ /** Narrows an event by type, e.g. `if (isEventType(event, "booking.created")) event.data.booking_id`. */
32
+ export declare function isEventType<T extends WebhookEvent["type"]>(event: WebhookEvent, type: T): event is Extract<WebhookEvent, {
33
+ type: T;
34
+ }>;
@@ -0,0 +1,82 @@
1
+ import { WebhookSignatureError } from "./errors.js";
2
+ import { decodeUtf8, hexDecode, hexEncode, toBytes, utf8 } from "./internal/encoding.js";
3
+ import { hmacSha256, hmacSha256Verify } from "./internal/hmac.js";
4
+ export { APP_LIFECYCLE_EVENT_TYPES, EVENT_TYPES, SUBSCRIBABLE_EVENT_TYPES } from "./events.js";
5
+ export { WebhookSignatureError } from "./errors.js";
6
+ export const SIGNATURE_HEADER = "CheckCourt-Signature";
7
+ export const EVENT_ID_HEADER = "CheckCourt-Event-Id";
8
+ export const EVENT_TYPE_HEADER = "CheckCourt-Event-Type";
9
+ export const INSTALLATION_ID_HEADER = "CheckCourt-Installation-Id";
10
+ export const DEFAULT_TOLERANCE_SECONDS = 300;
11
+ function unixSeconds(now) {
12
+ if (now === undefined)
13
+ return Math.floor(Date.now() / 1000);
14
+ return now instanceof Date ? Math.floor(now.getTime() / 1000) : Math.floor(now);
15
+ }
16
+ function signedBytes(timestamp, body) {
17
+ const prefix = utf8(`${timestamp}.`);
18
+ const out = new Uint8Array(prefix.length + body.length);
19
+ out.set(prefix);
20
+ out.set(body, prefix.length);
21
+ return out;
22
+ }
23
+ /** Checks `t=<unix>,v1=<hex>` over the raw body, as CheckCourt sends it for webhooks and extension requests. */
24
+ export async function verifySignature(options) {
25
+ const { secret, signatureHeader } = options;
26
+ if (!signatureHeader)
27
+ throw new WebhookSignatureError("missing_header", `${SIGNATURE_HEADER} header missing`);
28
+ let timestamp = null;
29
+ const signatures = [];
30
+ for (const part of signatureHeader.split(",")) {
31
+ const [key, value] = part.trim().split("=", 2);
32
+ if (key === "t" && value && /^\d+$/.test(value))
33
+ timestamp = Number(value);
34
+ if (key === "v1" && value) {
35
+ const bytes = hexDecode(value);
36
+ if (bytes && bytes.length === 32)
37
+ signatures.push(bytes);
38
+ }
39
+ }
40
+ if (timestamp === null || signatures.length === 0) {
41
+ throw new WebhookSignatureError("malformed_header", `${SIGNATURE_HEADER} header has no t= or v1= part`);
42
+ }
43
+ const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
44
+ if (Math.abs(unixSeconds(options.now) - timestamp) > tolerance) {
45
+ throw new WebhookSignatureError("timestamp_out_of_tolerance", "Signature timestamp is outside the tolerance");
46
+ }
47
+ const data = signedBytes(timestamp, toBytes(options.rawBody));
48
+ for (const signature of signatures) {
49
+ if (await hmacSha256Verify(secret, data, signature))
50
+ return;
51
+ }
52
+ throw new WebhookSignatureError("signature_mismatch", "Signature does not match the body");
53
+ }
54
+ /**
55
+ * Verifies a webhook delivery and returns the parsed event. Throws `WebhookSignatureError`.
56
+ * Answer 2xx quickly and deduplicate on `event.id`: retries carry the same id.
57
+ */
58
+ export async function verifyWebhook(options) {
59
+ await verifySignature(options);
60
+ let parsed;
61
+ try {
62
+ const raw = options.rawBody;
63
+ parsed = JSON.parse(typeof raw === "string" ? raw : decodeUtf8(toBytes(raw)));
64
+ }
65
+ catch {
66
+ throw new WebhookSignatureError("invalid_payload", "Body is not valid JSON");
67
+ }
68
+ const event = parsed;
69
+ if (!event || typeof event !== "object" || typeof event.id !== "string" || typeof event.type !== "string") {
70
+ throw new WebhookSignatureError("invalid_payload", "Body is not a CheckCourt event");
71
+ }
72
+ return parsed;
73
+ }
74
+ /** Builds a valid `CheckCourt-Signature` value, for tests of your own receiver. */
75
+ export async function signWebhookPayload(secret, rawBody, timestamp = Math.floor(Date.now() / 1000)) {
76
+ const mac = await hmacSha256(secret, signedBytes(timestamp, toBytes(rawBody)));
77
+ return `t=${timestamp},v1=${hexEncode(mac)}`;
78
+ }
79
+ /** Narrows an event by type, e.g. `if (isEventType(event, "booking.created")) event.data.booking_id`. */
80
+ export function isEventType(event, type) {
81
+ return event.type === type;
82
+ }