@checkcourt/sdk 0.4.0 → 0.6.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/README.md +98 -1
- package/dist/connections.d.ts +28 -0
- package/dist/connections.js +55 -0
- package/dist/events.d.ts +41 -1
- package/dist/events.js +17 -0
- package/dist/extensions.d.ts +64 -3
- package/dist/extensions.js +60 -3
- package/dist/generated/schema.d.ts +670 -0
- package/dist/generated/spec-hash.d.ts +1 -1
- package/dist/generated/spec-hash.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/manifest.d.ts +131 -3
- package/dist/manifest.js +68 -0
- package/dist/notifications.d.ts +29 -0
- package/dist/notifications.js +14 -0
- package/dist/ui.d.ts +110 -1
- package/dist/ui.js +113 -0
- package/dist/webhooks.d.ts +2 -2
- package/dist/webhooks.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,25 @@ renews it shortly before it expires. Use `apiKeyAuth(key)` for your own club's A
|
|
|
52
52
|
`unwrap()` returns `data` or throws a `CheckCourtApiError`. Requests are retried once
|
|
53
53
|
after a `401` with a fresh token and with backoff after a `429`.
|
|
54
54
|
|
|
55
|
+
### Notify a member
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { sendNotification } from "@checkcourt/sdk";
|
|
59
|
+
|
|
60
|
+
// Needs notifications:send. recipient: a psn_… pseudonym from an extension context,
|
|
61
|
+
// the pairwise usr_… id from getMe(), or the user id if you hold members:read.
|
|
62
|
+
const { id } = await sendNotification(client, {
|
|
63
|
+
recipient: claims.viewer.user_id!,
|
|
64
|
+
title: "Deine Ballmaschine ist bereit",
|
|
65
|
+
body: "Platz 3 ab 17:30 Uhr.",
|
|
66
|
+
url: "/booking?date=2026-05-01",
|
|
67
|
+
idempotencyKey: "reservation-8812-ready",
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
CheckCourt delivers the message in the member's inbox and by email if they allow it. Your
|
|
72
|
+
app never learns contact data or whether the member muted it.
|
|
73
|
+
|
|
55
74
|
### Verify a webhook
|
|
56
75
|
|
|
57
76
|
```ts
|
|
@@ -74,6 +93,28 @@ export async function POST(request: Request) {
|
|
|
74
93
|
`verifyWebhook` throws a `WebhookSignatureError` when the signature or timestamp does not
|
|
75
94
|
check out. Deduplicate on `event.id`: retries carry the same id.
|
|
76
95
|
|
|
96
|
+
### Share data and events with other apps (club apps)
|
|
97
|
+
|
|
98
|
+
Apps never call each other. They declare in the manifest what they share (`shares`, `emits`) and
|
|
99
|
+
what they want from other apps (`reads`, `subscribes`); CheckCourt passes the data on once the club
|
|
100
|
+
approves the connection.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { getObjectMetadata, isAppEvent, publishAppEvent, putObjectMetadata } from "@checkcourt/sdk";
|
|
104
|
+
|
|
105
|
+
// Producer: attach a value to a booking (key declared under shares.metadata).
|
|
106
|
+
await putObjectMetadata(client, "booking", bookingId, "video_url", "https://video.example/abc");
|
|
107
|
+
// Producer: publish an event declared under emits.
|
|
108
|
+
await publishAppEvent(client, { name: "door_opened", data: { court_id: 3 } });
|
|
109
|
+
|
|
110
|
+
// Consumer: read your own and approved values of other apps, grouped by app slug.
|
|
111
|
+
const { metadata } = await getObjectMetadata(client, "booking", bookingId);
|
|
112
|
+
const videoUrl = metadata["wingfield"]?.["video_url"]?.value;
|
|
113
|
+
|
|
114
|
+
// Consumer webhook: events of other apps arrive as `app.<slug>.<name>`.
|
|
115
|
+
if (isAppEvent<{ court_id: number }>(event, "door-co", "door_opened")) console.log(event.data.court_id);
|
|
116
|
+
```
|
|
117
|
+
|
|
77
118
|
### Declarative extensions and the `ui` builder
|
|
78
119
|
|
|
79
120
|
```ts
|
|
@@ -126,10 +167,66 @@ return Response.json(ui.doc([ui.stat("Battery", `${level} %`)], { maxAge: 0 }));
|
|
|
126
167
|
A `Cache-Control` response header (`no-store`, `max-age=N`) works too; `maxAge` wins when
|
|
127
168
|
both are set. In your sandbox club nothing is cached.
|
|
128
169
|
|
|
170
|
+
Forms collect input for an action. A text field becomes a multi-line box with `multiline`
|
|
171
|
+
(and an optional `rows`, 1 to 12); the submitted value stays a string:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
ui.form({
|
|
175
|
+
actionId: "report",
|
|
176
|
+
submitLabel: "Meldung senden",
|
|
177
|
+
fields: [ui.field.text("message", "Platzproblem melden", { multiline: true, rows: 4, max_length: 500 })],
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`ui.field.date` and `ui.field.time` render native date and time pickers. `default`, `min` and
|
|
182
|
+
`max` are `YYYY-MM-DD` strings for dates and `HH:MM` (24-hour) strings for times; `time` takes an
|
|
183
|
+
optional `step` in minutes. Both submit their value as a string:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
ui.form({
|
|
187
|
+
actionId: "block",
|
|
188
|
+
submitLabel: "Sperren",
|
|
189
|
+
fields: [
|
|
190
|
+
ui.field.date("day", "Tag", { min: "2026-01-01", required: true }),
|
|
191
|
+
ui.field.time("from", "Von", { min: "07:00", max: "22:00", step: 30, required: true }),
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
129
196
|
Compare `context.installation_id` and `context.tenant_id` with what you stored from
|
|
130
197
|
`app.installed` before you act on a request. For the context token alone (for example in
|
|
131
198
|
the backend of an iframe extension), use `verifyExtensionContext(token, secret)`.
|
|
132
199
|
|
|
200
|
+
### Host surfaces
|
|
201
|
+
|
|
202
|
+
Some points are drawn by CheckCourt itself and only ask your app for a few words. Answer them
|
|
203
|
+
with the matching builder; each throws when the answer would break CheckCourt's limits.
|
|
204
|
+
|
|
205
|
+
| Point | Request carries | Answer with |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| `court.annotation` | `ext.date`, `ext.courts` | `ui.annotations([{ court_id, label, variant? }])`, label up to 24 characters, one per court |
|
|
208
|
+
| `member.list.column` | `ext.members` | `ui.column({ title, values: [{ member_id, text, variant? }] })`, title up to 20, text up to 24 characters |
|
|
209
|
+
| `booking.hint` | `ext.draft` | `ui.hint([...])` with up to 6 text, badge, key_value or link blocks; answer within 1 second |
|
|
210
|
+
| `booking_plan.action` | `ext.date`, `ext.courts` (and the day as subject) | `ui.doc([...])`, shown in a dialog after a click; `ui.hidden()` closes it |
|
|
211
|
+
| `sidebar.action` | nothing extra | `ui.doc([...])`, shown in a dialog after a click; `ui.hidden()` closes it |
|
|
212
|
+
| `member.settings.section` | nothing extra | `ui.doc([...])`, a card on the member's own settings page |
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
if (ext.kind === "render" && ext.point === "court.annotation" && ext.date && ext.courts) {
|
|
216
|
+
const wet = await wetCourts(ext.date);
|
|
217
|
+
return Response.json(
|
|
218
|
+
ui.annotations(
|
|
219
|
+
ext.courts.filter((c) => wet.has(c.id)).map((c) => ({ court_id: c.id, label: "Nass", variant: "secondary" })),
|
|
220
|
+
{ maxAge: 300 },
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`booking_plan.action` and `sidebar.action` need a `label` (at most 24 characters) in the
|
|
227
|
+
manifest and may set an `icon` from `APP_ACTION_ICONS`. In every declarative document,
|
|
228
|
+
CheckCourt places content first, then the buttons, then the links of each level.
|
|
229
|
+
|
|
133
230
|
### OAuth with PKCE (member apps)
|
|
134
231
|
|
|
135
232
|
```ts
|
|
@@ -183,7 +280,7 @@ Import the browser entry point `@checkcourt/sdk/iframe` only; it needs no secret
|
|
|
183
280
|
| `@checkcourt/sdk` | Server | Everything except the iframe part |
|
|
184
281
|
| `@checkcourt/sdk/oauth` | Server | OAuth and installation tokens |
|
|
185
282
|
| `@checkcourt/sdk/webhooks` | Server, edge | Webhook verification and event types |
|
|
186
|
-
| `@checkcourt/sdk/extensions` | Server, edge | Context tokens, request verification, UI builder |
|
|
283
|
+
| `@checkcourt/sdk/extensions` | Server, edge | Context tokens, request verification, UI builder, host surface builders |
|
|
187
284
|
| `@checkcourt/sdk/manifest` | Anywhere | `defineManifest` and constants |
|
|
188
285
|
| `@checkcourt/sdk/iframe` | Browser | `connectExtensionFrame` and messages |
|
|
189
286
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type CheckCourtClient } from "./client.js";
|
|
2
|
+
import type { components } from "./generated/schema.js";
|
|
3
|
+
import type { SharedObjectType } from "./manifest.js";
|
|
4
|
+
export type ObjectMetadata = components["schemas"]["ObjectMetadata"];
|
|
5
|
+
export type ObjectMetadataEntry = components["schemas"]["ObjectMetadataEntry"];
|
|
6
|
+
export type ObjectMetadataWritten = components["schemas"]["ObjectMetadataWritten"];
|
|
7
|
+
export type PublishAppEventRequest = components["schemas"]["PublishAppEventRequest"];
|
|
8
|
+
export type PublishedAppEvent = components["schemas"]["PublishedAppEvent"];
|
|
9
|
+
/**
|
|
10
|
+
* `GET /api/v1/{bookings|courts|members}/{id}/metadata`: your app's own values on the object plus
|
|
11
|
+
* those of other apps you declare under `reads.metadata` and the club connected to yours, grouped
|
|
12
|
+
* by app slug. Club installation tokens (`cca_`) only.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getObjectMetadata(client: CheckCourtClient, objectType: SharedObjectType, id: string | number): Promise<ObjectMetadata>;
|
|
15
|
+
/**
|
|
16
|
+
* `PUT …/{id}/metadata/{key}`: writes one of your keys declared under `shares.metadata`. Any JSON
|
|
17
|
+
* value except `null`, at most 4096 bytes serialized. Connected readers get `app.metadata_changed`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function putObjectMetadata(client: CheckCourtClient, objectType: SharedObjectType, id: string | number, key: string, value: unknown): Promise<ObjectMetadataWritten>;
|
|
20
|
+
/** `DELETE …/{id}/metadata/{key}`: removes your own value; `deleted: false` when there was none. */
|
|
21
|
+
export declare function deleteObjectMetadata(client: CheckCourtClient, objectType: SharedObjectType, id: string | number, key: string): Promise<{
|
|
22
|
+
deleted: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
/**
|
|
25
|
+
* `POST /api/v1/app/events`: publishes an event declared under `emits`. Subscribed apps the club
|
|
26
|
+
* connected to yours receive it as `app.<your slug>.<name>`. At most 60 per minute per installation.
|
|
27
|
+
*/
|
|
28
|
+
export declare function publishAppEvent(client: CheckCourtClient, event: PublishAppEventRequest): Promise<PublishedAppEvent>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { unwrap } from "./client.js";
|
|
2
|
+
function courtId(id) {
|
|
3
|
+
const n = typeof id === "number" ? id : Number(id);
|
|
4
|
+
if (!Number.isSafeInteger(n))
|
|
5
|
+
throw new TypeError(`Court ids are integers, got ${String(id)}`);
|
|
6
|
+
return n;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* `GET /api/v1/{bookings|courts|members}/{id}/metadata`: your app's own values on the object plus
|
|
10
|
+
* those of other apps you declare under `reads.metadata` and the club connected to yours, grouped
|
|
11
|
+
* by app slug. Club installation tokens (`cca_`) only.
|
|
12
|
+
*/
|
|
13
|
+
export async function getObjectMetadata(client, objectType, id) {
|
|
14
|
+
switch (objectType) {
|
|
15
|
+
case "booking":
|
|
16
|
+
return unwrap(client.GET("/bookings/{id}/metadata", { params: { path: { id: String(id) } } }));
|
|
17
|
+
case "court":
|
|
18
|
+
return unwrap(client.GET("/courts/{id}/metadata", { params: { path: { id: courtId(id) } } }));
|
|
19
|
+
case "member":
|
|
20
|
+
return unwrap(client.GET("/members/{id}/metadata", { params: { path: { id: String(id) } } }));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `PUT …/{id}/metadata/{key}`: writes one of your keys declared under `shares.metadata`. Any JSON
|
|
25
|
+
* value except `null`, at most 4096 bytes serialized. Connected readers get `app.metadata_changed`.
|
|
26
|
+
*/
|
|
27
|
+
export async function putObjectMetadata(client, objectType, id, key, value) {
|
|
28
|
+
const body = { value };
|
|
29
|
+
switch (objectType) {
|
|
30
|
+
case "booking":
|
|
31
|
+
return unwrap(client.PUT("/bookings/{id}/metadata/{key}", { params: { path: { id: String(id), key } }, body }));
|
|
32
|
+
case "court":
|
|
33
|
+
return unwrap(client.PUT("/courts/{id}/metadata/{key}", { params: { path: { id: courtId(id), key } }, body }));
|
|
34
|
+
case "member":
|
|
35
|
+
return unwrap(client.PUT("/members/{id}/metadata/{key}", { params: { path: { id: String(id), key } }, body }));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** `DELETE …/{id}/metadata/{key}`: removes your own value; `deleted: false` when there was none. */
|
|
39
|
+
export async function deleteObjectMetadata(client, objectType, id, key) {
|
|
40
|
+
switch (objectType) {
|
|
41
|
+
case "booking":
|
|
42
|
+
return unwrap(client.DELETE("/bookings/{id}/metadata/{key}", { params: { path: { id: String(id), key } } }));
|
|
43
|
+
case "court":
|
|
44
|
+
return unwrap(client.DELETE("/courts/{id}/metadata/{key}", { params: { path: { id: courtId(id), key } } }));
|
|
45
|
+
case "member":
|
|
46
|
+
return unwrap(client.DELETE("/members/{id}/metadata/{key}", { params: { path: { id: String(id), key } } }));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `POST /api/v1/app/events`: publishes an event declared under `emits`. Subscribed apps the club
|
|
51
|
+
* connected to yours receive it as `app.<your slug>.<name>`. At most 60 per minute per installation.
|
|
52
|
+
*/
|
|
53
|
+
export async function publishAppEvent(client, event) {
|
|
54
|
+
return unwrap(client.POST("/app/events", { body: event }));
|
|
55
|
+
}
|
package/dist/events.d.ts
CHANGED
|
@@ -67,6 +67,46 @@ export interface WebhookEventOf<T extends EventType> {
|
|
|
67
67
|
};
|
|
68
68
|
data: EventDataMap[T];
|
|
69
69
|
}
|
|
70
|
+
/** `data` of `app.metadata_changed`: another app changed or removed a value your app reads. */
|
|
71
|
+
export interface AppMetadataChangedEventData {
|
|
72
|
+
object_type: "booking" | "court" | "member";
|
|
73
|
+
object_id: string;
|
|
74
|
+
/** Slug of the app that wrote the value. */
|
|
75
|
+
app: string;
|
|
76
|
+
key: string;
|
|
77
|
+
/** True when the value was removed. */
|
|
78
|
+
deleted: boolean;
|
|
79
|
+
}
|
|
80
|
+
export declare const METADATA_CHANGED_EVENT_TYPE = "app.metadata_changed";
|
|
81
|
+
/** Type of an event one app publishes to others: `app.<publishing app slug>.<name>`. */
|
|
82
|
+
export type AppEventType = `app.${string}.${string}`;
|
|
83
|
+
interface ConnectionEventEnvelope<T extends string, D> {
|
|
84
|
+
id: string;
|
|
85
|
+
type: T;
|
|
86
|
+
created_at: string;
|
|
87
|
+
tenant_id: string;
|
|
88
|
+
installation_id?: string;
|
|
89
|
+
/** The `subject` the publisher gave, otherwise its own installation (`app_installation`). */
|
|
90
|
+
object: {
|
|
91
|
+
type: EventObjectType | (string & {});
|
|
92
|
+
id: string;
|
|
93
|
+
};
|
|
94
|
+
data: D;
|
|
95
|
+
}
|
|
96
|
+
/** Sent to apps the club connected to the writer, for keys they declare under `reads.metadata`. Read the value with `getObjectMetadata`. */
|
|
97
|
+
export type AppMetadataChangedEvent = ConnectionEventEnvelope<typeof METADATA_CHANGED_EVENT_TYPE, AppMetadataChangedEventData>;
|
|
98
|
+
/** An event of another app, delivered when you declare it under `subscribes` and the club approved the connection. */
|
|
99
|
+
export type AppEvent<D extends Record<string, unknown> = Record<string, unknown>> = ConnectionEventEnvelope<AppEventType, D>;
|
|
100
|
+
/** Events between apps; CheckCourt delivers them only along connections the club approved. */
|
|
101
|
+
export type ConnectionEvent = AppMetadataChangedEvent | AppEvent;
|
|
70
102
|
export type WebhookEvent = {
|
|
71
103
|
[T in EventType]: WebhookEventOf<T>;
|
|
72
|
-
}[EventType];
|
|
104
|
+
}[EventType] | ConnectionEvent;
|
|
105
|
+
export declare function appEventType(appSlug: string, name: string): AppEventType;
|
|
106
|
+
/**
|
|
107
|
+
* Narrows to an event published by another app, optionally a specific one:
|
|
108
|
+
* `if (isAppEvent<DoorOpened>(event, "door-co", "door_opened")) event.data.court_id`.
|
|
109
|
+
*/
|
|
110
|
+
export declare function isAppEvent<D extends Record<string, unknown> = Record<string, unknown>>(event: WebhookEvent, appSlug?: string, name?: string): event is AppEvent<D>;
|
|
111
|
+
export declare function isMetadataChangedEvent(event: WebhookEvent): event is AppMetadataChangedEvent;
|
|
112
|
+
export {};
|
package/dist/events.js
CHANGED
|
@@ -22,3 +22,20 @@ export const EVENT_TYPES = [
|
|
|
22
22
|
"webhook.test",
|
|
23
23
|
...APP_LIFECYCLE_EVENT_TYPES,
|
|
24
24
|
];
|
|
25
|
+
export const METADATA_CHANGED_EVENT_TYPE = "app.metadata_changed";
|
|
26
|
+
export function appEventType(appSlug, name) {
|
|
27
|
+
return `app.${appSlug}.${name}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Narrows to an event published by another app, optionally a specific one:
|
|
31
|
+
* `if (isAppEvent<DoorOpened>(event, "door-co", "door_opened")) event.data.court_id`.
|
|
32
|
+
*/
|
|
33
|
+
export function isAppEvent(event, appSlug, name) {
|
|
34
|
+
const match = /^app\.([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.([a-z][a-z0-9_]*)$/.exec(event.type);
|
|
35
|
+
if (!match)
|
|
36
|
+
return false;
|
|
37
|
+
return (appSlug === undefined || match[1] === appSlug) && (name === undefined || match[2] === name);
|
|
38
|
+
}
|
|
39
|
+
export function isMetadataChangedEvent(event) {
|
|
40
|
+
return event.type === METADATA_CHANGED_EVENT_TYPE;
|
|
41
|
+
}
|
package/dist/extensions.d.ts
CHANGED
|
@@ -3,14 +3,15 @@ import type { UiFormValues } from "./ui.js";
|
|
|
3
3
|
import { type RawBody } from "./webhooks.js";
|
|
4
4
|
export * from "./ui.js";
|
|
5
5
|
export { ExtensionVerificationError, type ExtensionVerificationFailure } from "./errors.js";
|
|
6
|
-
export { EXTENSION_POINTS, type ExtensionKind, type ExtensionPoint } from "./manifest.js";
|
|
6
|
+
export { EXTENSION_POINTS, STATIC_ACTION_POINTS, type ExtensionKind, type ExtensionPoint, type StaticActionPoint, } from "./manifest.js";
|
|
7
7
|
export declare const CONTEXT_HEADER = "CheckCourt-Context";
|
|
8
8
|
export declare const CONTEXT_ISSUER = "checkcourt";
|
|
9
9
|
export declare const CONTEXT_TTL_SECONDS = 300;
|
|
10
10
|
export declare const CLOCK_LEEWAY_SECONDS = 30;
|
|
11
11
|
/** `action_id` a `booking.action` button sends when it is clicked. */
|
|
12
12
|
export declare const BOOKING_ACTION_INVOKE = "invoke";
|
|
13
|
-
|
|
13
|
+
/** "booking_plan": the plan of one day; its id is the date (YYYY-MM-DD). */
|
|
14
|
+
export type ExtensionSubjectType = "booking" | "member" | "installation" | "booking_plan";
|
|
14
15
|
export interface ExtensionSubject {
|
|
15
16
|
type: ExtensionSubjectType;
|
|
16
17
|
id: string;
|
|
@@ -33,6 +34,12 @@ export interface DashboardCapabilities {
|
|
|
33
34
|
export interface AppSettingsCapabilities {
|
|
34
35
|
can_manage_app: boolean;
|
|
35
36
|
}
|
|
37
|
+
export interface MemberListCapabilities {
|
|
38
|
+
can_edit_members: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface BookingPlanCapabilities {
|
|
41
|
+
can_edit_bookings: boolean;
|
|
42
|
+
}
|
|
36
43
|
export interface PointCapabilities {
|
|
37
44
|
"app.settings": AppSettingsCapabilities;
|
|
38
45
|
"booking.detail.panel": BookingCapabilities;
|
|
@@ -40,6 +47,12 @@ export interface PointCapabilities {
|
|
|
40
47
|
"member.profile.section": MemberCapabilities;
|
|
41
48
|
"dashboard.widget": DashboardCapabilities;
|
|
42
49
|
"kiosk.tile": Record<string, never>;
|
|
50
|
+
"court.annotation": Record<string, never>;
|
|
51
|
+
"member.list.column": MemberListCapabilities;
|
|
52
|
+
"member.settings.section": Record<string, never>;
|
|
53
|
+
"booking.hint": Record<string, never>;
|
|
54
|
+
"booking_plan.action": BookingPlanCapabilities;
|
|
55
|
+
"sidebar.action": Record<string, never>;
|
|
43
56
|
}
|
|
44
57
|
export interface PointSubject {
|
|
45
58
|
"app.settings": {
|
|
@@ -60,6 +73,16 @@ export interface PointSubject {
|
|
|
60
73
|
};
|
|
61
74
|
"dashboard.widget": null;
|
|
62
75
|
"kiosk.tile": null;
|
|
76
|
+
"court.annotation": null;
|
|
77
|
+
"member.list.column": null;
|
|
78
|
+
"member.settings.section": null;
|
|
79
|
+
"booking.hint": null;
|
|
80
|
+
/** `id` is the plan's day, YYYY-MM-DD. */
|
|
81
|
+
"booking_plan.action": {
|
|
82
|
+
type: "booking_plan";
|
|
83
|
+
id: string;
|
|
84
|
+
};
|
|
85
|
+
"sidebar.action": null;
|
|
63
86
|
}
|
|
64
87
|
interface ContextClaimsOf<P extends ExtensionPoint> {
|
|
65
88
|
iss: typeof CONTEXT_ISSUER;
|
|
@@ -92,12 +115,44 @@ export type ExtensionContextClaims = {
|
|
|
92
115
|
export declare function verifyExtensionContext(token: string, secret: string, options?: {
|
|
93
116
|
now?: Date | number;
|
|
94
117
|
}): Promise<ExtensionContextClaims>;
|
|
118
|
+
/** A court of the plan a `court.annotation` or `booking_plan.action` request covers. */
|
|
119
|
+
export interface AnnotationCourt {
|
|
120
|
+
id: number;
|
|
121
|
+
name: string;
|
|
122
|
+
}
|
|
123
|
+
/** A member on the visible page of the member list. */
|
|
124
|
+
export interface ColumnMember {
|
|
125
|
+
/** The club membership id, as `member_id` in `member.*` events; key your column values by it. */
|
|
126
|
+
member_id: string;
|
|
127
|
+
user_id: string;
|
|
128
|
+
}
|
|
129
|
+
export declare const BOOKING_DRAFT_TYPES: readonly ["regular", "training", "mannschaft"];
|
|
130
|
+
export type BookingDraftType = (typeof BOOKING_DRAFT_TYPES)[number];
|
|
131
|
+
/** The booking a member is about to confirm, sent with `booking.hint`. */
|
|
132
|
+
export interface BookingDraft {
|
|
133
|
+
court_id: number;
|
|
134
|
+
/** YYYY-MM-DD. */
|
|
135
|
+
date: string;
|
|
136
|
+
/** HH:MM. */
|
|
137
|
+
start_time: string;
|
|
138
|
+
/** HH:MM. */
|
|
139
|
+
end_time: string;
|
|
140
|
+
type: BookingDraftType;
|
|
141
|
+
}
|
|
95
142
|
/** Body of a declarative render request. */
|
|
96
143
|
export interface ExtensionRenderRequest {
|
|
97
144
|
kind: "render";
|
|
98
145
|
context: ExtensionContextClaims;
|
|
99
146
|
point: ExtensionPoint;
|
|
100
147
|
subject: ExtensionSubject | null;
|
|
148
|
+
/** `court.annotation` and `booking_plan.action`: the plan's day, YYYY-MM-DD. */
|
|
149
|
+
date?: string;
|
|
150
|
+
/** `court.annotation` (one document for all) and `booking_plan.action`: every court of the plan. */
|
|
151
|
+
courts?: AnnotationCourt[];
|
|
152
|
+
/** `member.list.column`: the members on the visible page. */
|
|
153
|
+
members?: ColumnMember[];
|
|
154
|
+
/** `booking.hint`: the booking being drafted. */
|
|
155
|
+
draft?: BookingDraft;
|
|
101
156
|
}
|
|
102
157
|
/** Body of a button click or form submission. `values` is `{}` for buttons. */
|
|
103
158
|
export interface ExtensionActionRequest {
|
|
@@ -107,13 +162,19 @@ export interface ExtensionActionRequest {
|
|
|
107
162
|
subject: ExtensionSubject | null;
|
|
108
163
|
actionId: string;
|
|
109
164
|
values: UiFormValues;
|
|
165
|
+
/** `booking_plan.action`: the plan's day, YYYY-MM-DD. */
|
|
166
|
+
date?: string;
|
|
167
|
+
/** `booking_plan.action`: every court of the plan, so a form can offer a court select. */
|
|
168
|
+
courts?: AnnotationCourt[];
|
|
110
169
|
}
|
|
111
170
|
export type ExtensionRequest = ExtensionRenderRequest | ExtensionActionRequest;
|
|
112
171
|
type HeaderSource = Headers | Record<string, string | string[] | undefined>;
|
|
113
172
|
/**
|
|
114
173
|
* Verifies a declarative extension POST: the `CheckCourt-Signature` over the raw body (it binds
|
|
115
174
|
* `action_id` and `values` to the token), the context token, and that body, header token and
|
|
116
|
-
* claims agree.
|
|
175
|
+
* claims agree. Renders at `court.annotation`, `member.list.column` and `booking.hint` also
|
|
176
|
+
* carry `date` and `courts`, `members` or `draft`; `booking_plan.action` renders and actions
|
|
177
|
+
* carry the plan's `date` and `courts`. Throws `ExtensionVerificationError`.
|
|
117
178
|
*/
|
|
118
179
|
export declare function verifyExtensionRequest(options: {
|
|
119
180
|
secret: string;
|
package/dist/extensions.js
CHANGED
|
@@ -4,7 +4,7 @@ import { hmacSha256Verify } from "./internal/hmac.js";
|
|
|
4
4
|
import { SIGNATURE_HEADER, verifySignature } from "./webhooks.js";
|
|
5
5
|
export * from "./ui.js";
|
|
6
6
|
export { ExtensionVerificationError } from "./errors.js";
|
|
7
|
-
export { EXTENSION_POINTS } from "./manifest.js";
|
|
7
|
+
export { EXTENSION_POINTS, STATIC_ACTION_POINTS, } from "./manifest.js";
|
|
8
8
|
export const CONTEXT_HEADER = "CheckCourt-Context";
|
|
9
9
|
export const CONTEXT_ISSUER = "checkcourt";
|
|
10
10
|
export const CONTEXT_TTL_SECONDS = 300;
|
|
@@ -65,6 +65,7 @@ export async function verifyExtensionContext(token, secret, options = {}) {
|
|
|
65
65
|
throw fail("expired", "Token has expired");
|
|
66
66
|
return claims;
|
|
67
67
|
}
|
|
68
|
+
export const BOOKING_DRAFT_TYPES = ["regular", "training", "mannschaft"];
|
|
68
69
|
function header(headers, name) {
|
|
69
70
|
if (typeof headers.get === "function")
|
|
70
71
|
return headers.get(name) ?? undefined;
|
|
@@ -80,10 +81,65 @@ function sameSubject(a, b) {
|
|
|
80
81
|
return a === b;
|
|
81
82
|
return a.type === b.type && a.id === b.id;
|
|
82
83
|
}
|
|
84
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
85
|
+
const TIME = /^\d{2}:\d{2}$/;
|
|
86
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
87
|
+
/** Parses the `date` and `courts` a court.annotation or booking_plan.action request carries. */
|
|
88
|
+
function planCourts(body) {
|
|
89
|
+
const { date, courts } = body;
|
|
90
|
+
if (typeof date !== "string" || !DATE.test(date))
|
|
91
|
+
throw fail("invalid_body", "date is not YYYY-MM-DD");
|
|
92
|
+
if (!Array.isArray(courts) || !courts.every((c) => isObject(c) && typeof c.id === "number" && typeof c.name === "string")) {
|
|
93
|
+
throw fail("invalid_body", "courts is not a list of { id, name }");
|
|
94
|
+
}
|
|
95
|
+
return { date, courts: courts.map((c) => ({ id: c.id, name: c.name })) };
|
|
96
|
+
}
|
|
97
|
+
function surfaceFields(point, body) {
|
|
98
|
+
switch (point) {
|
|
99
|
+
case "court.annotation":
|
|
100
|
+
case "booking_plan.action":
|
|
101
|
+
return planCourts(body);
|
|
102
|
+
case "member.list.column": {
|
|
103
|
+
const { members } = body;
|
|
104
|
+
if (!Array.isArray(members) ||
|
|
105
|
+
!members.every((m) => isObject(m) && typeof m.member_id === "string" && typeof m.user_id === "string")) {
|
|
106
|
+
throw fail("invalid_body", "members is not a list of { member_id, user_id }");
|
|
107
|
+
}
|
|
108
|
+
return { members: members.map((m) => ({ member_id: m.member_id, user_id: m.user_id })) };
|
|
109
|
+
}
|
|
110
|
+
case "booking.hint": {
|
|
111
|
+
const d = body.draft;
|
|
112
|
+
if (!isObject(d) ||
|
|
113
|
+
typeof d.court_id !== "number" ||
|
|
114
|
+
typeof d.date !== "string" ||
|
|
115
|
+
!DATE.test(d.date) ||
|
|
116
|
+
typeof d.start_time !== "string" ||
|
|
117
|
+
!TIME.test(d.start_time) ||
|
|
118
|
+
typeof d.end_time !== "string" ||
|
|
119
|
+
!TIME.test(d.end_time) ||
|
|
120
|
+
typeof d.type !== "string") {
|
|
121
|
+
throw fail("invalid_body", "draft is not a booking draft");
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
draft: {
|
|
125
|
+
court_id: d.court_id,
|
|
126
|
+
date: d.date,
|
|
127
|
+
start_time: d.start_time,
|
|
128
|
+
end_time: d.end_time,
|
|
129
|
+
type: d.type,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
default:
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
83
137
|
/**
|
|
84
138
|
* Verifies a declarative extension POST: the `CheckCourt-Signature` over the raw body (it binds
|
|
85
139
|
* `action_id` and `values` to the token), the context token, and that body, header token and
|
|
86
|
-
* claims agree.
|
|
140
|
+
* claims agree. Renders at `court.annotation`, `member.list.column` and `booking.hint` also
|
|
141
|
+
* carry `date` and `courts`, `members` or `draft`; `booking_plan.action` renders and actions
|
|
142
|
+
* carry the plan's `date` and `courts`. Throws `ExtensionVerificationError`.
|
|
87
143
|
*/
|
|
88
144
|
export async function verifyExtensionRequest(options) {
|
|
89
145
|
try {
|
|
@@ -121,7 +177,7 @@ export async function verifyExtensionRequest(options) {
|
|
|
121
177
|
throw fail("context_mismatch", "point or subject differ from the context token");
|
|
122
178
|
}
|
|
123
179
|
if (body.action_id === undefined)
|
|
124
|
-
return { kind: "render", context, point: context.point, subject };
|
|
180
|
+
return { kind: "render", context, point: context.point, subject, ...surfaceFields(context.point, body) };
|
|
125
181
|
if (typeof body.action_id !== "string")
|
|
126
182
|
throw fail("invalid_body", "action_id is not a string");
|
|
127
183
|
const values = body.values ?? {};
|
|
@@ -134,5 +190,6 @@ export async function verifyExtensionRequest(options) {
|
|
|
134
190
|
subject,
|
|
135
191
|
actionId: body.action_id,
|
|
136
192
|
values: values,
|
|
193
|
+
...(context.point === "booking_plan.action" ? planCourts(body) : {}),
|
|
137
194
|
};
|
|
138
195
|
}
|