@baack-software/baack-ts-tasks 0.0.0-stage → 0.0.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.
- package/LICENSE +201 -0
- package/README.md +158 -2
- package/dist/activity.d.ts +20 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +75 -0
- package/dist/activity.js.map +1 -0
- package/dist/blockers.d.ts +28 -0
- package/dist/blockers.d.ts.map +1 -0
- package/dist/blockers.js +134 -0
- package/dist/blockers.js.map +1 -0
- package/dist/conflicts.d.ts +50 -0
- package/dist/conflicts.d.ts.map +1 -0
- package/dist/conflicts.js +73 -0
- package/dist/conflicts.js.map +1 -0
- package/dist/draft.d.ts +45 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +155 -0
- package/dist/draft.js.map +1 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +37 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/react/TaskActivity.d.ts +15 -0
- package/dist/react/TaskActivity.d.ts.map +1 -0
- package/dist/react/TaskActivity.js +60 -0
- package/dist/react/TaskActivity.js.map +1 -0
- package/dist/react/TaskBoard.d.ts +38 -0
- package/dist/react/TaskBoard.d.ts.map +1 -0
- package/dist/react/TaskBoard.js +85 -0
- package/dist/react/TaskBoard.js.map +1 -0
- package/dist/react/TaskConflicts.d.ts +26 -0
- package/dist/react/TaskConflicts.d.ts.map +1 -0
- package/dist/react/TaskConflicts.js +87 -0
- package/dist/react/TaskConflicts.js.map +1 -0
- package/dist/react/TaskCreateForm.d.ts +13 -0
- package/dist/react/TaskCreateForm.d.ts.map +1 -0
- package/dist/react/TaskCreateForm.js +68 -0
- package/dist/react/TaskCreateForm.js.map +1 -0
- package/dist/react/TaskEditor.d.ts +33 -0
- package/dist/react/TaskEditor.d.ts.map +1 -0
- package/dist/react/TaskEditor.js +172 -0
- package/dist/react/TaskEditor.js.map +1 -0
- package/dist/react/TaskFilters.d.ts +15 -0
- package/dist/react/TaskFilters.d.ts.map +1 -0
- package/dist/react/TaskFilters.js +48 -0
- package/dist/react/TaskFilters.js.map +1 -0
- package/dist/react/TaskView.d.ts +30 -0
- package/dist/react/TaskView.d.ts.map +1 -0
- package/dist/react/TaskView.js +108 -0
- package/dist/react/TaskView.js.map +1 -0
- package/dist/react/TasksAbout.d.ts +24 -0
- package/dist/react/TasksAbout.d.ts.map +1 -0
- package/dist/react/TasksAbout.js +82 -0
- package/dist/react/TasksAbout.js.map +1 -0
- package/dist/react/copy.d.ts +154 -0
- package/dist/react/copy.d.ts.map +1 -0
- package/dist/react/copy.js +173 -0
- package/dist/react/copy.js.map +1 -0
- package/dist/react/fields.d.ts +36 -0
- package/dist/react/fields.d.ts.map +1 -0
- package/dist/react/fields.js +21 -0
- package/dist/react/fields.js.map +1 -0
- package/dist/react/hooks.d.ts +9 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +31 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +13 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/parts.d.ts +55 -0
- package/dist/react/parts.d.ts.map +1 -0
- package/dist/react/parts.js +92 -0
- package/dist/react/parts.js.map +1 -0
- package/dist/react/ui/alert.d.ts +11 -0
- package/dist/react/ui/alert.d.ts.map +1 -0
- package/dist/react/ui/alert.js +29 -0
- package/dist/react/ui/alert.js.map +1 -0
- package/dist/react/ui/avatar.d.ts +12 -0
- package/dist/react/ui/avatar.d.ts.map +1 -0
- package/dist/react/ui/avatar.js +24 -0
- package/dist/react/ui/avatar.js.map +1 -0
- package/dist/react/ui/badge.d.ts +10 -0
- package/dist/react/ui/badge.d.ts.map +1 -0
- package/dist/react/ui/badge.js +26 -0
- package/dist/react/ui/badge.js.map +1 -0
- package/dist/react/ui/button.d.ts +11 -0
- package/dist/react/ui/button.d.ts.map +1 -0
- package/dist/react/ui/button.js +37 -0
- package/dist/react/ui/button.js.map +1 -0
- package/dist/react/ui/card.d.ts +12 -0
- package/dist/react/ui/card.d.ts.map +1 -0
- package/dist/react/ui/card.js +26 -0
- package/dist/react/ui/card.js.map +1 -0
- package/dist/react/ui/input.d.ts +4 -0
- package/dist/react/ui/input.d.ts.map +1 -0
- package/dist/react/ui/input.js +8 -0
- package/dist/react/ui/input.js.map +1 -0
- package/dist/react/ui/label.d.ts +5 -0
- package/dist/react/ui/label.d.ts.map +1 -0
- package/dist/react/ui/label.js +10 -0
- package/dist/react/ui/label.js.map +1 -0
- package/dist/react/ui/radio-group.d.ts +6 -0
- package/dist/react/ui/radio-group.d.ts.map +1 -0
- package/dist/react/ui/radio-group.js +12 -0
- package/dist/react/ui/radio-group.js.map +1 -0
- package/dist/react/ui/select.d.ts +16 -0
- package/dist/react/ui/select.d.ts.map +1 -0
- package/dist/react/ui/select.js +37 -0
- package/dist/react/ui/select.js.map +1 -0
- package/dist/react/ui/separator.d.ts +5 -0
- package/dist/react/ui/separator.d.ts.map +1 -0
- package/dist/react/ui/separator.js +10 -0
- package/dist/react/ui/separator.js.map +1 -0
- package/dist/react/ui/sheet.d.ts +15 -0
- package/dist/react/ui/sheet.d.ts.map +1 -0
- package/dist/react/ui/sheet.js +38 -0
- package/dist/react/ui/sheet.js.map +1 -0
- package/dist/react/ui/skeleton.d.ts +3 -0
- package/dist/react/ui/skeleton.d.ts.map +1 -0
- package/dist/react/ui/skeleton.js +7 -0
- package/dist/react/ui/skeleton.js.map +1 -0
- package/dist/react/ui/table.d.ts +11 -0
- package/dist/react/ui/table.d.ts.map +1 -0
- package/dist/react/ui/table.js +29 -0
- package/dist/react/ui/table.js.map +1 -0
- package/dist/react/ui/tabs.d.ts +12 -0
- package/dist/react/ui/tabs.d.ts.map +1 -0
- package/dist/react/ui/tabs.js +31 -0
- package/dist/react/ui/tabs.js.map +1 -0
- package/dist/react/ui/textarea.d.ts +4 -0
- package/dist/react/ui/textarea.d.ts.map +1 -0
- package/dist/react/ui/textarea.js +8 -0
- package/dist/react/ui/textarea.js.map +1 -0
- package/dist/react/ui/toggle-group.d.ts +11 -0
- package/dist/react/ui/toggle-group.d.ts.map +1 -0
- package/dist/react/ui/toggle-group.js +25 -0
- package/dist/react/ui/toggle-group.js.map +1 -0
- package/dist/react/ui/toggle.d.ts +10 -0
- package/dist/react/ui/toggle.d.ts.map +1 -0
- package/dist/react/ui/toggle.js +27 -0
- package/dist/react/ui/toggle.js.map +1 -0
- package/dist/refs.d.ts +16 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +43 -0
- package/dist/refs.js.map +1 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +4 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/recipes.d.ts +39 -0
- package/dist/server/recipes.d.ts.map +1 -0
- package/dist/server/recipes.js +81 -0
- package/dist/server/recipes.js.map +1 -0
- package/dist/server/webhooks.d.ts +135 -0
- package/dist/server/webhooks.d.ts.map +1 -0
- package/dist/server/webhooks.js +243 -0
- package/dist/server/webhooks.js.map +1 -0
- package/dist/store.d.ts +124 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +443 -0
- package/dist/store.js.map +1 -0
- package/dist/task-client.d.ts +137 -0
- package/dist/task-client.d.ts.map +1 -0
- package/dist/task-client.js +271 -0
- package/dist/task-client.js.map +1 -0
- package/dist/tasks.d.ts +84 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +208 -0
- package/dist/tasks.js.map +1 -0
- package/dist/time.d.ts +15 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +34 -0
- package/dist/time.js.map +1 -0
- package/dist/types.d.ts +74 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +101 -4
- package/src/activity.ts +86 -0
- package/src/blockers.ts +134 -0
- package/src/conflicts.ts +115 -0
- package/src/draft.ts +162 -0
- package/src/errors.ts +40 -0
- package/src/index.ts +10 -0
- package/src/react/TaskActivity.tsx +187 -0
- package/src/react/TaskBoard.tsx +264 -0
- package/src/react/TaskConflicts.tsx +217 -0
- package/src/react/TaskCreateForm.tsx +165 -0
- package/src/react/TaskEditor.tsx +485 -0
- package/src/react/TaskFilters.tsx +122 -0
- package/src/react/TaskView.tsx +291 -0
- package/src/react/TasksAbout.tsx +226 -0
- package/src/react/copy.tsx +253 -0
- package/src/react/fields.tsx +104 -0
- package/src/react/hooks.ts +41 -0
- package/src/react/index.ts +12 -0
- package/src/react/parts.tsx +176 -0
- package/src/react/ui/README.md +6 -0
- package/src/react/ui/alert.tsx +75 -0
- package/src/react/ui/avatar.tsx +109 -0
- package/src/react/ui/badge.tsx +48 -0
- package/src/react/ui/button.tsx +66 -0
- package/src/react/ui/card.tsx +102 -0
- package/src/react/ui/input.tsx +18 -0
- package/src/react/ui/label.tsx +23 -0
- package/src/react/ui/radio-group.tsx +41 -0
- package/src/react/ui/select.tsx +189 -0
- package/src/react/ui/separator.tsx +27 -0
- package/src/react/ui/sheet.tsx +145 -0
- package/src/react/ui/skeleton.tsx +13 -0
- package/src/react/ui/table.tsx +113 -0
- package/src/react/ui/tabs.tsx +89 -0
- package/src/react/ui/textarea.tsx +17 -0
- package/src/react/ui/toggle-group.tsx +89 -0
- package/src/react/ui/toggle.tsx +44 -0
- package/src/refs.ts +49 -0
- package/src/server/index.ts +3 -0
- package/src/server/recipes.ts +107 -0
- package/src/server/webhooks.ts +369 -0
- package/src/store.ts +538 -0
- package/src/task-client.ts +373 -0
- package/src/tasks.ts +263 -0
- package/src/time.ts +35 -0
- package/src/types.ts +101 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { isBaackApiError, type Task } from '@baack-software/baack-ts-client';
|
|
2
|
+
import type { TaskClient } from '../task-client.ts';
|
|
3
|
+
import { parseTimestamp } from '../time.ts';
|
|
4
|
+
import type { Ref } from '../types.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Receiving Baack webhooks: verifying a delivery's signature, reading its event, and fetching the task it is about.
|
|
8
|
+
* Uses Web Crypto and the Fetch API types only, so it runs on Node 20+, Deno, Bun and edge runtimes.
|
|
9
|
+
*
|
|
10
|
+
* A delivery is a POST whose body is a webhook event, an envelope naming what changed (`trigger`) and where it is
|
|
11
|
+
* (`v1`), without the changed representation itself; read it from the API. Its headers:
|
|
12
|
+
*
|
|
13
|
+
* - `Signature`: base64 HMAC-SHA256, keyed with the subscription's secret, over the UTF-8 bytes of the parts
|
|
14
|
+
* `Signed-Headers` lists, in that order and without separators. Today that is the body, then `Last-Modified`, then
|
|
15
|
+
* `Idempotency-Key`.
|
|
16
|
+
* - `Last-Modified`: when the trigger happened, an ISO local date-time (UTC, without a zone, like other Baack
|
|
17
|
+
* timestamps), signed as sent.
|
|
18
|
+
* - `Idempotency-Key`: the trigger's URN. Retries carry the same key, so handle each key once.
|
|
19
|
+
* - `X-Attempt-Number` and `X-Attempts-Remaining`: where a delivery is in its retries.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Header names, lower case as the Fetch API's `Headers` and Node's `IncomingMessage` give them. */
|
|
23
|
+
export const WEBHOOK_HEADERS = {
|
|
24
|
+
signature: 'signature',
|
|
25
|
+
signedHeaders: 'signed-headers',
|
|
26
|
+
timestamp: 'last-modified',
|
|
27
|
+
idempotencyKey: 'idempotency-key',
|
|
28
|
+
attempt: 'x-attempt-number',
|
|
29
|
+
attemptsRemaining: 'x-attempts-remaining',
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/** What a delivery signs when it doesn't say, the order the API uses. */
|
|
33
|
+
export const DEFAULT_SIGNED_PARTS = ['body', WEBHOOK_HEADERS.timestamp, WEBHOOK_HEADERS.idempotencyKey] as const;
|
|
34
|
+
|
|
35
|
+
/** The kinds of representation a trigger can be about. */
|
|
36
|
+
export type WebhookSubject =
|
|
37
|
+
| 'UNKNOWN'
|
|
38
|
+
| 'ADDRESS'
|
|
39
|
+
| 'API_CLIENT'
|
|
40
|
+
| 'BEARER_TOKEN'
|
|
41
|
+
| 'BOOLEAN_ITEM'
|
|
42
|
+
| 'CLIENT_IDENTIFIER'
|
|
43
|
+
| 'COMPANY'
|
|
44
|
+
| 'DATETIME_ITEM'
|
|
45
|
+
| 'DOUBLE_ITEM'
|
|
46
|
+
| 'EMAIL'
|
|
47
|
+
| 'ENTITY'
|
|
48
|
+
| 'ENTITY_LIST'
|
|
49
|
+
| 'GROUP'
|
|
50
|
+
| 'GROUP_MEMBERSHIP'
|
|
51
|
+
| 'IDENTITY'
|
|
52
|
+
| 'JSON_ITEM'
|
|
53
|
+
| 'LINK_ITEM'
|
|
54
|
+
| 'REFERENCE_ITEM'
|
|
55
|
+
| 'IMAGE_ITEM'
|
|
56
|
+
| 'LATLONG_ITEM'
|
|
57
|
+
| 'LONG_ITEM'
|
|
58
|
+
| 'MARKDOWN_ITEM'
|
|
59
|
+
| 'MESSAGE_THREAD'
|
|
60
|
+
| 'MESSAGE_THREAD_NOTIFICATION'
|
|
61
|
+
| 'MONEY_ITEM'
|
|
62
|
+
| 'OAUTH_TOKEN'
|
|
63
|
+
| 'PERMISSION'
|
|
64
|
+
| 'PERSON'
|
|
65
|
+
| 'PERSON_NAME'
|
|
66
|
+
| 'PROJECT'
|
|
67
|
+
| 'ROLE'
|
|
68
|
+
| 'TASK'
|
|
69
|
+
| 'TELEPHONE'
|
|
70
|
+
| 'TEMPLATE_ITEM'
|
|
71
|
+
| 'TEXT_ITEM'
|
|
72
|
+
| 'THREAD_POST'
|
|
73
|
+
| 'WEBHOOK_SUBSCRIPTION'
|
|
74
|
+
| 'WORKFLOW';
|
|
75
|
+
|
|
76
|
+
export type WebhookEventKind = 'CREATE' | 'UPDATE' | 'DELETE';
|
|
77
|
+
|
|
78
|
+
/** What happened: a representation of `subject` was created, updated or deleted. */
|
|
79
|
+
export interface WebhookTrigger {
|
|
80
|
+
urn: string;
|
|
81
|
+
owner?: Ref;
|
|
82
|
+
subject: WebhookSubject;
|
|
83
|
+
event: WebhookEventKind;
|
|
84
|
+
/** The URN of the representation that changed. */
|
|
85
|
+
targetUrn?: string;
|
|
86
|
+
timestamp?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A webhook delivery's body. */
|
|
90
|
+
export interface WebhookEvent {
|
|
91
|
+
trigger: WebhookTrigger;
|
|
92
|
+
subscription?: { urn: string; [field: string]: unknown };
|
|
93
|
+
/** Where the changed representation is. */
|
|
94
|
+
v1?: { urn: string; url?: string };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A delivery that passed verification. */
|
|
98
|
+
export interface WebhookDelivery {
|
|
99
|
+
event: WebhookEvent;
|
|
100
|
+
/** The trigger's URN; the same on every retry of this delivery. */
|
|
101
|
+
idempotencyKey: string;
|
|
102
|
+
/** `Last-Modified`, as sent. */
|
|
103
|
+
timestamp: string;
|
|
104
|
+
attempt?: number;
|
|
105
|
+
attemptsRemaining?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type WebhookRejection =
|
|
109
|
+
| 'missing-signature'
|
|
110
|
+
| 'missing-header'
|
|
111
|
+
| 'unsigned-body'
|
|
112
|
+
| 'bad-signature'
|
|
113
|
+
| 'stale'
|
|
114
|
+
| 'malformed-event';
|
|
115
|
+
|
|
116
|
+
/** Thrown when a delivery can't be trusted or read. Answer 401 (or 400 for `malformed-event`) and don't act on it. */
|
|
117
|
+
export class WebhookVerificationError extends Error {
|
|
118
|
+
override readonly name = 'WebhookVerificationError';
|
|
119
|
+
|
|
120
|
+
constructor(
|
|
121
|
+
readonly reason: WebhookRejection,
|
|
122
|
+
message: string,
|
|
123
|
+
) {
|
|
124
|
+
super(message);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Header lookups from the Fetch API's `Headers`, or a plain object such as Node's `req.headers`. */
|
|
129
|
+
export type HeaderSource = Headers | Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
130
|
+
|
|
131
|
+
export interface VerifyOptions {
|
|
132
|
+
/**
|
|
133
|
+
* Refuse deliveries whose `Last-Modified` is older than this. Off by default: retries keep the trigger's time, so a
|
|
134
|
+
* late retry is still genuine. Use the idempotency key against replays instead.
|
|
135
|
+
*/
|
|
136
|
+
maxAgeMs?: number;
|
|
137
|
+
now?: Date;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Checks a delivery's signature and returns its event. `body` must be the raw bytes or text as received: parsing and
|
|
142
|
+
* re-serialising the JSON changes the bytes and the signature won't match. Throws `WebhookVerificationError`.
|
|
143
|
+
*/
|
|
144
|
+
export async function verifyWebhook(
|
|
145
|
+
delivery: { body: string | Uint8Array; headers: HeaderSource },
|
|
146
|
+
secret: string,
|
|
147
|
+
options: VerifyOptions = {},
|
|
148
|
+
): Promise<WebhookDelivery> {
|
|
149
|
+
const header = (name: string) => readHeader(delivery.headers, name);
|
|
150
|
+
const signature = header(WEBHOOK_HEADERS.signature);
|
|
151
|
+
if (!signature) throw new WebhookVerificationError('missing-signature', 'The delivery has no Signature header.');
|
|
152
|
+
|
|
153
|
+
const listed = header(WEBHOOK_HEADERS.signedHeaders);
|
|
154
|
+
const parts = listed ? listed.split(',').map((part) => part.trim().toLowerCase()) : [...DEFAULT_SIGNED_PARTS];
|
|
155
|
+
// A signature that doesn't cover the body proves nothing about the event.
|
|
156
|
+
if (!parts.includes('body')) throw new WebhookVerificationError('unsigned-body', 'Signed-Headers does not include the body.');
|
|
157
|
+
const body = typeof delivery.body === 'string' ? encoder.encode(delivery.body) : delivery.body;
|
|
158
|
+
const signed: Uint8Array[] = [];
|
|
159
|
+
for (const part of parts) {
|
|
160
|
+
if (part === 'body') {
|
|
161
|
+
signed.push(body);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const value = header(part);
|
|
165
|
+
if (value === undefined) throw new WebhookVerificationError('missing-header', `The signed header ${part} is missing.`);
|
|
166
|
+
signed.push(encoder.encode(value));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const expected = decodeBase64(signature);
|
|
170
|
+
const key = await hmacKey(secret, ['verify']);
|
|
171
|
+
// Web Crypto's verify compares in constant time.
|
|
172
|
+
if (!expected || !(await crypto.subtle.verify('HMAC', key, expected as BufferSource, concat(signed) as BufferSource))) {
|
|
173
|
+
throw new WebhookVerificationError('bad-signature', 'The signature does not match.');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const timestamp = header(WEBHOOK_HEADERS.timestamp) ?? '';
|
|
177
|
+
if (options.maxAgeMs !== undefined) {
|
|
178
|
+
// Zoneless, so parseTimestamp (UTC) rather than Date.parse, which would read it as local time.
|
|
179
|
+
const at = parseTimestamp(timestamp)?.getTime();
|
|
180
|
+
const now = (options.now ?? new Date()).getTime();
|
|
181
|
+
if (at === undefined || now - at > options.maxAgeMs) {
|
|
182
|
+
throw new WebhookVerificationError('stale', `The delivery is from ${timestamp || 'an unknown time'}, too long ago.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const event = parseWebhookEvent(typeof delivery.body === 'string' ? delivery.body : decoder.decode(delivery.body));
|
|
187
|
+
const idempotencyKey = header(WEBHOOK_HEADERS.idempotencyKey) ?? event.trigger.urn;
|
|
188
|
+
const attempt = toNumber(header(WEBHOOK_HEADERS.attempt));
|
|
189
|
+
const attemptsRemaining = toNumber(header(WEBHOOK_HEADERS.attemptsRemaining));
|
|
190
|
+
return {
|
|
191
|
+
event,
|
|
192
|
+
idempotencyKey,
|
|
193
|
+
timestamp,
|
|
194
|
+
...(attempt === undefined ? {} : { attempt }),
|
|
195
|
+
...(attemptsRemaining === undefined ? {} : { attemptsRemaining }),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `verifyWebhook` for a Fetch API `Request`, reading its body. */
|
|
200
|
+
export async function verifyWebhookRequest(request: Request, secret: string, options?: VerifyOptions): Promise<WebhookDelivery> {
|
|
201
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
202
|
+
return verifyWebhook({ body, headers: request.headers }, secret, options);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Reads a delivery's body as a webhook event, checking the parts handlers rely on. Throws `WebhookVerificationError`. */
|
|
206
|
+
export function parseWebhookEvent(body: string): WebhookEvent {
|
|
207
|
+
let value: unknown;
|
|
208
|
+
try {
|
|
209
|
+
value = JSON.parse(body);
|
|
210
|
+
} catch {
|
|
211
|
+
throw new WebhookVerificationError('malformed-event', 'The body is not JSON.');
|
|
212
|
+
}
|
|
213
|
+
const trigger = (value as { trigger?: Partial<WebhookTrigger> } | null)?.trigger;
|
|
214
|
+
if (!trigger || typeof trigger.urn !== 'string' || typeof trigger.subject !== 'string' || typeof trigger.event !== 'string') {
|
|
215
|
+
throw new WebhookVerificationError('malformed-event', 'The event has no trigger with a urn, subject and event.');
|
|
216
|
+
}
|
|
217
|
+
return value as WebhookEvent;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The URN of the representation an event is about. */
|
|
221
|
+
export function webhookTarget(event: WebhookEvent): string | undefined {
|
|
222
|
+
return event.v1?.urn || event.trigger.targetUrn || undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Signs a delivery the way the API does, returning the headers to send with `body`. For tests and for replaying or
|
|
227
|
+
* simulating deliveries locally.
|
|
228
|
+
*/
|
|
229
|
+
export async function signWebhook(
|
|
230
|
+
body: string,
|
|
231
|
+
secret: string,
|
|
232
|
+
{ timestamp, idempotencyKey }: { timestamp: string; idempotencyKey: string },
|
|
233
|
+
): Promise<Record<string, string>> {
|
|
234
|
+
const key = await hmacKey(secret, ['sign']);
|
|
235
|
+
const data = concat([encoder.encode(body), encoder.encode(timestamp), encoder.encode(idempotencyKey)]);
|
|
236
|
+
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, data as BufferSource));
|
|
237
|
+
return {
|
|
238
|
+
Signature: encodeBase64(mac),
|
|
239
|
+
'Signed-Headers': DEFAULT_SIGNED_PARTS.join(','),
|
|
240
|
+
'Last-Modified': timestamp,
|
|
241
|
+
'Idempotency-Key': idempotencyKey,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The task an event is about, read fresh, since events don't carry it. `null` when the task was deleted, by the event
|
|
247
|
+
* or since. Throws for events about anything but a task.
|
|
248
|
+
*/
|
|
249
|
+
export async function taskFromWebhook(event: WebhookEvent, tasks: TaskClient): Promise<Task | null> {
|
|
250
|
+
if (event.trigger.subject !== 'TASK') throw new TypeError(`The event is about a ${event.trigger.subject}, not a task.`);
|
|
251
|
+
const urn = webhookTarget(event);
|
|
252
|
+
if (!urn || event.trigger.event === 'DELETE') return null;
|
|
253
|
+
try {
|
|
254
|
+
return await tasks.get(urn);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (isBaackApiError(error) && error.isNotFound) return null;
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Remembers which deliveries were handled, so a retry isn't acted on twice. */
|
|
262
|
+
export interface IdempotencyStore {
|
|
263
|
+
/** Records the key and returns true the first time it is seen, false after. */
|
|
264
|
+
claim(key: string): Promise<boolean> | boolean;
|
|
265
|
+
/** Forgets a key whose handling failed, so a retry runs it. */
|
|
266
|
+
release?(key: string): Promise<void> | void;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Keys held in memory for `ttlMs` (default a day). Enough for one process; use a shared store (a database row with
|
|
271
|
+
* a unique key, Redis `SET NX`) when several handle deliveries.
|
|
272
|
+
*/
|
|
273
|
+
export function memoryIdempotencyStore(ttlMs = 24 * 3600_000): IdempotencyStore {
|
|
274
|
+
const seen = new Map<string, number>();
|
|
275
|
+
return {
|
|
276
|
+
claim(key) {
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
for (const [old, at] of seen) if (now - at > ttlMs) seen.delete(old);
|
|
279
|
+
if (seen.has(key)) return false;
|
|
280
|
+
seen.set(key, now);
|
|
281
|
+
return true;
|
|
282
|
+
},
|
|
283
|
+
release(key) {
|
|
284
|
+
seen.delete(key);
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface WebhookHandlerOptions {
|
|
290
|
+
secret: string;
|
|
291
|
+
/** Acts on a verified delivery seen for the first time. A throw answers 500, so the API retries. */
|
|
292
|
+
onEvent: (delivery: WebhookDelivery) => Promise<void> | void;
|
|
293
|
+
/** Default: in memory, per process. */
|
|
294
|
+
idempotency?: IdempotencyStore;
|
|
295
|
+
verify?: VerifyOptions;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A Fetch API handler (`(request) => Response`) for a webhook endpoint: verifies, skips repeats, and runs `onEvent`.
|
|
300
|
+
* Answers 401 to an untrusted delivery, 400 to an unreadable one, 200 once handled or already handled.
|
|
301
|
+
*/
|
|
302
|
+
export function webhookHandler(options: WebhookHandlerOptions): (request: Request) => Promise<Response> {
|
|
303
|
+
const idempotency = options.idempotency ?? memoryIdempotencyStore();
|
|
304
|
+
return async (request) => {
|
|
305
|
+
let delivery: WebhookDelivery;
|
|
306
|
+
try {
|
|
307
|
+
delivery = await verifyWebhookRequest(request, options.secret, options.verify);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error instanceof WebhookVerificationError) {
|
|
310
|
+
return new Response(error.message, { status: error.reason === 'malformed-event' ? 400 : 401 });
|
|
311
|
+
}
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
if (!(await idempotency.claim(delivery.idempotencyKey))) return new Response('Already handled', { status: 200 });
|
|
315
|
+
try {
|
|
316
|
+
await options.onEvent(delivery);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
// Not handled after all: let the retry run it.
|
|
319
|
+
await idempotency.release?.(delivery.idempotencyKey);
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
return new Response('OK', { status: 200 });
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const encoder = new TextEncoder();
|
|
327
|
+
const decoder = new TextDecoder();
|
|
328
|
+
|
|
329
|
+
function hmacKey(secret: string, usages: KeyUsage[]): Promise<CryptoKey> {
|
|
330
|
+
return crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, usages);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function readHeader(headers: HeaderSource, name: string): string | undefined {
|
|
334
|
+
if (typeof (headers as Headers).get === 'function') return (headers as Headers).get(name) ?? undefined;
|
|
335
|
+
const record = headers as Record<string, string | readonly string[] | undefined>;
|
|
336
|
+
const key = Object.keys(record).find((k) => k.toLowerCase() === name);
|
|
337
|
+
const value = key === undefined ? undefined : record[key];
|
|
338
|
+
return Array.isArray(value) ? value.join(', ') : (value as string | undefined);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function concat(parts: readonly Uint8Array[]): Uint8Array {
|
|
342
|
+
const result = new Uint8Array(parts.reduce((length, part) => length + part.length, 0));
|
|
343
|
+
let offset = 0;
|
|
344
|
+
for (const part of parts) {
|
|
345
|
+
result.set(part, offset);
|
|
346
|
+
offset += part.length;
|
|
347
|
+
}
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function encodeBase64(bytes: Uint8Array): string {
|
|
352
|
+
let binary = '';
|
|
353
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
354
|
+
return btoa(binary);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function decodeBase64(text: string): Uint8Array | undefined {
|
|
358
|
+
try {
|
|
359
|
+
return Uint8Array.from(atob(text.trim()), (char) => char.charCodeAt(0));
|
|
360
|
+
} catch {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function toNumber(value: string | undefined): number | undefined {
|
|
366
|
+
if (value === undefined || value === '') return undefined;
|
|
367
|
+
const number = Number(value);
|
|
368
|
+
return Number.isFinite(number) ? number : undefined;
|
|
369
|
+
}
|