@shuffl/api-contracts 0.4.0 → 0.5.1

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,429 @@
1
+ // #643: outgoing webhooks. The catalog of events a workspace endpoint can
2
+ // subscribe to, the envelope every delivery carries and the Standard Webhooks
3
+ // (https://www.standardwebhooks.com/) signature, shared by the server that
4
+ // signs and the SDK that verifies. Payloads are thin on purpose: ids,
5
+ // statuses, counts and revisions, never names, emails, Slack ids, message
6
+ // text or document titles. A receiver reads anything more through the API
7
+ // with a key that holds the event's read scope.
8
+ export const webhookApiVersion = '2026-10-17';
9
+ export const webhookEventGroups = {
10
+ connections: {
11
+ description: 'Connections programs, rounds and who takes part',
12
+ label: 'Connections',
13
+ scope: 'programs:read',
14
+ },
15
+ hr: {
16
+ description: 'Who answers HR requests',
17
+ label: 'HR team',
18
+ scope: 'hr:read',
19
+ },
20
+ knowledge: {
21
+ description: 'Proposals, drafts, published pages and connected sources',
22
+ label: 'Knowledge',
23
+ scope: 'knowledge:read',
24
+ },
25
+ people: {
26
+ description: 'People, roles, access, identities and departments',
27
+ label: 'People',
28
+ scope: 'users:read',
29
+ },
30
+ playbook: {
31
+ description: 'Playbook enrollments, steps, buddies and check-ins',
32
+ label: 'Playbooks',
33
+ scope: 'playbooks:read',
34
+ },
35
+ webhooks: {
36
+ description: 'Test events and endpoints Shuffl turned off',
37
+ label: 'Webhooks',
38
+ scope: 'webhooks:read',
39
+ },
40
+ workspace: {
41
+ description: 'The workspace itself and its Slack connection',
42
+ label: 'Workspace',
43
+ scope: 'team:read',
44
+ },
45
+ };
46
+ export const webhookEventGroupIds = Object.keys(webhookEventGroups);
47
+ export const webhookEventCatalog = {
48
+ 'hr.responder.granted': {
49
+ description: 'Someone joined the HR team that answers requests',
50
+ fields: ['person_id', 'revision'],
51
+ group: 'hr',
52
+ },
53
+ 'hr.responder.revoked': {
54
+ description: 'Someone left the HR team',
55
+ fields: ['person_id', 'revision'],
56
+ group: 'hr',
57
+ },
58
+ 'hr.team.renamed': {
59
+ description: 'The HR team’s name changed',
60
+ fields: ['revision'],
61
+ group: 'hr',
62
+ },
63
+ 'knowledge.document.archived': {
64
+ description: 'A page was archived',
65
+ fields: ['document_id'],
66
+ group: 'knowledge',
67
+ },
68
+ 'knowledge.document.audience_changed': {
69
+ description: 'Who can read a published page changed',
70
+ fields: ['document_id', 'version_id', 'audience', 'previous_audience'],
71
+ group: 'knowledge',
72
+ },
73
+ 'knowledge.document.published': {
74
+ description: 'A page was published',
75
+ fields: ['document_id', 'version_id', 'audience'],
76
+ group: 'knowledge',
77
+ },
78
+ 'knowledge.document.restored': {
79
+ description: 'An archived page was restored',
80
+ fields: ['document_id'],
81
+ group: 'knowledge',
82
+ },
83
+ 'knowledge.document.withdrawn': {
84
+ description: 'A published page was withdrawn',
85
+ fields: ['document_id'],
86
+ group: 'knowledge',
87
+ },
88
+ 'knowledge.draft.saved': {
89
+ description: 'A draft of a page was saved',
90
+ fields: ['document_id', 'version_id'],
91
+ group: 'knowledge',
92
+ },
93
+ 'knowledge.proposal.created': {
94
+ description: 'Mello proposed new or changed Knowledge for review',
95
+ fields: ['proposal_id', 'revision'],
96
+ group: 'knowledge',
97
+ },
98
+ 'knowledge.proposal.published': {
99
+ description: 'A verifier approved a proposal, as written or edited',
100
+ fields: ['proposal_id', 'revision', 'document_id', 'edited'],
101
+ group: 'knowledge',
102
+ },
103
+ 'knowledge.proposal.rejected': {
104
+ description: 'A verifier turned a proposal down',
105
+ fields: ['proposal_id', 'revision'],
106
+ group: 'knowledge',
107
+ },
108
+ 'knowledge.section.removed': {
109
+ description: 'A section was removed from a handbook page',
110
+ fields: ['document_id'],
111
+ group: 'knowledge',
112
+ },
113
+ 'knowledge.source.access_lost': {
114
+ description: 'Shuffl can no longer read a connected document',
115
+ fields: ['document_id'],
116
+ group: 'knowledge',
117
+ },
118
+ 'knowledge.source.connected': {
119
+ description: 'A Knowledge source such as Google Drive was connected',
120
+ fields: ['connection_id'],
121
+ group: 'knowledge',
122
+ },
123
+ 'knowledge.source.disconnected': {
124
+ description: 'A Knowledge source was disconnected',
125
+ fields: ['connection_id'],
126
+ group: 'knowledge',
127
+ },
128
+ 'knowledge.verifier.granted': {
129
+ description: 'Someone can now verify Knowledge',
130
+ fields: ['person_id', 'revision'],
131
+ group: 'knowledge',
132
+ },
133
+ 'knowledge.verifier.revoked': {
134
+ description: 'Someone can no longer verify Knowledge',
135
+ fields: ['person_id', 'revision'],
136
+ group: 'knowledge',
137
+ },
138
+ 'participant.opted_in': {
139
+ description: 'A person joined a Connections program',
140
+ fields: ['person_id', 'program_id', 'revision'],
141
+ group: 'connections',
142
+ },
143
+ 'participant.opted_out': {
144
+ description: 'A person left a Connections program',
145
+ fields: ['person_id', 'program_id', 'revision'],
146
+ group: 'connections',
147
+ },
148
+ 'participant.profile_updated': {
149
+ description: 'A person updated their profile',
150
+ fields: ['person_id'],
151
+ group: 'connections',
152
+ },
153
+ 'person.access_changed': {
154
+ description: 'A person’s app access or employment changed',
155
+ fields: [
156
+ 'person_id',
157
+ 'status',
158
+ 'previous_status',
159
+ 'employment',
160
+ 'previous_employment',
161
+ 'revision',
162
+ ],
163
+ group: 'people',
164
+ },
165
+ 'person.access_suspended': {
166
+ description: 'A person’s access was suspended',
167
+ fields: ['person_id'],
168
+ group: 'people',
169
+ },
170
+ 'person.created': {
171
+ description: 'A person was added to the directory',
172
+ fields: ['person_id', 'revision'],
173
+ group: 'people',
174
+ },
175
+ 'person.identity_linked': {
176
+ description: 'A Slack or single sign-on identity was linked to a person',
177
+ fields: ['person_id', 'identity_id', 'provider'],
178
+ group: 'people',
179
+ },
180
+ 'person.identity_revoked': {
181
+ description: 'A linked identity was removed from a person',
182
+ fields: ['person_id', 'identity_id', 'provider'],
183
+ group: 'people',
184
+ },
185
+ 'person.manager_changed': {
186
+ description: 'A person’s manager changed',
187
+ fields: ['person_id'],
188
+ group: 'people',
189
+ },
190
+ 'person.role_changed': {
191
+ description: 'A person’s workspace role changed',
192
+ fields: ['person_id', 'role', 'previous_role'],
193
+ group: 'people',
194
+ },
195
+ 'person.updated': {
196
+ description: 'A person’s directory record changed',
197
+ fields: ['person_id', 'revision'],
198
+ group: 'people',
199
+ },
200
+ 'playbooks.approved': {
201
+ description: 'HR approved a person’s playbook steps',
202
+ fields: ['enrollment_id'],
203
+ group: 'playbook',
204
+ },
205
+ 'playbooks.buddy.accepted': {
206
+ description: 'A buddy said yes',
207
+ fields: ['enrollment_id'],
208
+ group: 'playbook',
209
+ },
210
+ 'playbooks.buddy.assigned': {
211
+ description: 'A buddy was asked to help a new hire',
212
+ fields: ['enrollment_id', 'buddy_person_id'],
213
+ group: 'playbook',
214
+ },
215
+ 'playbooks.buddy.declined': {
216
+ description: 'A buddy said no',
217
+ fields: ['enrollment_id'],
218
+ group: 'playbook',
219
+ },
220
+ 'playbooks.check_in.answered': {
221
+ description: 'A new hire answered a check-in: all_good, or need_help when they asked HR for help',
222
+ fields: ['step_id', 'enrollment_id', 'answer'],
223
+ group: 'playbook',
224
+ },
225
+ 'playbooks.enrolled': {
226
+ description: 'A person started a playbook',
227
+ fields: ['enrollment_id', 'person_id', 'program_id', 'with_buddy'],
228
+ group: 'playbook',
229
+ },
230
+ 'playbooks.enrollment_removed': {
231
+ description: 'A person was taken off a playbook',
232
+ fields: ['enrollment_id', 'reason'],
233
+ group: 'playbook',
234
+ },
235
+ 'playbooks.schedule_changed': {
236
+ description: 'A person’s playbook schedule changed',
237
+ fields: ['enrollment_id'],
238
+ group: 'playbook',
239
+ },
240
+ 'playbooks.step.failed': {
241
+ description: 'A playbook step could not be delivered',
242
+ fields: ['step_id', 'reason'],
243
+ group: 'playbook',
244
+ },
245
+ 'playbooks.step.needs_review': {
246
+ description: 'A playbook step waits for HR before it sends',
247
+ fields: ['step_id'],
248
+ group: 'playbook',
249
+ },
250
+ 'playbooks.step.sent': {
251
+ description: 'A playbook step reached its person',
252
+ fields: ['step_id'],
253
+ group: 'playbook',
254
+ },
255
+ 'program.archived': {
256
+ description: 'A program was archived',
257
+ fields: ['program_id'],
258
+ group: 'connections',
259
+ },
260
+ 'program.created': {
261
+ description: 'A Connections program or playbook was created',
262
+ fields: ['program_id', 'kind'],
263
+ group: 'connections',
264
+ },
265
+ 'program.paused': {
266
+ description: 'A program was paused',
267
+ fields: ['program_id'],
268
+ group: 'connections',
269
+ },
270
+ 'program.resumed': {
271
+ description: 'A program was switched on',
272
+ fields: ['program_id'],
273
+ group: 'connections',
274
+ },
275
+ 'program.updated': {
276
+ description: 'A program’s settings changed',
277
+ fields: ['program_id', 'kind'],
278
+ group: 'connections',
279
+ },
280
+ 'round.planned': {
281
+ description: 'Shuffl planned a round of introductions',
282
+ fields: ['round_id', 'groups', 'unmatched'],
283
+ group: 'connections',
284
+ },
285
+ 'round.requested': {
286
+ description: 'Someone asked for a round of introductions',
287
+ fields: ['round_id', 'retry'],
288
+ group: 'connections',
289
+ },
290
+ 'slack.connected': {
291
+ description: 'Slack was connected',
292
+ fields: [],
293
+ group: 'workspace',
294
+ },
295
+ 'slack.connection_expired': {
296
+ description: 'Slack stopped accepting Shuffl’s connection',
297
+ fields: ['reason'],
298
+ group: 'workspace',
299
+ },
300
+ 'slack.uninstalled': {
301
+ description: 'Shuffl was removed from Slack',
302
+ fields: ['reason'],
303
+ group: 'workspace',
304
+ },
305
+ 'webhook.endpoint.disabled': {
306
+ description: 'Shuffl turned an endpoint off after it kept failing',
307
+ fields: ['endpoint_id', 'reason'],
308
+ group: 'webhooks',
309
+ },
310
+ 'webhook.test': {
311
+ description: 'A test event someone sent from Settings or the API',
312
+ fields: ['endpoint_id'],
313
+ group: 'webhooks',
314
+ },
315
+ 'workspace.deletion_requested': {
316
+ description: 'An owner asked to delete the workspace',
317
+ fields: ['delete_after'],
318
+ group: 'workspace',
319
+ },
320
+ 'workspace.exported': {
321
+ description: 'Someone exported the workspace’s data',
322
+ fields: [],
323
+ group: 'workspace',
324
+ },
325
+ 'workspace.renamed': {
326
+ description: 'The workspace was renamed',
327
+ fields: [],
328
+ group: 'workspace',
329
+ },
330
+ 'workspace.restored': {
331
+ description: 'The workspace was restored',
332
+ fields: ['deletion_canceled'],
333
+ group: 'workspace',
334
+ },
335
+ 'workspace.slack_join_access_changed': {
336
+ description: 'Who can join from Slack changed',
337
+ fields: [],
338
+ group: 'workspace',
339
+ },
340
+ };
341
+ export const webhookEventTypes = Object.keys(webhookEventCatalog);
342
+ export const isWebhookEventType = (value) => Object.hasOwn(webhookEventCatalog, value);
343
+ /** Keys no payload may ever carry. A test walks the catalog against it. */
344
+ export const webhookNeverSendFields = [
345
+ 'answer_text',
346
+ 'body',
347
+ 'comment',
348
+ 'bio',
349
+ 'email',
350
+ 'feedback',
351
+ 'message',
352
+ 'name',
353
+ 'note',
354
+ 'previous_name',
355
+ 'question',
356
+ 'slack_team_id',
357
+ 'slack_user_id',
358
+ 'subject',
359
+ 'text',
360
+ 'title',
361
+ 'token',
362
+ 'secret',
363
+ ];
364
+ export const webhookSecretPrefix = 'whsec_';
365
+ export const webhookToleranceSeconds = 300;
366
+ const base64 = (bytes) => {
367
+ let text = '';
368
+ for (const byte of new Uint8Array(bytes))
369
+ text += String.fromCharCode(byte);
370
+ return btoa(text);
371
+ };
372
+ const secretBytes = (secret) => {
373
+ const raw = secret.startsWith(webhookSecretPrefix)
374
+ ? secret.slice(webhookSecretPrefix.length)
375
+ : secret;
376
+ return Uint8Array.from(atob(raw), (char) => char.charCodeAt(0));
377
+ };
378
+ /** The `v1,<signature>` value for one secret: base64 HMAC-SHA256 of
379
+ * `id.timestamp.body`, as Standard Webhooks specifies. */
380
+ export async function signWebhook(secret, id, timestamp, body) {
381
+ const key = await crypto.subtle.importKey('raw', secretBytes(secret), { hash: 'SHA-256', name: 'HMAC' }, false, ['sign']);
382
+ const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${id}.${timestamp}.${body}`));
383
+ return `v1,${base64(signature)}`;
384
+ }
385
+ export class WebhookVerificationError extends Error {
386
+ name = 'WebhookVerificationError';
387
+ }
388
+ const header = (headers, name) => {
389
+ if (typeof headers.get === 'function')
390
+ return headers.get(name) ?? undefined;
391
+ const record = headers;
392
+ const key = Object.keys(record).find((k) => k.toLowerCase() === name);
393
+ const value = key ? record[key] : undefined;
394
+ return Array.isArray(value) ? value[0] : (value ?? undefined);
395
+ };
396
+ const sameText = (a, b) => {
397
+ if (a.length !== b.length)
398
+ return false;
399
+ let difference = 0;
400
+ for (let i = 0; i < a.length; i++)
401
+ difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
402
+ return difference === 0;
403
+ };
404
+ /** Checks a delivery's Standard Webhooks headers against the endpoint's
405
+ * signing secret (or several while one is rolled) and returns the parsed
406
+ * event. Pass the raw request body, before any JSON parsing. Throws
407
+ * WebhookVerificationError for a missing header, a timestamp more than
408
+ * five minutes away or a signature that matches no secret. */
409
+ export async function verifyWebhook(input) {
410
+ const id = header(input.headers, 'webhook-id');
411
+ const timestamp = header(input.headers, 'webhook-timestamp');
412
+ const signatures = header(input.headers, 'webhook-signature');
413
+ if (!id || !timestamp || !signatures)
414
+ throw new WebhookVerificationError('Missing webhook headers');
415
+ const seconds = Number(timestamp);
416
+ const now = Math.floor((input.now ?? new Date()).getTime() / 1000);
417
+ if (!Number.isInteger(seconds) ||
418
+ Math.abs(now - seconds) >
419
+ (input.toleranceSeconds ?? webhookToleranceSeconds))
420
+ throw new WebhookVerificationError('Webhook timestamp is too old or new');
421
+ const offered = signatures.split(' ').filter(Boolean);
422
+ const secrets = typeof input.secret === 'string' ? [input.secret] : input.secret;
423
+ for (const secret of secrets) {
424
+ const expected = await signWebhook(secret, id, seconds, input.body);
425
+ if (offered.some((value) => sameText(value, expected)))
426
+ return JSON.parse(input.body);
427
+ }
428
+ throw new WebhookVerificationError('No matching webhook signature');
429
+ }