@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/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
+ }
@@ -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
+ }