@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 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 github:CheckCourt/sdk#v0.3.1
25
+ npm install @checkcourt/sdk
28
26
  ```
29
27
 
30
- npm builds the package on install. Once it is published, the package name will be
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 tag and read the [changelog](CHANGELOG.md) before you
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
+ }
@@ -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
- export type ExtensionSubjectType = "booking" | "member" | "installation";
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. Throws `ExtensionVerificationError`.
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;
@@ -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. Throws `ExtensionVerificationError`.
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 ?? {};