@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.
- package/LICENSE +21 -0
- package/README.md +230 -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 +183 -0
- package/dist/ui.js +91 -0
- package/dist/webhooks.d.ts +34 -0
- package/dist/webhooks.js +82 -0
- package/package.json +76 -4
package/dist/errors.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Symbol.for, not Symbol(): two copies of the SDK (route bundles, duplicate installs) must agree.
|
|
2
|
+
const CHECKCOURT_ERROR_BRAND = Symbol.for("checkcourt.sdk.error");
|
|
3
|
+
const kindBrand = (kind) => Symbol.for(`checkcourt.sdk.error.${kind}`);
|
|
4
|
+
const KIND = Symbol.for("checkcourt.sdk.error.kind");
|
|
5
|
+
function brand(error, kind) {
|
|
6
|
+
// Hard-coded because minifiers rename classes, so constructor.name is unreliable.
|
|
7
|
+
error.name = kind;
|
|
8
|
+
Object.defineProperty(error, CHECKCOURT_ERROR_BRAND, { value: true, configurable: true });
|
|
9
|
+
Object.defineProperty(error, kindBrand(kind), { value: true, configurable: true });
|
|
10
|
+
}
|
|
11
|
+
function hasBrand(value, kind) {
|
|
12
|
+
return (typeof value === "object" &&
|
|
13
|
+
value !== null &&
|
|
14
|
+
value[CHECKCOURT_ERROR_BRAND] === true &&
|
|
15
|
+
value[kindBrand(kind)] === true);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Base class for every error the SDK throws on purpose. `instanceof` works across copies of the
|
|
19
|
+
* SDK (it checks a brand, not the prototype); `isCheckCourtError(err, kind)` does the same as a function.
|
|
20
|
+
*/
|
|
21
|
+
export class CheckCourtError extends Error {
|
|
22
|
+
static [KIND] = "CheckCourtError";
|
|
23
|
+
static [Symbol.hasInstance](value) {
|
|
24
|
+
if (Function.prototype[Symbol.hasInstance].call(this, value))
|
|
25
|
+
return true;
|
|
26
|
+
// Only SDK classes declare their own kind; user subclasses fall back to the prototype check.
|
|
27
|
+
if (!Object.prototype.hasOwnProperty.call(this, KIND))
|
|
28
|
+
return false;
|
|
29
|
+
return hasBrand(value, this[KIND]);
|
|
30
|
+
}
|
|
31
|
+
constructor(message, options) {
|
|
32
|
+
super(message, options);
|
|
33
|
+
brand(this, "CheckCourtError");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** A non-2xx answer from `/api/v1`. Branch on `code`; `message` is German and may change. */
|
|
37
|
+
export class CheckCourtApiError extends CheckCourtError {
|
|
38
|
+
static [KIND] = "CheckCourtApiError";
|
|
39
|
+
status;
|
|
40
|
+
code;
|
|
41
|
+
response;
|
|
42
|
+
constructor(status, code, message, response) {
|
|
43
|
+
super(message);
|
|
44
|
+
brand(this, "CheckCourtApiError");
|
|
45
|
+
this.status = status;
|
|
46
|
+
this.code = code;
|
|
47
|
+
this.response = response;
|
|
48
|
+
}
|
|
49
|
+
static fromBody(status, body, response) {
|
|
50
|
+
const error = body?.error;
|
|
51
|
+
const code = typeof error?.code === "string" ? error.code : `HTTP_${status}`;
|
|
52
|
+
const message = typeof error?.message === "string" ? error.message : `HTTP ${status}`;
|
|
53
|
+
return new CheckCourtApiError(status, code, message, response);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** An RFC 6749 error from `/api/oauth/*`, e.g. `invalid_client` or `slow_down`. */
|
|
57
|
+
export class OAuthError extends CheckCourtError {
|
|
58
|
+
static [KIND] = "OAuthError";
|
|
59
|
+
status;
|
|
60
|
+
error;
|
|
61
|
+
description;
|
|
62
|
+
constructor(status, error, description) {
|
|
63
|
+
super(`${error}: ${description}`);
|
|
64
|
+
brand(this, "OAuthError");
|
|
65
|
+
this.status = status;
|
|
66
|
+
this.error = error;
|
|
67
|
+
this.description = description;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `invalid_grant`: the refresh token or code is dead (expired, revoked, reused, chain older than
|
|
72
|
+
* 180 days, member disconnected everywhere). Only a new authorization helps.
|
|
73
|
+
*/
|
|
74
|
+
export class TokenRevokedError extends OAuthError {
|
|
75
|
+
static [KIND] = "TokenRevokedError";
|
|
76
|
+
constructor(status, error, description) {
|
|
77
|
+
super(status, error, description);
|
|
78
|
+
brand(this, "TokenRevokedError");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `temporarily_unavailable` on refresh: the grant is still valid but nothing may run right now
|
|
83
|
+
* (connection paused, app suspended, account banned). The refresh token was not consumed.
|
|
84
|
+
*/
|
|
85
|
+
export class AppTemporarilyUnavailableError extends OAuthError {
|
|
86
|
+
static [KIND] = "AppTemporarilyUnavailableError";
|
|
87
|
+
constructor(status, error, description) {
|
|
88
|
+
super(status, error, description);
|
|
89
|
+
brand(this, "AppTemporarilyUnavailableError");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function oauthErrorFrom(status, body) {
|
|
93
|
+
const b = (body ?? {});
|
|
94
|
+
const error = typeof b.error === "string" ? b.error : `http_${status}`;
|
|
95
|
+
const description = typeof b.error_description === "string" ? b.error_description : `HTTP ${status}`;
|
|
96
|
+
if (error === "invalid_grant")
|
|
97
|
+
return new TokenRevokedError(status, error, description);
|
|
98
|
+
if (error === "temporarily_unavailable")
|
|
99
|
+
return new AppTemporarilyUnavailableError(status, error, description);
|
|
100
|
+
return new OAuthError(status, error, description);
|
|
101
|
+
}
|
|
102
|
+
export class WebhookSignatureError extends CheckCourtError {
|
|
103
|
+
static [KIND] = "WebhookSignatureError";
|
|
104
|
+
reason;
|
|
105
|
+
constructor(reason, message) {
|
|
106
|
+
super(message);
|
|
107
|
+
brand(this, "WebhookSignatureError");
|
|
108
|
+
this.reason = reason;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export class ExtensionVerificationError extends CheckCourtError {
|
|
112
|
+
static [KIND] = "ExtensionVerificationError";
|
|
113
|
+
reason;
|
|
114
|
+
constructor(reason, message) {
|
|
115
|
+
super(message);
|
|
116
|
+
brand(this, "ExtensionVerificationError");
|
|
117
|
+
this.reason = reason;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** True for errors from any copy of the SDK; with `kind`, also for that class or a subclass of it. */
|
|
121
|
+
export function isCheckCourtError(error, kind) {
|
|
122
|
+
return hasBrand(error, kind ?? "CheckCourtError");
|
|
123
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface BookingEventData {
|
|
2
|
+
booking_id: string;
|
|
3
|
+
court_id: number;
|
|
4
|
+
date: string;
|
|
5
|
+
start_time: string;
|
|
6
|
+
end_time: string;
|
|
7
|
+
type: string;
|
|
8
|
+
}
|
|
9
|
+
export interface MemberEventData {
|
|
10
|
+
member_id: string;
|
|
11
|
+
user_id: string;
|
|
12
|
+
}
|
|
13
|
+
/** `date`, `start_time` and `end_time` are null when the whole court is (un)locked. */
|
|
14
|
+
export interface CourtLockEventData {
|
|
15
|
+
court_id: number;
|
|
16
|
+
date: string | null;
|
|
17
|
+
start_time: string | null;
|
|
18
|
+
end_time: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface AppLifecycleEventData {
|
|
21
|
+
installation_id: string;
|
|
22
|
+
app_id: string;
|
|
23
|
+
version: number;
|
|
24
|
+
}
|
|
25
|
+
/** Payloads are thin on purpose: ids and non-personal facts. Fetch details through the API. */
|
|
26
|
+
export interface EventDataMap {
|
|
27
|
+
"booking.created": BookingEventData;
|
|
28
|
+
"booking.updated": BookingEventData;
|
|
29
|
+
"booking.cancelled": BookingEventData;
|
|
30
|
+
"booking.checked_in": BookingEventData;
|
|
31
|
+
"member.joined": MemberEventData;
|
|
32
|
+
"member.updated": MemberEventData & {
|
|
33
|
+
changed_fields: string[];
|
|
34
|
+
};
|
|
35
|
+
"member.left": MemberEventData;
|
|
36
|
+
"court.locked": CourtLockEventData;
|
|
37
|
+
"court.unlocked": CourtLockEventData;
|
|
38
|
+
"webhook.test": {
|
|
39
|
+
webhook_endpoint_id: string;
|
|
40
|
+
};
|
|
41
|
+
"app.installed": AppLifecycleEventData;
|
|
42
|
+
"app.uninstalled": AppLifecycleEventData;
|
|
43
|
+
"app.upgraded": AppLifecycleEventData;
|
|
44
|
+
/** The club changed the app's settings; read them with `getInstallation`. */
|
|
45
|
+
"app.settings_updated": AppLifecycleEventData;
|
|
46
|
+
}
|
|
47
|
+
export type EventType = keyof EventDataMap;
|
|
48
|
+
/** Events a manifest may list under `events`. */
|
|
49
|
+
export declare const SUBSCRIBABLE_EVENT_TYPES: readonly ["booking.created", "booking.updated", "booking.cancelled", "booking.checked_in", "member.joined", "member.updated", "member.left", "court.locked", "court.unlocked"];
|
|
50
|
+
export type SubscribableEventType = (typeof SUBSCRIBABLE_EVENT_TYPES)[number];
|
|
51
|
+
/** Delivered to the app they concern without being listed in the manifest. */
|
|
52
|
+
export declare const APP_LIFECYCLE_EVENT_TYPES: readonly ["app.installed", "app.uninstalled", "app.upgraded", "app.settings_updated"];
|
|
53
|
+
export type AppLifecycleEventType = (typeof APP_LIFECYCLE_EVENT_TYPES)[number];
|
|
54
|
+
export declare const EVENT_TYPES: readonly ["booking.created", "booking.updated", "booking.cancelled", "booking.checked_in", "member.joined", "member.updated", "member.left", "court.locked", "court.unlocked", "webhook.test", "app.installed", "app.uninstalled", "app.upgraded", "app.settings_updated"];
|
|
55
|
+
export type EventObjectType = "booking" | "court" | "member" | "webhook_endpoint" | "app_installation";
|
|
56
|
+
export interface WebhookEventOf<T extends EventType> {
|
|
57
|
+
/** `evt_…`, stable across retries and equal to the `CheckCourt-Event-Id` header: deduplicate on it. */
|
|
58
|
+
id: string;
|
|
59
|
+
type: T;
|
|
60
|
+
created_at: string;
|
|
61
|
+
tenant_id: string;
|
|
62
|
+
/** Present on deliveries to an app installation. */
|
|
63
|
+
installation_id?: string;
|
|
64
|
+
object: {
|
|
65
|
+
type: EventObjectType | (string & {});
|
|
66
|
+
id: string;
|
|
67
|
+
};
|
|
68
|
+
data: EventDataMap[T];
|
|
69
|
+
}
|
|
70
|
+
export type WebhookEvent = {
|
|
71
|
+
[T in EventType]: WebhookEventOf<T>;
|
|
72
|
+
}[EventType];
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Events a manifest may list under `events`. */
|
|
2
|
+
export const SUBSCRIBABLE_EVENT_TYPES = [
|
|
3
|
+
"booking.created",
|
|
4
|
+
"booking.updated",
|
|
5
|
+
"booking.cancelled",
|
|
6
|
+
"booking.checked_in",
|
|
7
|
+
"member.joined",
|
|
8
|
+
"member.updated",
|
|
9
|
+
"member.left",
|
|
10
|
+
"court.locked",
|
|
11
|
+
"court.unlocked",
|
|
12
|
+
];
|
|
13
|
+
/** Delivered to the app they concern without being listed in the manifest. */
|
|
14
|
+
export const APP_LIFECYCLE_EVENT_TYPES = [
|
|
15
|
+
"app.installed",
|
|
16
|
+
"app.uninstalled",
|
|
17
|
+
"app.upgraded",
|
|
18
|
+
"app.settings_updated",
|
|
19
|
+
];
|
|
20
|
+
export const EVENT_TYPES = [
|
|
21
|
+
...SUBSCRIBABLE_EVENT_TYPES,
|
|
22
|
+
"webhook.test",
|
|
23
|
+
...APP_LIFECYCLE_EVENT_TYPES,
|
|
24
|
+
];
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ExtensionPoint } from "./manifest.js";
|
|
2
|
+
import type { UiFormValues } from "./ui.js";
|
|
3
|
+
import { type RawBody } from "./webhooks.js";
|
|
4
|
+
export * from "./ui.js";
|
|
5
|
+
export { ExtensionVerificationError, type ExtensionVerificationFailure } from "./errors.js";
|
|
6
|
+
export { EXTENSION_POINTS, type ExtensionKind, type ExtensionPoint } from "./manifest.js";
|
|
7
|
+
export declare const CONTEXT_HEADER = "CheckCourt-Context";
|
|
8
|
+
export declare const CONTEXT_ISSUER = "checkcourt";
|
|
9
|
+
export declare const CONTEXT_TTL_SECONDS = 300;
|
|
10
|
+
export declare const CLOCK_LEEWAY_SECONDS = 30;
|
|
11
|
+
/** `action_id` a `booking.action` button sends when it is clicked. */
|
|
12
|
+
export declare const BOOKING_ACTION_INVOKE = "invoke";
|
|
13
|
+
export type ExtensionSubjectType = "booking" | "member" | "installation";
|
|
14
|
+
export interface ExtensionSubject {
|
|
15
|
+
type: ExtensionSubjectType;
|
|
16
|
+
id: string;
|
|
17
|
+
}
|
|
18
|
+
export interface BookingCapabilities {
|
|
19
|
+
is_booker: boolean;
|
|
20
|
+
can_edit_bookings: boolean;
|
|
21
|
+
can_cancel_foreign_bookings: boolean;
|
|
22
|
+
can_see_booking_details: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface MemberCapabilities {
|
|
25
|
+
is_self: boolean;
|
|
26
|
+
can_see_member_details: boolean;
|
|
27
|
+
can_edit_members: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface DashboardCapabilities {
|
|
30
|
+
can_book: boolean;
|
|
31
|
+
can_manage_club: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface AppSettingsCapabilities {
|
|
34
|
+
can_manage_app: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface PointCapabilities {
|
|
37
|
+
"app.settings": AppSettingsCapabilities;
|
|
38
|
+
"booking.detail.panel": BookingCapabilities;
|
|
39
|
+
"booking.action": BookingCapabilities;
|
|
40
|
+
"member.profile.section": MemberCapabilities;
|
|
41
|
+
"dashboard.widget": DashboardCapabilities;
|
|
42
|
+
"kiosk.tile": Record<string, never>;
|
|
43
|
+
}
|
|
44
|
+
export interface PointSubject {
|
|
45
|
+
"app.settings": {
|
|
46
|
+
type: "installation";
|
|
47
|
+
id: string;
|
|
48
|
+
};
|
|
49
|
+
"booking.detail.panel": {
|
|
50
|
+
type: "booking";
|
|
51
|
+
id: string;
|
|
52
|
+
};
|
|
53
|
+
"booking.action": {
|
|
54
|
+
type: "booking";
|
|
55
|
+
id: string;
|
|
56
|
+
};
|
|
57
|
+
"member.profile.section": {
|
|
58
|
+
type: "member";
|
|
59
|
+
id: string;
|
|
60
|
+
};
|
|
61
|
+
"dashboard.widget": null;
|
|
62
|
+
"kiosk.tile": null;
|
|
63
|
+
}
|
|
64
|
+
interface ContextClaimsOf<P extends ExtensionPoint> {
|
|
65
|
+
iss: typeof CONTEXT_ISSUER;
|
|
66
|
+
installation_id: string;
|
|
67
|
+
/** "user": a member's own connection; `viewer.user_id` is then always that member. */
|
|
68
|
+
installation_kind: "tenant" | "user";
|
|
69
|
+
app_id: string;
|
|
70
|
+
tenant_id: string;
|
|
71
|
+
point: P;
|
|
72
|
+
subject: PointSubject[P];
|
|
73
|
+
viewer: {
|
|
74
|
+
/** null on the kiosk; a stable `psn_…` pseudonym unless the installation holds `members:read`. */
|
|
75
|
+
user_id: string | null;
|
|
76
|
+
capabilities: PointCapabilities[P];
|
|
77
|
+
};
|
|
78
|
+
iat: number;
|
|
79
|
+
exp: number;
|
|
80
|
+
nonce: string;
|
|
81
|
+
}
|
|
82
|
+
/** Claims of a verified context token, narrowed by `point`. */
|
|
83
|
+
export type ExtensionContextClaims = {
|
|
84
|
+
[P in ExtensionPoint]: ContextClaimsOf<P>;
|
|
85
|
+
}[ExtensionPoint];
|
|
86
|
+
/**
|
|
87
|
+
* Verifies the context token (HS256 JWT keyed with the whole `whsec_…` secret): signature,
|
|
88
|
+
* `iss`, `iat` and `exp` with 30 s clock leeway, exactly as CheckCourt does. Throws
|
|
89
|
+
* `ExtensionVerificationError`. Then compare `installation_id` and `tenant_id` with what you
|
|
90
|
+
* stored from `app.installed`.
|
|
91
|
+
*/
|
|
92
|
+
export declare function verifyExtensionContext(token: string, secret: string, options?: {
|
|
93
|
+
now?: Date | number;
|
|
94
|
+
}): Promise<ExtensionContextClaims>;
|
|
95
|
+
/** Body of a declarative render request. */
|
|
96
|
+
export interface ExtensionRenderRequest {
|
|
97
|
+
kind: "render";
|
|
98
|
+
context: ExtensionContextClaims;
|
|
99
|
+
point: ExtensionPoint;
|
|
100
|
+
subject: ExtensionSubject | null;
|
|
101
|
+
}
|
|
102
|
+
/** Body of a button click or form submission. `values` is `{}` for buttons. */
|
|
103
|
+
export interface ExtensionActionRequest {
|
|
104
|
+
kind: "action";
|
|
105
|
+
context: ExtensionContextClaims;
|
|
106
|
+
point: ExtensionPoint;
|
|
107
|
+
subject: ExtensionSubject | null;
|
|
108
|
+
actionId: string;
|
|
109
|
+
values: UiFormValues;
|
|
110
|
+
}
|
|
111
|
+
export type ExtensionRequest = ExtensionRenderRequest | ExtensionActionRequest;
|
|
112
|
+
type HeaderSource = Headers | Record<string, string | string[] | undefined>;
|
|
113
|
+
/**
|
|
114
|
+
* Verifies a declarative extension POST: the `CheckCourt-Signature` over the raw body (it binds
|
|
115
|
+
* `action_id` and `values` to the token), the context token, and that body, header token and
|
|
116
|
+
* claims agree. Throws `ExtensionVerificationError`.
|
|
117
|
+
*/
|
|
118
|
+
export declare function verifyExtensionRequest(options: {
|
|
119
|
+
secret: string;
|
|
120
|
+
rawBody: RawBody;
|
|
121
|
+
headers: HeaderSource;
|
|
122
|
+
toleranceSeconds?: number;
|
|
123
|
+
now?: Date | number;
|
|
124
|
+
}): Promise<ExtensionRequest>;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { ExtensionVerificationError, WebhookSignatureError } from "./errors.js";
|
|
2
|
+
import { base64UrlDecode, decodeUtf8, toBytes, utf8 } from "./internal/encoding.js";
|
|
3
|
+
import { hmacSha256Verify } from "./internal/hmac.js";
|
|
4
|
+
import { SIGNATURE_HEADER, verifySignature } from "./webhooks.js";
|
|
5
|
+
export * from "./ui.js";
|
|
6
|
+
export { ExtensionVerificationError } from "./errors.js";
|
|
7
|
+
export { EXTENSION_POINTS } from "./manifest.js";
|
|
8
|
+
export const CONTEXT_HEADER = "CheckCourt-Context";
|
|
9
|
+
export const CONTEXT_ISSUER = "checkcourt";
|
|
10
|
+
export const CONTEXT_TTL_SECONDS = 300;
|
|
11
|
+
export const CLOCK_LEEWAY_SECONDS = 30;
|
|
12
|
+
/** `action_id` a `booking.action` button sends when it is clicked. */
|
|
13
|
+
export const BOOKING_ACTION_INVOKE = "invoke";
|
|
14
|
+
const MAX_TOKEN_LENGTH = 8192;
|
|
15
|
+
function unixSeconds(now) {
|
|
16
|
+
if (now === undefined)
|
|
17
|
+
return Math.floor(Date.now() / 1000);
|
|
18
|
+
return now instanceof Date ? Math.floor(now.getTime() / 1000) : Math.floor(now);
|
|
19
|
+
}
|
|
20
|
+
function decodeJson(part) {
|
|
21
|
+
const bytes = base64UrlDecode(part);
|
|
22
|
+
if (!bytes)
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(decodeUtf8(bytes));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const fail = (reason, message) => new ExtensionVerificationError(reason, message);
|
|
32
|
+
/**
|
|
33
|
+
* Verifies the context token (HS256 JWT keyed with the whole `whsec_…` secret): signature,
|
|
34
|
+
* `iss`, `iat` and `exp` with 30 s clock leeway, exactly as CheckCourt does. Throws
|
|
35
|
+
* `ExtensionVerificationError`. Then compare `installation_id` and `tenant_id` with what you
|
|
36
|
+
* stored from `app.installed`.
|
|
37
|
+
*/
|
|
38
|
+
export async function verifyExtensionContext(token, secret, options = {}) {
|
|
39
|
+
if (typeof token !== "string" || token.length > MAX_TOKEN_LENGTH)
|
|
40
|
+
throw fail("malformed_token", "Token is not a string or too long");
|
|
41
|
+
const parts = token.split(".");
|
|
42
|
+
if (parts.length !== 3 || parts.some((p) => !/^[A-Za-z0-9_-]+$/.test(p))) {
|
|
43
|
+
throw fail("malformed_token", "Token is not a compact JWT");
|
|
44
|
+
}
|
|
45
|
+
const [headerPart, payloadPart, signaturePart] = parts;
|
|
46
|
+
const header = decodeJson(headerPart);
|
|
47
|
+
if (!header || header.alg !== "HS256")
|
|
48
|
+
throw fail("unsupported_algorithm", "Only HS256 is accepted");
|
|
49
|
+
const signature = base64UrlDecode(signaturePart);
|
|
50
|
+
if (!signature || !(await hmacSha256Verify(secret, utf8(`${headerPart}.${payloadPart}`), signature))) {
|
|
51
|
+
throw fail("invalid_signature", "Token signature does not match");
|
|
52
|
+
}
|
|
53
|
+
const claims = decodeJson(payloadPart);
|
|
54
|
+
if (!claims || typeof claims !== "object")
|
|
55
|
+
throw fail("invalid_claims", "Token payload is not an object");
|
|
56
|
+
if (claims.iss !== CONTEXT_ISSUER)
|
|
57
|
+
throw fail("invalid_claims", "Token issuer is not checkcourt");
|
|
58
|
+
if (typeof claims.iat !== "number" || typeof claims.exp !== "number") {
|
|
59
|
+
throw fail("invalid_claims", "Token has no iat or exp");
|
|
60
|
+
}
|
|
61
|
+
const now = unixSeconds(options.now);
|
|
62
|
+
if (claims.iat > now + CLOCK_LEEWAY_SECONDS)
|
|
63
|
+
throw fail("not_yet_valid", "Token was issued in the future");
|
|
64
|
+
if (claims.exp < now - CLOCK_LEEWAY_SECONDS)
|
|
65
|
+
throw fail("expired", "Token has expired");
|
|
66
|
+
return claims;
|
|
67
|
+
}
|
|
68
|
+
function header(headers, name) {
|
|
69
|
+
if (typeof headers.get === "function")
|
|
70
|
+
return headers.get(name) ?? undefined;
|
|
71
|
+
const lower = name.toLowerCase();
|
|
72
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
73
|
+
if (key.toLowerCase() === lower)
|
|
74
|
+
return Array.isArray(value) ? value[0] : value;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
function sameSubject(a, b) {
|
|
79
|
+
if (a === null || b === null)
|
|
80
|
+
return a === b;
|
|
81
|
+
return a.type === b.type && a.id === b.id;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Verifies a declarative extension POST: the `CheckCourt-Signature` over the raw body (it binds
|
|
85
|
+
* `action_id` and `values` to the token), the context token, and that body, header token and
|
|
86
|
+
* claims agree. Throws `ExtensionVerificationError`.
|
|
87
|
+
*/
|
|
88
|
+
export async function verifyExtensionRequest(options) {
|
|
89
|
+
try {
|
|
90
|
+
await verifySignature({
|
|
91
|
+
secret: options.secret,
|
|
92
|
+
rawBody: options.rawBody,
|
|
93
|
+
signatureHeader: header(options.headers, SIGNATURE_HEADER),
|
|
94
|
+
toleranceSeconds: options.toleranceSeconds,
|
|
95
|
+
now: options.now,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err instanceof WebhookSignatureError)
|
|
100
|
+
throw fail("request_signature", err.message);
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
let body;
|
|
104
|
+
try {
|
|
105
|
+
const raw = options.rawBody;
|
|
106
|
+
body = JSON.parse(typeof raw === "string" ? raw : decodeUtf8(toBytes(raw)));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw fail("invalid_body", "Body is not valid JSON");
|
|
110
|
+
}
|
|
111
|
+
if (!body || typeof body !== "object" || typeof body.context !== "string") {
|
|
112
|
+
throw fail("invalid_body", "Body has no context");
|
|
113
|
+
}
|
|
114
|
+
const headerToken = header(options.headers, CONTEXT_HEADER);
|
|
115
|
+
if (headerToken !== undefined && headerToken !== body.context) {
|
|
116
|
+
throw fail("context_mismatch", `${CONTEXT_HEADER} differs from the body's context`);
|
|
117
|
+
}
|
|
118
|
+
const context = await verifyExtensionContext(body.context, options.secret, { now: options.now });
|
|
119
|
+
const subject = (body.subject ?? null);
|
|
120
|
+
if (body.point !== context.point || !sameSubject(subject, context.subject)) {
|
|
121
|
+
throw fail("context_mismatch", "point or subject differ from the context token");
|
|
122
|
+
}
|
|
123
|
+
if (body.action_id === undefined)
|
|
124
|
+
return { kind: "render", context, point: context.point, subject };
|
|
125
|
+
if (typeof body.action_id !== "string")
|
|
126
|
+
throw fail("invalid_body", "action_id is not a string");
|
|
127
|
+
const values = body.values ?? {};
|
|
128
|
+
if (typeof values !== "object" || Array.isArray(values))
|
|
129
|
+
throw fail("invalid_body", "values is not an object");
|
|
130
|
+
return {
|
|
131
|
+
kind: "action",
|
|
132
|
+
context,
|
|
133
|
+
point: context.point,
|
|
134
|
+
subject,
|
|
135
|
+
actionId: body.action_id,
|
|
136
|
+
values: values,
|
|
137
|
+
};
|
|
138
|
+
}
|