@churchapps/integration-sdk 0.2.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.
@@ -0,0 +1,366 @@
1
+ import { Request, Response, RequestHandler } from 'express';
2
+
3
+ /** Every webhook event B1 can emit. */
4
+ type B1WebhookEventName = "person.created" | "person.updated" | "person.destroyed" | "group.created" | "group.updated" | "group.destroyed" | "group.member.added" | "group.member.removed" | "household.created" | "household.updated" | "household.destroyed" | "donation.created" | "donation.updated" | "attendance.recorded" | "session.created" | "form.submission.created" | "event.created" | "event.updated" | "event.destroyed";
5
+ /** The HTTP headers B1 sends with every webhook delivery. */
6
+ declare const WEBHOOK_HEADERS: {
7
+ readonly signature: "X-B1-Signature";
8
+ readonly event: "X-B1-Event";
9
+ readonly deliveryId: "X-B1-Delivery-Id";
10
+ readonly timestamp: "X-B1-Timestamp";
11
+ };
12
+ /** The JSON body B1 POSTs to a subscriber URL. */
13
+ interface B1WebhookEnvelope<T = unknown> {
14
+ event: B1WebhookEventName;
15
+ churchId: string;
16
+ /** ISO 8601 timestamp. */
17
+ occurredAt: string;
18
+ data: T;
19
+ }
20
+ interface PersonWebhookData {
21
+ id: string;
22
+ churchId: string;
23
+ name?: {
24
+ display?: string;
25
+ first?: string;
26
+ last?: string;
27
+ };
28
+ contactInfo?: {
29
+ email?: string;
30
+ };
31
+ }
32
+ interface GroupWebhookData {
33
+ id: string;
34
+ churchId: string;
35
+ name?: string;
36
+ categoryName?: string;
37
+ }
38
+ interface GroupMemberWebhookData {
39
+ id: string;
40
+ churchId: string;
41
+ groupId: string;
42
+ personId: string;
43
+ }
44
+ interface HouseholdWebhookData {
45
+ id: string;
46
+ churchId: string;
47
+ name?: string;
48
+ }
49
+ interface DonationWebhookData {
50
+ id: string;
51
+ churchId: string;
52
+ personId?: string;
53
+ batchId?: string;
54
+ donationDate?: string;
55
+ amount?: number;
56
+ currency?: string;
57
+ method?: string;
58
+ status?: string;
59
+ }
60
+ interface AttendanceWebhookData {
61
+ id: string;
62
+ churchId: string;
63
+ personId?: string;
64
+ visitDate?: string;
65
+ checkinTime?: string;
66
+ }
67
+ interface SessionWebhookData {
68
+ id: string;
69
+ churchId: string;
70
+ groupId?: string;
71
+ serviceTimeId?: string;
72
+ sessionDate?: string;
73
+ }
74
+ interface FormSubmissionWebhookData {
75
+ id: string;
76
+ churchId: string;
77
+ formId?: string;
78
+ contentType?: string;
79
+ contentId?: string;
80
+ }
81
+ interface EventWebhookData {
82
+ id: string;
83
+ churchId: string;
84
+ groupId?: string;
85
+ title?: string;
86
+ start?: string;
87
+ end?: string;
88
+ }
89
+ /**
90
+ * Discriminated union of every webhook envelope — `switch (env.event)` narrows
91
+ * `data` to the matching shape.
92
+ */
93
+ type B1Webhook = B1WebhookEnvelope<PersonWebhookData> & {
94
+ event: "person.created" | "person.updated" | "person.destroyed";
95
+ } | B1WebhookEnvelope<GroupWebhookData> & {
96
+ event: "group.created" | "group.updated" | "group.destroyed";
97
+ } | B1WebhookEnvelope<GroupMemberWebhookData> & {
98
+ event: "group.member.added" | "group.member.removed";
99
+ } | B1WebhookEnvelope<HouseholdWebhookData> & {
100
+ event: "household.created" | "household.updated" | "household.destroyed";
101
+ } | B1WebhookEnvelope<DonationWebhookData> & {
102
+ event: "donation.created" | "donation.updated";
103
+ } | B1WebhookEnvelope<AttendanceWebhookData> & {
104
+ event: "attendance.recorded";
105
+ } | B1WebhookEnvelope<SessionWebhookData> & {
106
+ event: "session.created";
107
+ } | B1WebhookEnvelope<FormSubmissionWebhookData> & {
108
+ event: "form.submission.created";
109
+ } | B1WebhookEnvelope<EventWebhookData> & {
110
+ event: "event.created" | "event.updated" | "event.destroyed";
111
+ };
112
+
113
+ /** A recognised OAuth / API-key scope. */
114
+ type B1KnownScope = "people:read" | "people:write" | "groups:read" | "groups:write" | "donations:read" | "donations:write" | "attendance:read" | "attendance:write" | "forms:write" | "content:read" | "content:write" | "messaging:read" | "messaging:write" | "roles:read" | "roles:write" | "settings:read" | "settings:write" | "offline_access";
115
+ /** Scope strings — known scopes get autocomplete, custom strings still allowed. */
116
+ type B1Scope = B1KnownScope | (string & {});
117
+ /** All scopes B1 recognises in its catalog (plus `offline_access`). */
118
+ declare const B1_SCOPES: B1KnownScope[];
119
+ type B1GrantType = "authorization_code" | "refresh_token" | "urn:ietf:params:oauth:grant-type:device_code";
120
+ /** The token response from `POST /membership/oauth/token`. */
121
+ interface B1TokenResponse {
122
+ access_token: string;
123
+ token_type: "Bearer";
124
+ /** Lifetime in seconds. */
125
+ expires_in: number;
126
+ /** Unix timestamp (seconds) the token was created. Absent on the device grant. */
127
+ created_at?: number;
128
+ refresh_token: string;
129
+ scope: string;
130
+ }
131
+ /** The response from `POST /membership/oauth/device/authorize` (RFC 8628). */
132
+ interface B1DeviceAuthResponse {
133
+ device_code: string;
134
+ user_code: string;
135
+ verification_uri: string;
136
+ verification_uri_complete?: string;
137
+ /** Lifetime in seconds. */
138
+ expires_in: number;
139
+ /** Recommended poll interval in seconds. */
140
+ interval: number;
141
+ }
142
+ /** Outcome of a single device-token poll. */
143
+ type B1DevicePollResult = {
144
+ status: "approved";
145
+ token: B1TokenResponse;
146
+ } | {
147
+ status: "pending";
148
+ } | {
149
+ status: "expired";
150
+ } | {
151
+ status: "denied";
152
+ };
153
+
154
+ /** The B1 Api modules, each addressed by a `/<module>` path prefix. */
155
+ type B1Module = "membership" | "giving" | "attendance" | "content" | "messaging" | "doing" | "reporting";
156
+ /** Known B1 Api base URLs. */
157
+ declare const B1_BASE_URLS: {
158
+ readonly prod: "https://api.b1.church";
159
+ readonly staging: "https://api.staging.b1.church";
160
+ };
161
+ interface B1RestClientOptions {
162
+ /** A raw `cak_<prefix>.<secret>` API key, sent verbatim as a bearer token. */
163
+ apiKey: string;
164
+ /** Base URL — defaults to production. */
165
+ baseUrl?: string;
166
+ /** Override `fetch` (for tests or non-global-fetch runtimes). */
167
+ fetch?: typeof fetch;
168
+ }
169
+ type B1QueryValue = string | number | boolean | undefined | null;
170
+ interface B1RequestOptions {
171
+ method?: "GET" | "POST" | "PUT" | "DELETE";
172
+ body?: unknown;
173
+ query?: Record<string, B1QueryValue>;
174
+ headers?: Record<string, string>;
175
+ }
176
+
177
+ /** Thrown by `verifyAndParse` when a signature does not match. */
178
+ declare class WebhookVerificationError extends Error {
179
+ constructor(message: string);
180
+ }
181
+ /**
182
+ * Verifies and parses inbound B1 webhook deliveries.
183
+ *
184
+ * The signature is an HMAC-SHA256 over the **raw request body** — verify
185
+ * before any JSON parse/re-stringify, which would change byte order/whitespace.
186
+ * Byte-compatible with the B1 Api `shared/webhooks/WebhookSigner.ts`.
187
+ */
188
+ declare class WebhookVerifier {
189
+ /** Computes the `X-B1-Signature` value for a raw body. */
190
+ static sign(secret: string, rawBody: string | Buffer): string;
191
+ /**
192
+ * Returns `true` when `signatureHeader` matches the body. Never throws —
193
+ * a missing, empty, or malformed header simply returns `false`.
194
+ */
195
+ static verify(secret: string, rawBody: string | Buffer, signatureHeader: string | null | undefined): boolean;
196
+ /** Parses a raw body into a typed envelope (no verification). */
197
+ static parseEnvelope<T = unknown>(rawBody: string | Buffer): B1WebhookEnvelope<T>;
198
+ /**
199
+ * Verifies the signature, then parses the body into a typed envelope.
200
+ * Throws `WebhookVerificationError` if the signature does not match.
201
+ */
202
+ static verifyAndParse<T = unknown>(secret: string, rawBody: string | Buffer, signatureHeader: string | null | undefined): B1WebhookEnvelope<T>;
203
+ }
204
+
205
+ declare global {
206
+ namespace Express {
207
+ interface Request {
208
+ /** The untouched request body — set by `express.json({ verify })`. */
209
+ rawBody?: Buffer | string;
210
+ /** The verified, parsed webhook envelope — set by `b1WebhookMiddleware`. */
211
+ b1Webhook?: B1WebhookEnvelope;
212
+ }
213
+ }
214
+ }
215
+ interface B1WebhookMiddlewareOptions {
216
+ /** The webhook secret, or a function resolving one per request. */
217
+ secret: string | ((req: Request) => string);
218
+ /** Called instead of the default 401 response when verification fails. */
219
+ onInvalid?: (req: Request, res: Response) => void;
220
+ }
221
+ /**
222
+ * Express middleware that verifies the `X-B1-Signature` header and attaches a
223
+ * typed `req.b1Webhook` envelope.
224
+ *
225
+ * The raw request body must be available — capture it before JSON parsing:
226
+ *
227
+ * ```ts
228
+ * app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));
229
+ * app.post("/webhooks/b1", b1WebhookMiddleware({ secret }), (req, res) => {
230
+ * console.log(req.b1Webhook?.event);
231
+ * res.sendStatus(200);
232
+ * });
233
+ * ```
234
+ *
235
+ * `express.raw({ type: "application/json" })` is also accepted — `req.body` is
236
+ * then a Buffer the middleware verifies and parses itself.
237
+ */
238
+ declare function b1WebhookMiddleware(options: B1WebhookMiddlewareOptions): RequestHandler;
239
+
240
+ /**
241
+ * A typed REST client for the B1 Api, authenticated with a `cak_` API key.
242
+ *
243
+ * The Api is a single host with per-module path prefixes — use `request()`
244
+ * with a full `/membership/...` path, or the module helpers which prefix it
245
+ * for you.
246
+ *
247
+ * ```ts
248
+ * const client = new B1RestClient({ apiKey: "cak_..." });
249
+ * const people = await client.membership<Person[]>("/people");
250
+ * ```
251
+ *
252
+ * Non-2xx responses throw `B1ApiError` (carrying status + parsed body) so a
253
+ * caller can distinguish 401/403/404/500.
254
+ */
255
+ declare class B1RestClient {
256
+ private readonly apiKey;
257
+ private readonly baseUrl;
258
+ private readonly fetchImpl;
259
+ constructor(options: B1RestClientOptions);
260
+ /** Issues a request against a full Api path (e.g. `/membership/people`). */
261
+ request<T = unknown>(path: string, options?: B1RequestOptions): Promise<T>;
262
+ /** Request against the `/membership` module. */
263
+ membership<T = unknown>(path: string, options?: B1RequestOptions): Promise<T>;
264
+ /** Request against the `/giving` module. */
265
+ giving<T = unknown>(path: string, options?: B1RequestOptions): Promise<T>;
266
+ /** Request against the `/attendance` module. */
267
+ attendance<T = unknown>(path: string, options?: B1RequestOptions): Promise<T>;
268
+ /** Request against the `/content` module. */
269
+ content<T = unknown>(path: string, options?: B1RequestOptions): Promise<T>;
270
+ /** Request against the `/messaging` module. */
271
+ messaging<T = unknown>(path: string, options?: B1RequestOptions): Promise<T>;
272
+ /** Request against the `/doing` module. */
273
+ doing<T = unknown>(path: string, options?: B1RequestOptions): Promise<T>;
274
+ /** Request against the `/reporting` module. */
275
+ reporting<T = unknown>(path: string, options?: B1RequestOptions): Promise<T>;
276
+ private module;
277
+ private buildUrl;
278
+ }
279
+
280
+ /** Thrown by `B1RestClient` when the Api returns a non-2xx response. */
281
+ declare class B1ApiError extends Error {
282
+ readonly status: number;
283
+ readonly statusText: string;
284
+ readonly body: unknown;
285
+ readonly method: string;
286
+ readonly url: string;
287
+ constructor(opts: {
288
+ status: number;
289
+ statusText: string;
290
+ body: unknown;
291
+ method: string;
292
+ url: string;
293
+ });
294
+ }
295
+
296
+ /** Thrown when a B1 OAuth endpoint returns an `error` response. */
297
+ declare class B1OAuthError extends Error {
298
+ readonly error: string;
299
+ readonly errorDescription?: string;
300
+ readonly status: number;
301
+ constructor(error: string, errorDescription: string | undefined, status: number);
302
+ }
303
+ interface B1OAuthClientOptions {
304
+ clientId: string;
305
+ /** Required for confidential clients (authorization_code grant). */
306
+ clientSecret?: string;
307
+ /** Base URL — defaults to production. */
308
+ baseUrl?: string;
309
+ /** Override `fetch` (for tests or non-global-fetch runtimes). */
310
+ fetch?: typeof fetch;
311
+ }
312
+ interface AwaitDeviceTokenOptions {
313
+ deviceCode: string;
314
+ /** Poll interval in seconds (from `B1DeviceAuthResponse.interval`). */
315
+ interval: number;
316
+ /** Overall timeout in seconds (from `B1DeviceAuthResponse.expires_in`). */
317
+ expiresIn: number;
318
+ /** Optional abort signal to cancel polling. */
319
+ signal?: AbortSignal;
320
+ }
321
+ /**
322
+ * Helper for B1's OAuth flows — authorization-code, refresh-token, and the
323
+ * RFC 8628 device flow — against `/membership/oauth/*`.
324
+ */
325
+ declare class B1OAuthClient {
326
+ private readonly clientId;
327
+ private readonly clientSecret?;
328
+ private readonly baseUrl;
329
+ private readonly fetchImpl;
330
+ constructor(options: B1OAuthClientOptions);
331
+ /**
332
+ * Requests an authorization code. B1's `/authorize` endpoint is an
333
+ * authenticated POST, so this needs the *user's* access token (a JWT).
334
+ */
335
+ getAuthorizationCode(params: {
336
+ userAccessToken: string;
337
+ redirectUri: string;
338
+ scope: B1Scope[] | string;
339
+ state?: string;
340
+ }): Promise<{
341
+ code: string;
342
+ state: string | null;
343
+ }>;
344
+ /** Exchanges an authorization code for tokens. */
345
+ exchangeCode(params: {
346
+ code: string;
347
+ redirectUri?: string;
348
+ }): Promise<B1TokenResponse>;
349
+ /** Exchanges a refresh token for a fresh access token. */
350
+ refresh(refreshToken: string): Promise<B1TokenResponse>;
351
+ /** Starts the device flow — returns a user code + verification URI. */
352
+ startDeviceFlow(scope?: B1Scope[] | string): Promise<B1DeviceAuthResponse>;
353
+ /** Polls once for a device-flow token. Never throws on a pending/expired/denied state. */
354
+ pollDeviceToken(deviceCode: string): Promise<B1DevicePollResult>;
355
+ /** Polls until the device flow is approved, denied, or expires. */
356
+ awaitDeviceToken(options: AwaitDeviceTokenOptions): Promise<B1TokenResponse>;
357
+ /** Looks up a pending device authorization by its user code (for an approval UI). */
358
+ getPendingDevice(userCode: string): Promise<unknown>;
359
+ private post;
360
+ private rawPost;
361
+ }
362
+
363
+ /** `@churchapps/integration-sdk` — toolkit for building B1.church integrations. */
364
+ declare const VERSION = "0.1.0";
365
+
366
+ export { type AttendanceWebhookData, type AwaitDeviceTokenOptions, B1ApiError, type B1DeviceAuthResponse, type B1DevicePollResult, type B1GrantType, type B1KnownScope, type B1Module, B1OAuthClient, type B1OAuthClientOptions, B1OAuthError, type B1QueryValue, type B1RequestOptions, B1RestClient, type B1RestClientOptions, type B1Scope, type B1TokenResponse, type B1Webhook, type B1WebhookEnvelope, type B1WebhookEventName, type B1WebhookMiddlewareOptions, B1_BASE_URLS, B1_SCOPES, type DonationWebhookData, type EventWebhookData, type FormSubmissionWebhookData, type GroupMemberWebhookData, type GroupWebhookData, type HouseholdWebhookData, type PersonWebhookData, type SessionWebhookData, VERSION, WEBHOOK_HEADERS, WebhookVerificationError, WebhookVerifier, b1WebhookMiddleware };