@meith/api 0.16.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/LICENSE.md +165 -0
- package/package.json +24 -0
- package/src/index.ts +80 -0
- package/src/openapi.ts +208 -0
- package/src/rate-limit.ts +44 -0
- package/src/reference.ts +251 -0
- package/src/routes.ts +516 -0
- package/src/schema.ts +337 -0
- package/src/tokens.ts +126 -0
- package/src/webhooks.ts +96 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
export type ScalarType = 'string' | 'integer' | 'number' | 'boolean'
|
|
2
|
+
|
|
3
|
+
export interface ScalarSchema {
|
|
4
|
+
readonly type: ScalarType
|
|
5
|
+
readonly description: string
|
|
6
|
+
readonly format?: string
|
|
7
|
+
readonly enum?: readonly string[]
|
|
8
|
+
readonly nullable?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ObjectSchema {
|
|
12
|
+
readonly type: 'object'
|
|
13
|
+
readonly description?: string
|
|
14
|
+
readonly properties: Readonly<Record<string, Schema>>
|
|
15
|
+
readonly required: readonly string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ArraySchema {
|
|
19
|
+
readonly type: 'array'
|
|
20
|
+
readonly description?: string
|
|
21
|
+
readonly items: Schema
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RefSchema {
|
|
25
|
+
readonly ref: ComponentName
|
|
26
|
+
readonly description?: string
|
|
27
|
+
readonly nullable?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Schema = ScalarSchema | ObjectSchema | ArraySchema | RefSchema
|
|
31
|
+
|
|
32
|
+
export function text(
|
|
33
|
+
description: string,
|
|
34
|
+
extra: Partial<Omit<ScalarSchema, 'type'>> = {},
|
|
35
|
+
): ScalarSchema {
|
|
36
|
+
return { type: 'string', description, ...extra }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function integer(
|
|
40
|
+
description: string,
|
|
41
|
+
extra: Partial<Omit<ScalarSchema, 'type'>> = {},
|
|
42
|
+
): ScalarSchema {
|
|
43
|
+
return { type: 'integer', description, ...extra }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function boolean(description: string): ScalarSchema {
|
|
47
|
+
return { type: 'boolean', description }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function timestamp(description: string, nullable = false): ScalarSchema {
|
|
51
|
+
return { type: 'string', description, format: 'date-time', ...(nullable ? { nullable } : {}) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function choice(
|
|
55
|
+
description: string,
|
|
56
|
+
values: readonly string[],
|
|
57
|
+
nullable = false,
|
|
58
|
+
): ScalarSchema {
|
|
59
|
+
return { type: 'string', description, enum: values, ...(nullable ? { nullable } : {}) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function list(items: Schema, description?: string): ArraySchema {
|
|
63
|
+
return { type: 'array', items, ...(description === undefined ? {} : { description }) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function object(
|
|
67
|
+
properties: Readonly<Record<string, Schema>>,
|
|
68
|
+
required: readonly string[] = Object.keys(properties),
|
|
69
|
+
description?: string,
|
|
70
|
+
): ObjectSchema {
|
|
71
|
+
return {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties,
|
|
74
|
+
required,
|
|
75
|
+
...(description === undefined ? {} : { description }),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ref(name: ComponentName, description?: string): RefSchema {
|
|
80
|
+
return { ref: name, ...(description === undefined ? {} : { description }) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function nullableRef(name: ComponentName, description?: string): RefSchema {
|
|
84
|
+
return { ref: name, nullable: true, ...(description === undefined ? {} : { description }) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function envelope(item: Schema): ObjectSchema {
|
|
88
|
+
return object({ data: item })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function page(
|
|
92
|
+
item: Schema,
|
|
93
|
+
cursor: 'nextCursor' | 'nextAfterId' | 'nextBefore',
|
|
94
|
+
): ObjectSchema {
|
|
95
|
+
const cursorSchema =
|
|
96
|
+
cursor === 'nextCursor'
|
|
97
|
+
? text('Pass back as `after` for the next page, or `null` at the end.', { nullable: true })
|
|
98
|
+
: integer('Pass back as `after` for the next page, or `null` at the end.', {
|
|
99
|
+
nullable: true,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return object({ data: list(item), [cursor]: cursorSchema })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const COMPONENT_NAMES = [
|
|
106
|
+
'Error',
|
|
107
|
+
'Forum',
|
|
108
|
+
'Identity',
|
|
109
|
+
'Member',
|
|
110
|
+
'Message',
|
|
111
|
+
'MessageSummary',
|
|
112
|
+
'Participant',
|
|
113
|
+
'Poll',
|
|
114
|
+
'PollOption',
|
|
115
|
+
'PollVoter',
|
|
116
|
+
'Post',
|
|
117
|
+
'SearchHit',
|
|
118
|
+
'Subscription',
|
|
119
|
+
'Thread',
|
|
120
|
+
] as const
|
|
121
|
+
|
|
122
|
+
export type ComponentName = (typeof COMPONENT_NAMES)[number]
|
|
123
|
+
|
|
124
|
+
export const VISIBILITY = ['visible', 'unapproved', 'deleted'] as const
|
|
125
|
+
export const SUBSCRIPTION_TARGETS = ['thread', 'forum'] as const
|
|
126
|
+
export const SUBSCRIPTION_MODES = ['instant', 'daily', 'weekly', 'none'] as const
|
|
127
|
+
export const MESSAGE_FOLDERS = ['inbox', 'sent', 'trash'] as const
|
|
128
|
+
export const MESSAGE_ROLES = ['author', 'to', 'bcc'] as const
|
|
129
|
+
|
|
130
|
+
export const COMPONENTS: Readonly<Record<ComponentName, ObjectSchema>> = {
|
|
131
|
+
Error: object(
|
|
132
|
+
{
|
|
133
|
+
error: object({
|
|
134
|
+
code: text('Stable and machine-readable. Branch on this, never on `message`.'),
|
|
135
|
+
message: text('For a human reading a terminal. Translated to the board’s language.'),
|
|
136
|
+
requestId: text('The board’s correlation id, for quoting in a report.', {
|
|
137
|
+
nullable: true,
|
|
138
|
+
}),
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
141
|
+
['error'],
|
|
142
|
+
'Every failure on every endpoint has this shape.',
|
|
143
|
+
),
|
|
144
|
+
|
|
145
|
+
Forum: object(
|
|
146
|
+
{
|
|
147
|
+
id: integer('The forum’s id.'),
|
|
148
|
+
title: text('The forum’s name.'),
|
|
149
|
+
slug: text('The URL-safe form of the title.'),
|
|
150
|
+
type: choice('What the forum holds.', ['category', 'forum', 'link']),
|
|
151
|
+
parentId: integer('The forum this one sits under, or `null` at the top.', {
|
|
152
|
+
nullable: true,
|
|
153
|
+
}),
|
|
154
|
+
depth: integer('How far down the tree the forum sits; the top level is 0.'),
|
|
155
|
+
},
|
|
156
|
+
undefined,
|
|
157
|
+
'One forum, as the token’s owner may see it.',
|
|
158
|
+
),
|
|
159
|
+
|
|
160
|
+
Identity: object(
|
|
161
|
+
{
|
|
162
|
+
userId: integer('The account the token belongs to.'),
|
|
163
|
+
username: text('That account’s name.'),
|
|
164
|
+
scopes: list(text('One scope.'), 'Every scope this token carries.'),
|
|
165
|
+
},
|
|
166
|
+
undefined,
|
|
167
|
+
'Who the caller is, and what this token may ask for.',
|
|
168
|
+
),
|
|
169
|
+
|
|
170
|
+
Member: object(
|
|
171
|
+
{
|
|
172
|
+
id: integer('The member’s user id.'),
|
|
173
|
+
username: text('The member’s name.'),
|
|
174
|
+
title: text('Their custom or group title.', { nullable: true }),
|
|
175
|
+
postCount: integer('How many posts they have made.'),
|
|
176
|
+
joinedAt: timestamp('When they registered.'),
|
|
177
|
+
lastActiveAt: timestamp('When they were last seen, if the board records it.', true),
|
|
178
|
+
location: text('Their stated location.', { nullable: true }),
|
|
179
|
+
website: text('Their stated website.', { nullable: true }),
|
|
180
|
+
bio: text('Their stated biography.', { nullable: true }),
|
|
181
|
+
},
|
|
182
|
+
undefined,
|
|
183
|
+
'A member’s public profile — the same fields the member page shows a visitor.',
|
|
184
|
+
),
|
|
185
|
+
|
|
186
|
+
Message: object(
|
|
187
|
+
{
|
|
188
|
+
id: integer('The message’s id.'),
|
|
189
|
+
copyId: integer('The caller’s copy of the message, which folders and reads act on.'),
|
|
190
|
+
authorUserId: integer('Who sent it, or `null` if the account is gone.', { nullable: true }),
|
|
191
|
+
authorUsername: text('The sender’s name as it was at the time.'),
|
|
192
|
+
subject: text('The subject line.'),
|
|
193
|
+
message: text('The body, as Markdown source.'),
|
|
194
|
+
folder: choice('Which of the caller’s folders this copy sits in.', MESSAGE_FOLDERS),
|
|
195
|
+
role: choice('How the caller is on the message.', MESSAGE_ROLES),
|
|
196
|
+
receiptRequested: boolean('Whether the sender asked to be told it was read.'),
|
|
197
|
+
replyToId: integer('The message this one replies to, if any.', { nullable: true }),
|
|
198
|
+
sentAt: timestamp('When it was sent.'),
|
|
199
|
+
readAt: timestamp('When the caller read it, or `null`.', true),
|
|
200
|
+
participants: list(ref('Participant'), 'Everyone on the message the caller may see.'),
|
|
201
|
+
},
|
|
202
|
+
undefined,
|
|
203
|
+
'One private message, as one participant sees it. Reading marks it read.',
|
|
204
|
+
),
|
|
205
|
+
|
|
206
|
+
MessageSummary: object(
|
|
207
|
+
{
|
|
208
|
+
copyId: integer('The caller’s copy of the message. Page on this.'),
|
|
209
|
+
messageId: integer('The message itself.'),
|
|
210
|
+
folder: choice('Which folder this copy sits in.', MESSAGE_FOLDERS),
|
|
211
|
+
role: choice('How the caller is on the message.', MESSAGE_ROLES),
|
|
212
|
+
subject: text('The subject line.'),
|
|
213
|
+
sentAt: timestamp('When it was sent.'),
|
|
214
|
+
readAt: timestamp('When the caller read it, or `null`.', true),
|
|
215
|
+
counterparties: list(text('One name.'), 'The other people on the message.'),
|
|
216
|
+
moreCounterparties: integer('How many further names were not listed.'),
|
|
217
|
+
},
|
|
218
|
+
undefined,
|
|
219
|
+
'One row of a message folder. Listing does not mark anything read.',
|
|
220
|
+
),
|
|
221
|
+
|
|
222
|
+
Participant: object(
|
|
223
|
+
{
|
|
224
|
+
userId: integer('The participant’s user id.'),
|
|
225
|
+
username: text('Their name.'),
|
|
226
|
+
role: choice('How they are on the message.', MESSAGE_ROLES),
|
|
227
|
+
readAt: timestamp('When they read it, or `null`.', true),
|
|
228
|
+
},
|
|
229
|
+
undefined,
|
|
230
|
+
'One person on a message. Blind copies are visible only to themselves and the sender.',
|
|
231
|
+
),
|
|
232
|
+
|
|
233
|
+
Poll: object(
|
|
234
|
+
{
|
|
235
|
+
id: integer('The poll’s id.'),
|
|
236
|
+
threadId: integer('The thread the poll is attached to.'),
|
|
237
|
+
question: text('What the poll asks.'),
|
|
238
|
+
closesAt: timestamp('When voting closes, or `null` if it does not.', true),
|
|
239
|
+
maxOptions: integer('How many options one member may pick. 0 means no limit.'),
|
|
240
|
+
allowRevote: boolean('Whether a member may change their vote.'),
|
|
241
|
+
publicVotes: boolean('Whether the voters are named on each option.'),
|
|
242
|
+
options: list(ref('PollOption'), 'What may be voted for.'),
|
|
243
|
+
votedOptionId: integer('The first option the caller voted for, or `null`.', {
|
|
244
|
+
nullable: true,
|
|
245
|
+
}),
|
|
246
|
+
votedOptionIds: list(integer('An option the caller voted for.'), 'Everything they picked.'),
|
|
247
|
+
},
|
|
248
|
+
undefined,
|
|
249
|
+
'A thread’s poll.',
|
|
250
|
+
),
|
|
251
|
+
|
|
252
|
+
PollOption: object(
|
|
253
|
+
{
|
|
254
|
+
id: integer('The option’s id. Vote with this.'),
|
|
255
|
+
label: text('The option as it is shown.'),
|
|
256
|
+
votes: integer('How many votes it has.'),
|
|
257
|
+
voters: list(ref('PollVoter'), 'Who voted for it, on a poll with public votes.'),
|
|
258
|
+
},
|
|
259
|
+
undefined,
|
|
260
|
+
'One option on a poll.',
|
|
261
|
+
),
|
|
262
|
+
|
|
263
|
+
PollVoter: object(
|
|
264
|
+
{
|
|
265
|
+
userId: integer('Who voted.'),
|
|
266
|
+
username: text('Their name.'),
|
|
267
|
+
votedAt: timestamp('When they voted, or `null` if the board did not record it.', true),
|
|
268
|
+
},
|
|
269
|
+
undefined,
|
|
270
|
+
'One named voter on a poll that makes its votes public.',
|
|
271
|
+
),
|
|
272
|
+
|
|
273
|
+
Post: object(
|
|
274
|
+
{
|
|
275
|
+
id: integer('The post’s id.'),
|
|
276
|
+
threadId: integer('The thread it is in.'),
|
|
277
|
+
number: integer('Its position in the thread, counting from 1.'),
|
|
278
|
+
authorUserId: integer('Who wrote it, or `null` if the account is gone.', { nullable: true }),
|
|
279
|
+
authorUsername: text('The author’s name as it was at the time.'),
|
|
280
|
+
message: text('The body, as Markdown source.'),
|
|
281
|
+
visibility: choice('Whether the post is public, held, or removed.', VISIBILITY),
|
|
282
|
+
postedAt: timestamp('When it was posted.'),
|
|
283
|
+
},
|
|
284
|
+
undefined,
|
|
285
|
+
'One post, as the caller may see it.',
|
|
286
|
+
),
|
|
287
|
+
|
|
288
|
+
SearchHit: object(
|
|
289
|
+
{
|
|
290
|
+
postId: integer('The matching post.'),
|
|
291
|
+
threadId: integer('The thread it is in.'),
|
|
292
|
+
forumId: integer('The forum that thread is in.'),
|
|
293
|
+
threadTitle: text('The thread’s title.'),
|
|
294
|
+
authorUserId: integer('Who wrote it, or `null`.', { nullable: true }),
|
|
295
|
+
authorUsername: text('The author’s name.'),
|
|
296
|
+
postedAt: timestamp('When it was posted.'),
|
|
297
|
+
excerpt: text('The matching passage, word-filtered as the board filters it.'),
|
|
298
|
+
},
|
|
299
|
+
undefined,
|
|
300
|
+
'One search result.',
|
|
301
|
+
),
|
|
302
|
+
|
|
303
|
+
Subscription: object(
|
|
304
|
+
{
|
|
305
|
+
target: choice('What is being followed.', SUBSCRIPTION_TARGETS),
|
|
306
|
+
targetId: integer('The id of the thread or forum being followed.'),
|
|
307
|
+
title: text('Its title.'),
|
|
308
|
+
href: text('Where it lives on the board.'),
|
|
309
|
+
mode: choice('How the follower is told about new posts.', SUBSCRIPTION_MODES),
|
|
310
|
+
createdAt: timestamp('When the subscription was made.'),
|
|
311
|
+
pending: integer('Posts since the follower last caught up.'),
|
|
312
|
+
},
|
|
313
|
+
undefined,
|
|
314
|
+
'One thing the caller follows.',
|
|
315
|
+
),
|
|
316
|
+
|
|
317
|
+
Thread: object(
|
|
318
|
+
{
|
|
319
|
+
id: integer('The thread’s id.'),
|
|
320
|
+
forumId: integer('The forum it is in.'),
|
|
321
|
+
title: text('The thread’s title.'),
|
|
322
|
+
slug: text('The URL-safe form of the title.'),
|
|
323
|
+
authorUserId: integer('Who started it, or `null` if the account is gone.', {
|
|
324
|
+
nullable: true,
|
|
325
|
+
}),
|
|
326
|
+
authorUsername: text('The starter’s name as it was at the time.'),
|
|
327
|
+
replyCount: integer('How many replies it has.'),
|
|
328
|
+
viewCount: integer('How many times it has been read.'),
|
|
329
|
+
isSticky: boolean('Whether it is pinned to the top of its forum.'),
|
|
330
|
+
isLocked: boolean('Whether it is closed to further replies.'),
|
|
331
|
+
visibility: choice('Whether the thread is public, held, or removed.', VISIBILITY),
|
|
332
|
+
lastPostAt: timestamp('When it was last posted in.'),
|
|
333
|
+
},
|
|
334
|
+
undefined,
|
|
335
|
+
'One thread, as the caller may see it.',
|
|
336
|
+
),
|
|
337
|
+
}
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const TOKEN_PREFIX = 'forum_pat'
|
|
4
|
+
|
|
5
|
+
const LOOKUP_LENGTH = 8
|
|
6
|
+
const SECRET_BYTES = 32
|
|
7
|
+
|
|
8
|
+
export const SCOPES = [
|
|
9
|
+
'forums:read',
|
|
10
|
+
'threads:read',
|
|
11
|
+
'threads:write',
|
|
12
|
+
'posts:read',
|
|
13
|
+
'posts:write',
|
|
14
|
+
'members:read',
|
|
15
|
+
'messages:read',
|
|
16
|
+
'messages:write',
|
|
17
|
+
'polls:write',
|
|
18
|
+
'reputation:write',
|
|
19
|
+
'subscriptions:read',
|
|
20
|
+
'subscriptions:write',
|
|
21
|
+
'search:read',
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
export type Scope = (typeof SCOPES)[number]
|
|
25
|
+
|
|
26
|
+
export function isScope(value: string): value is Scope {
|
|
27
|
+
return (SCOPES as readonly string[]).includes(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ApiTokenRecord {
|
|
31
|
+
readonly id: number
|
|
32
|
+
readonly userId: number
|
|
33
|
+
readonly name: string
|
|
34
|
+
readonly lookup: string
|
|
35
|
+
readonly secretHash: string
|
|
36
|
+
readonly scopes: readonly Scope[]
|
|
37
|
+
readonly expiresAt: Date | null
|
|
38
|
+
readonly revokedAt: Date | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface IssuedToken {
|
|
42
|
+
readonly token: string
|
|
43
|
+
readonly lookup: string
|
|
44
|
+
readonly secretHash: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ApiTokenRepository {
|
|
48
|
+
findByLookup(lookup: string): Promise<ApiTokenRecord | null>
|
|
49
|
+
touch(id: number, at: Date): Promise<void>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function hashTokenSecret(secret: string): string {
|
|
53
|
+
return createHash('sha256').update(secret, 'utf8').digest('hex')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function issueToken(): IssuedToken {
|
|
57
|
+
const lookup = randomBytes(LOOKUP_LENGTH).toString('hex').slice(0, LOOKUP_LENGTH)
|
|
58
|
+
const secret = randomBytes(SECRET_BYTES).toString('base64url')
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
token: `${TOKEN_PREFIX}_${lookup}_${secret}`,
|
|
62
|
+
lookup,
|
|
63
|
+
secretHash: hashTokenSecret(secret),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ParsedToken {
|
|
68
|
+
readonly lookup: string
|
|
69
|
+
readonly secret: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseToken(presented: string): ParsedToken | null {
|
|
73
|
+
const marker = `${TOKEN_PREFIX}_`
|
|
74
|
+
if (!presented.startsWith(marker)) return null
|
|
75
|
+
|
|
76
|
+
const rest = presented.slice(marker.length)
|
|
77
|
+
const separator = rest.indexOf('_')
|
|
78
|
+
if (separator === -1) return null
|
|
79
|
+
|
|
80
|
+
const lookup = rest.slice(0, separator)
|
|
81
|
+
const secret = rest.slice(separator + 1)
|
|
82
|
+
if (lookup.length !== LOOKUP_LENGTH || secret.length < 20) return null
|
|
83
|
+
|
|
84
|
+
return { lookup, secret }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type TokenFailure = 'malformed' | 'unknown' | 'revoked' | 'expired' | 'bad-secret'
|
|
88
|
+
|
|
89
|
+
export type TokenOutcome =
|
|
90
|
+
| { readonly ok: true; readonly token: ApiTokenRecord }
|
|
91
|
+
| { readonly ok: false; readonly reason: TokenFailure }
|
|
92
|
+
|
|
93
|
+
export async function authenticateToken(
|
|
94
|
+
presented: string,
|
|
95
|
+
repository: ApiTokenRepository,
|
|
96
|
+
now: Date,
|
|
97
|
+
): Promise<TokenOutcome> {
|
|
98
|
+
const parsed = parseToken(presented)
|
|
99
|
+
if (parsed === null) return { ok: false, reason: 'malformed' }
|
|
100
|
+
|
|
101
|
+
const record = await repository.findByLookup(parsed.lookup)
|
|
102
|
+
if (record === null) return { ok: false, reason: 'unknown' }
|
|
103
|
+
|
|
104
|
+
const candidate = Buffer.from(hashTokenSecret(parsed.secret), 'hex')
|
|
105
|
+
const stored = Buffer.from(record.secretHash, 'hex')
|
|
106
|
+
if (candidate.length !== stored.length || !timingSafeEqual(candidate, stored)) {
|
|
107
|
+
return { ok: false, reason: 'bad-secret' }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (record.revokedAt !== null) return { ok: false, reason: 'revoked' }
|
|
111
|
+
if (record.expiresAt !== null && record.expiresAt.getTime() <= now.getTime()) {
|
|
112
|
+
return { ok: false, reason: 'expired' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { ok: true, token: record }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function hasScope(token: ApiTokenRecord, scope: Scope): boolean {
|
|
119
|
+
return token.scopes.includes(scope)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function bearerFrom(header: string | null): string | null {
|
|
123
|
+
if (header === null) return null
|
|
124
|
+
const match = /^bearer\s+(\S+)$/i.exec(header.trim())
|
|
125
|
+
return match?.[1] ?? null
|
|
126
|
+
}
|
package/src/webhooks.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const SIGNATURE_HEADER = 'x-forum-signature'
|
|
4
|
+
export const TIMESTAMP_HEADER = 'x-forum-timestamp'
|
|
5
|
+
export const EVENT_HEADER = 'x-forum-event'
|
|
6
|
+
export const DELIVERY_HEADER = 'x-forum-delivery'
|
|
7
|
+
|
|
8
|
+
export const REPLAY_TOLERANCE_SECONDS = 300
|
|
9
|
+
|
|
10
|
+
export const MAX_ATTEMPTS = 6
|
|
11
|
+
|
|
12
|
+
export const WEBHOOK_TOPICS = [
|
|
13
|
+
'thread.created',
|
|
14
|
+
'post.created',
|
|
15
|
+
'post.edited',
|
|
16
|
+
'post.deleted',
|
|
17
|
+
'user.registered',
|
|
18
|
+
'report.created',
|
|
19
|
+
] as const
|
|
20
|
+
|
|
21
|
+
export type WebhookTopic = (typeof WEBHOOK_TOPICS)[number]
|
|
22
|
+
|
|
23
|
+
export function isWebhookTopic(value: string): value is WebhookTopic {
|
|
24
|
+
return (WEBHOOK_TOPICS as readonly string[]).includes(value)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WebhookSubscription {
|
|
28
|
+
readonly id: number
|
|
29
|
+
readonly url: string
|
|
30
|
+
readonly secret: string
|
|
31
|
+
readonly topics: readonly WebhookTopic[]
|
|
32
|
+
readonly active: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface WebhookDelivery {
|
|
36
|
+
readonly subscriptionId: number
|
|
37
|
+
readonly topic: WebhookTopic
|
|
38
|
+
readonly deliveryId: string
|
|
39
|
+
readonly payload: Record<string, unknown>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function signPayload(secret: string, timestampSeconds: number, body: string): string {
|
|
43
|
+
const mac = createHmac('sha256', secret)
|
|
44
|
+
mac.update(`${timestampSeconds}.${body}`, 'utf8')
|
|
45
|
+
return `sha256=${mac.digest('hex')}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function verifySignature(
|
|
49
|
+
secret: string,
|
|
50
|
+
timestampSeconds: number,
|
|
51
|
+
body: string,
|
|
52
|
+
presented: string,
|
|
53
|
+
nowSeconds: number,
|
|
54
|
+
toleranceSeconds: number = REPLAY_TOLERANCE_SECONDS,
|
|
55
|
+
): boolean {
|
|
56
|
+
if (Math.abs(nowSeconds - timestampSeconds) > toleranceSeconds) return false
|
|
57
|
+
|
|
58
|
+
const expected = Buffer.from(signPayload(secret, timestampSeconds, body), 'utf8')
|
|
59
|
+
const actual = Buffer.from(presented, 'utf8')
|
|
60
|
+
if (expected.length !== actual.length) return false
|
|
61
|
+
return timingSafeEqual(expected, actual)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function deliveryHeaders(
|
|
65
|
+
subscription: WebhookSubscription,
|
|
66
|
+
delivery: WebhookDelivery,
|
|
67
|
+
timestampSeconds: number,
|
|
68
|
+
body: string,
|
|
69
|
+
): Record<string, string> {
|
|
70
|
+
return {
|
|
71
|
+
'content-type': 'application/json',
|
|
72
|
+
[EVENT_HEADER]: delivery.topic,
|
|
73
|
+
[DELIVERY_HEADER]: delivery.deliveryId,
|
|
74
|
+
[TIMESTAMP_HEADER]: String(timestampSeconds),
|
|
75
|
+
[SIGNATURE_HEADER]: signPayload(subscription.secret, timestampSeconds, body),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function nextRetryDelaySeconds(
|
|
80
|
+
attempt: number,
|
|
81
|
+
random: () => number = Math.random,
|
|
82
|
+
): number | null {
|
|
83
|
+
if (attempt >= MAX_ATTEMPTS) return null
|
|
84
|
+
|
|
85
|
+
const base = Math.min(30 * 2 ** (attempt - 1), 3600)
|
|
86
|
+
const jitter = 1 + (random() - 0.5) / 2
|
|
87
|
+
return Math.round(base * jitter)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type DeliveryVerdict = 'delivered' | 'retry' | 'dead'
|
|
91
|
+
|
|
92
|
+
export function verdictFor(status: number, attempt: number): DeliveryVerdict {
|
|
93
|
+
if (status >= 200 && status < 300) return 'delivered'
|
|
94
|
+
if (status === 410) return 'dead'
|
|
95
|
+
return nextRetryDelaySeconds(attempt) === null ? 'dead' : 'retry'
|
|
96
|
+
}
|