@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 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
+ }
@@ -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` 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. Throws `ExtensionVerificationError`.
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;
@@ -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. Throws `ExtensionVerificationError`.
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
  }