@checkcourt/sdk 0.3.1 → 0.5.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 +106 -11
- 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 +59 -3
- package/dist/extensions.js +54 -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 +101 -7
- package/dist/ui.js +119 -2
- package/dist/webhooks.d.ts +2 -2
- package/dist/webhooks.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,18 +21,11 @@ Cloudflare Workers, Vercel Edge or Deno. Its only runtime dependency is `openapi
|
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
24
|
-
The package is not on npm yet. Until it is, install a tagged release straight from GitHub:
|
|
25
|
-
|
|
26
24
|
```bash
|
|
27
|
-
npm install
|
|
25
|
+
npm install @checkcourt/sdk
|
|
28
26
|
```
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
`@checkcourt/sdk`:
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
npm install @checkcourt/sdk # coming soon
|
|
35
|
-
```
|
|
28
|
+
Every release is also tagged on [GitHub](https://github.com/CheckCourt/sdk/releases).
|
|
36
29
|
|
|
37
30
|
## Quick start
|
|
38
31
|
|
|
@@ -59,6 +52,25 @@ renews it shortly before it expires. Use `apiKeyAuth(key)` for your own club's A
|
|
|
59
52
|
`unwrap()` returns `data` or throws a `CheckCourtApiError`. Requests are retried once
|
|
60
53
|
after a `401` with a fresh token and with backoff after a `429`.
|
|
61
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
|
+
|
|
62
74
|
### Verify a webhook
|
|
63
75
|
|
|
64
76
|
```ts
|
|
@@ -81,6 +93,28 @@ export async function POST(request: Request) {
|
|
|
81
93
|
`verifyWebhook` throws a `WebhookSignatureError` when the signature or timestamp does not
|
|
82
94
|
check out. Deduplicate on `event.id`: retries carry the same id.
|
|
83
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
|
+
|
|
84
118
|
### Declarative extensions and the `ui` builder
|
|
85
119
|
|
|
86
120
|
```ts
|
|
@@ -122,10 +156,61 @@ export async function POST(request: Request) {
|
|
|
122
156
|
}
|
|
123
157
|
```
|
|
124
158
|
|
|
159
|
+
CheckCourt keeps a successful render for 30 seconds. Pass `maxAge` (seconds, at most 300,
|
|
160
|
+
sent as `cache.max_age`) to change that for one answer, or `0` when the panel must always
|
|
161
|
+
be fresh:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
return Response.json(ui.doc([ui.stat("Battery", `${level} %`)], { maxAge: 0 }));
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
A `Cache-Control` response header (`no-store`, `max-age=N`) works too; `maxAge` wins when
|
|
168
|
+
both are set. In your sandbox club nothing is cached.
|
|
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
|
+
|
|
125
181
|
Compare `context.installation_id` and `context.tenant_id` with what you stored from
|
|
126
182
|
`app.installed` before you act on a request. For the context token alone (for example in
|
|
127
183
|
the backend of an iframe extension), use `verifyExtensionContext(token, secret)`.
|
|
128
184
|
|
|
185
|
+
### Host surfaces
|
|
186
|
+
|
|
187
|
+
Some points are drawn by CheckCourt itself and only ask your app for a few words. Answer them
|
|
188
|
+
with the matching builder; each throws when the answer would break CheckCourt's limits.
|
|
189
|
+
|
|
190
|
+
| Point | Request carries | Answer with |
|
|
191
|
+
|---|---|---|
|
|
192
|
+
| `court.annotation` | `ext.date`, `ext.courts` | `ui.annotations([{ court_id, label, variant? }])`, label up to 24 characters, one per court |
|
|
193
|
+
| `member.list.column` | `ext.members` | `ui.column({ title, values: [{ member_id, text, variant? }] })`, title up to 20, text up to 24 characters |
|
|
194
|
+
| `booking.hint` | `ext.draft` | `ui.hint([...])` with up to 6 text, badge, key_value or link blocks; answer within 1 second |
|
|
195
|
+
| `booking_plan.action`, `sidebar.action` | `booking_plan.action`: the day as subject | `ui.doc([...])`, shown in a dialog after a click; `ui.hidden()` closes it |
|
|
196
|
+
| `member.settings.section` | nothing extra | `ui.doc([...])`, a card on the member's own settings page |
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
if (ext.kind === "render" && ext.point === "court.annotation" && ext.date && ext.courts) {
|
|
200
|
+
const wet = await wetCourts(ext.date);
|
|
201
|
+
return Response.json(
|
|
202
|
+
ui.annotations(
|
|
203
|
+
ext.courts.filter((c) => wet.has(c.id)).map((c) => ({ court_id: c.id, label: "Nass", variant: "secondary" })),
|
|
204
|
+
{ maxAge: 300 },
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`booking_plan.action` and `sidebar.action` need a `label` (at most 24 characters) in the
|
|
211
|
+
manifest and may set an `icon` from `APP_ACTION_ICONS`. In every declarative document,
|
|
212
|
+
CheckCourt places content first, then the buttons, then the links of each level.
|
|
213
|
+
|
|
129
214
|
### OAuth with PKCE (member apps)
|
|
130
215
|
|
|
131
216
|
```ts
|
|
@@ -179,7 +264,7 @@ Import the browser entry point `@checkcourt/sdk/iframe` only; it needs no secret
|
|
|
179
264
|
| `@checkcourt/sdk` | Server | Everything except the iframe part |
|
|
180
265
|
| `@checkcourt/sdk/oauth` | Server | OAuth and installation tokens |
|
|
181
266
|
| `@checkcourt/sdk/webhooks` | Server, edge | Webhook verification and event types |
|
|
182
|
-
| `@checkcourt/sdk/extensions` | Server, edge | Context tokens, request verification, UI builder |
|
|
267
|
+
| `@checkcourt/sdk/extensions` | Server, edge | Context tokens, request verification, UI builder, host surface builders |
|
|
183
268
|
| `@checkcourt/sdk/manifest` | Anywhere | `defineManifest` and constants |
|
|
184
269
|
| `@checkcourt/sdk/iframe` | Browser | `connectExtensionFrame` and messages |
|
|
185
270
|
|
|
@@ -192,7 +277,7 @@ Full guides and the API reference: <https://docs.checkcourt.de/docs/developer/sd
|
|
|
192
277
|
## Versioning
|
|
193
278
|
|
|
194
279
|
The SDK follows semantic versioning but is still in `0.x`: minor releases may contain
|
|
195
|
-
breaking changes until 1.0. Pin a
|
|
280
|
+
breaking changes until 1.0. Pin a version and read the [changelog](CHANGELOG.md) before you
|
|
196
281
|
upgrade.
|
|
197
282
|
|
|
198
283
|
The exported constant `OPENAPI_SPEC_SHA256` is the SHA-256 of the spec the bundled types
|
|
@@ -226,6 +311,16 @@ CHECKCOURT_OPENAPI=./openapi.json npm run generate # a local file or another UR
|
|
|
226
311
|
The generated types follow the platform. A release may ship types for endpoints that are
|
|
227
312
|
about to be deployed, so do not regenerate them in an unrelated pull request.
|
|
228
313
|
|
|
314
|
+
### Releasing
|
|
315
|
+
|
|
316
|
+
1. Bump `version` in `package.json` (`npm version <x.y.z> --no-git-tag-version`).
|
|
317
|
+
2. Add the release to `CHANGELOG.md`.
|
|
318
|
+
3. Run `npm run build` and commit, including the rebuilt `dist/`.
|
|
319
|
+
4. Tag the commit `vX.Y.Z` and push the tag (`git push origin vX.Y.Z`).
|
|
320
|
+
|
|
321
|
+
The release workflow checks that the tag matches the package version and that `dist/` is
|
|
322
|
+
up to date, then publishes to npm with provenance via trusted publishing.
|
|
323
|
+
|
|
229
324
|
## License
|
|
230
325
|
|
|
231
326
|
[MIT](LICENSE)
|
|
@@ -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` 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`: the plan's day, YYYY-MM-DD. */
|
|
149
|
+
date?: string;
|
|
150
|
+
/** `court.annotation`: every court of the plan, answered in one document. */
|
|
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 {
|
|
@@ -113,7 +168,8 @@ type HeaderSource = Headers | Record<string, string | string[] | undefined>;
|
|
|
113
168
|
/**
|
|
114
169
|
* Verifies a declarative extension POST: the `CheckCourt-Signature` over the raw body (it binds
|
|
115
170
|
* `action_id` and `values` to the token), the context token, and that body, header token and
|
|
116
|
-
* claims agree.
|
|
171
|
+
* claims agree. Renders at `court.annotation`, `member.list.column` and `booking.hint` also
|
|
172
|
+
* carry `date` and `courts`, `members` or `draft`. Throws `ExtensionVerificationError`.
|
|
117
173
|
*/
|
|
118
174
|
export declare function verifyExtensionRequest(options: {
|
|
119
175
|
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,60 @@ 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
|
+
function surfaceFields(point, body) {
|
|
88
|
+
switch (point) {
|
|
89
|
+
case "court.annotation": {
|
|
90
|
+
const { date, courts } = body;
|
|
91
|
+
if (typeof date !== "string" || !DATE.test(date))
|
|
92
|
+
throw fail("invalid_body", "date is not YYYY-MM-DD");
|
|
93
|
+
if (!Array.isArray(courts) || !courts.every((c) => isObject(c) && typeof c.id === "number" && typeof c.name === "string")) {
|
|
94
|
+
throw fail("invalid_body", "courts is not a list of { id, name }");
|
|
95
|
+
}
|
|
96
|
+
return { date, courts: courts.map((c) => ({ id: c.id, name: c.name })) };
|
|
97
|
+
}
|
|
98
|
+
case "member.list.column": {
|
|
99
|
+
const { members } = body;
|
|
100
|
+
if (!Array.isArray(members) ||
|
|
101
|
+
!members.every((m) => isObject(m) && typeof m.member_id === "string" && typeof m.user_id === "string")) {
|
|
102
|
+
throw fail("invalid_body", "members is not a list of { member_id, user_id }");
|
|
103
|
+
}
|
|
104
|
+
return { members: members.map((m) => ({ member_id: m.member_id, user_id: m.user_id })) };
|
|
105
|
+
}
|
|
106
|
+
case "booking.hint": {
|
|
107
|
+
const d = body.draft;
|
|
108
|
+
if (!isObject(d) ||
|
|
109
|
+
typeof d.court_id !== "number" ||
|
|
110
|
+
typeof d.date !== "string" ||
|
|
111
|
+
!DATE.test(d.date) ||
|
|
112
|
+
typeof d.start_time !== "string" ||
|
|
113
|
+
!TIME.test(d.start_time) ||
|
|
114
|
+
typeof d.end_time !== "string" ||
|
|
115
|
+
!TIME.test(d.end_time) ||
|
|
116
|
+
typeof d.type !== "string") {
|
|
117
|
+
throw fail("invalid_body", "draft is not a booking draft");
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
draft: {
|
|
121
|
+
court_id: d.court_id,
|
|
122
|
+
date: d.date,
|
|
123
|
+
start_time: d.start_time,
|
|
124
|
+
end_time: d.end_time,
|
|
125
|
+
type: d.type,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
default:
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
83
133
|
/**
|
|
84
134
|
* Verifies a declarative extension POST: the `CheckCourt-Signature` over the raw body (it binds
|
|
85
135
|
* `action_id` and `values` to the token), the context token, and that body, header token and
|
|
86
|
-
* claims agree.
|
|
136
|
+
* claims agree. Renders at `court.annotation`, `member.list.column` and `booking.hint` also
|
|
137
|
+
* carry `date` and `courts`, `members` or `draft`. Throws `ExtensionVerificationError`.
|
|
87
138
|
*/
|
|
88
139
|
export async function verifyExtensionRequest(options) {
|
|
89
140
|
try {
|
|
@@ -121,7 +172,7 @@ export async function verifyExtensionRequest(options) {
|
|
|
121
172
|
throw fail("context_mismatch", "point or subject differ from the context token");
|
|
122
173
|
}
|
|
123
174
|
if (body.action_id === undefined)
|
|
124
|
-
return { kind: "render", context, point: context.point, subject };
|
|
175
|
+
return { kind: "render", context, point: context.point, subject, ...surfaceFields(context.point, body) };
|
|
125
176
|
if (typeof body.action_id !== "string")
|
|
126
177
|
throw fail("invalid_body", "action_id is not a string");
|
|
127
178
|
const values = body.values ?? {};
|