@meith/api 0.31.0 → 0.32.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/package.json +2 -2
- package/src/index.ts +13 -0
- package/src/tokens.ts +48 -7
- package/src/webhooks.ts +55 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,6 +19,6 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@meith/core": "0.
|
|
22
|
+
"@meith/core": "0.32.0"
|
|
23
23
|
}
|
|
24
24
|
}
|
package/src/index.ts
CHANGED
|
@@ -44,8 +44,13 @@ export {
|
|
|
44
44
|
export {
|
|
45
45
|
type ApiTokenRecord,
|
|
46
46
|
type ApiTokenRepository,
|
|
47
|
+
authenticateFeedToken,
|
|
47
48
|
authenticateToken,
|
|
48
49
|
bearerFrom,
|
|
50
|
+
FEED_TOKEN_PREFIX,
|
|
51
|
+
type FeedTokenOutcome,
|
|
52
|
+
type FeedTokenRecord,
|
|
53
|
+
type FeedTokenRepository,
|
|
49
54
|
hashTokenSecret,
|
|
50
55
|
hasScope,
|
|
51
56
|
type IssuedToken,
|
|
@@ -55,6 +60,7 @@ export {
|
|
|
55
60
|
parseToken,
|
|
56
61
|
SCOPES,
|
|
57
62
|
type Scope,
|
|
63
|
+
secretMatches,
|
|
58
64
|
TOKEN_PREFIX,
|
|
59
65
|
type TokenFailure,
|
|
60
66
|
type TokenOutcome,
|
|
@@ -64,6 +70,9 @@ export {
|
|
|
64
70
|
type DeliveryVerdict,
|
|
65
71
|
deliveryHeaders,
|
|
66
72
|
EVENT_HEADER,
|
|
73
|
+
formatWebhookPayload,
|
|
74
|
+
generateWebhookSecret,
|
|
75
|
+
isWebhookFormat,
|
|
67
76
|
isWebhookTopic,
|
|
68
77
|
MAX_ATTEMPTS,
|
|
69
78
|
nextRetryDelaySeconds,
|
|
@@ -73,8 +82,12 @@ export {
|
|
|
73
82
|
TIMESTAMP_HEADER,
|
|
74
83
|
verdictFor,
|
|
75
84
|
verifySignature,
|
|
85
|
+
WEBHOOK_FORMATS,
|
|
86
|
+
WEBHOOK_SECRET_PREFIX,
|
|
76
87
|
WEBHOOK_TOPICS,
|
|
77
88
|
type WebhookDelivery,
|
|
89
|
+
type WebhookFormat,
|
|
78
90
|
type WebhookSubscription,
|
|
79
91
|
type WebhookTopic,
|
|
92
|
+
webhookEventUrl,
|
|
80
93
|
} from './webhooks'
|
package/src/tokens.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
|
2
2
|
|
|
3
3
|
export const TOKEN_PREFIX = 'forum_pat'
|
|
4
4
|
|
|
5
|
+
export const FEED_TOKEN_PREFIX = 'forum_feed'
|
|
6
|
+
|
|
5
7
|
const LOOKUP_LENGTH = 8
|
|
6
8
|
const SECRET_BYTES = 32
|
|
7
9
|
|
|
@@ -53,12 +55,20 @@ export function hashTokenSecret(secret: string): string {
|
|
|
53
55
|
return createHash('sha256').update(secret, 'utf8').digest('hex')
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
const DECOY_HASH = hashTokenSecret('')
|
|
59
|
+
|
|
60
|
+
export function secretMatches(presentedSecret: string, storedHash: string): boolean {
|
|
61
|
+
const candidate = Buffer.from(hashTokenSecret(presentedSecret), 'hex')
|
|
62
|
+
const stored = Buffer.from(storedHash, 'hex')
|
|
63
|
+
return candidate.length === stored.length && timingSafeEqual(candidate, stored)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function issueToken(prefix: string = TOKEN_PREFIX): IssuedToken {
|
|
57
67
|
const lookup = randomBytes(LOOKUP_LENGTH).toString('hex').slice(0, LOOKUP_LENGTH)
|
|
58
68
|
const secret = randomBytes(SECRET_BYTES).toString('base64url')
|
|
59
69
|
|
|
60
70
|
return {
|
|
61
|
-
token: `${
|
|
71
|
+
token: `${prefix}_${lookup}_${secret}`,
|
|
62
72
|
lookup,
|
|
63
73
|
secretHash: hashTokenSecret(secret),
|
|
64
74
|
}
|
|
@@ -69,8 +79,8 @@ export interface ParsedToken {
|
|
|
69
79
|
readonly secret: string
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
export function parseToken(presented: string): ParsedToken | null {
|
|
73
|
-
const marker = `${
|
|
82
|
+
export function parseToken(presented: string, prefix: string = TOKEN_PREFIX): ParsedToken | null {
|
|
83
|
+
const marker = `${prefix}_`
|
|
74
84
|
if (!presented.startsWith(marker)) return null
|
|
75
85
|
|
|
76
86
|
const rest = presented.slice(marker.length)
|
|
@@ -101,9 +111,7 @@ export async function authenticateToken(
|
|
|
101
111
|
const record = await repository.findByLookup(parsed.lookup)
|
|
102
112
|
if (record === null) return { ok: false, reason: 'unknown' }
|
|
103
113
|
|
|
104
|
-
|
|
105
|
-
const stored = Buffer.from(record.secretHash, 'hex')
|
|
106
|
-
if (candidate.length !== stored.length || !timingSafeEqual(candidate, stored)) {
|
|
114
|
+
if (!secretMatches(parsed.secret, record.secretHash)) {
|
|
107
115
|
return { ok: false, reason: 'bad-secret' }
|
|
108
116
|
}
|
|
109
117
|
|
|
@@ -115,6 +123,39 @@ export async function authenticateToken(
|
|
|
115
123
|
return { ok: true, token: record }
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
export interface FeedTokenRecord {
|
|
127
|
+
readonly id: number
|
|
128
|
+
readonly userId: number
|
|
129
|
+
readonly lookup: string
|
|
130
|
+
readonly secretHash: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface FeedTokenRepository {
|
|
134
|
+
findByLookup(lookup: string): Promise<FeedTokenRecord | null>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type FeedTokenOutcome =
|
|
138
|
+
| { readonly ok: true; readonly record: FeedTokenRecord }
|
|
139
|
+
| { readonly ok: false }
|
|
140
|
+
|
|
141
|
+
export async function authenticateFeedToken(
|
|
142
|
+
presented: string,
|
|
143
|
+
repository: FeedTokenRepository,
|
|
144
|
+
): Promise<FeedTokenOutcome> {
|
|
145
|
+
const parsed = parseToken(presented, FEED_TOKEN_PREFIX)
|
|
146
|
+
if (parsed === null) return { ok: false }
|
|
147
|
+
|
|
148
|
+
const record = await repository.findByLookup(parsed.lookup)
|
|
149
|
+
if (record === null) {
|
|
150
|
+
secretMatches(parsed.secret, DECOY_HASH)
|
|
151
|
+
return { ok: false }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!secretMatches(parsed.secret, record.secretHash)) return { ok: false }
|
|
155
|
+
|
|
156
|
+
return { ok: true, record }
|
|
157
|
+
}
|
|
158
|
+
|
|
118
159
|
export function hasScope(token: ApiTokenRecord, scope: Scope): boolean {
|
|
119
160
|
return token.scopes.includes(scope)
|
|
120
161
|
}
|
package/src/webhooks.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const WEBHOOK_SECRET_PREFIX = 'whsec_'
|
|
4
|
+
|
|
5
|
+
export function generateWebhookSecret(): string {
|
|
6
|
+
return `${WEBHOOK_SECRET_PREFIX}${randomBytes(24).toString('base64url')}`
|
|
7
|
+
}
|
|
2
8
|
|
|
3
9
|
export const SIGNATURE_HEADER = 'x-forum-signature'
|
|
4
10
|
export const TIMESTAMP_HEADER = 'x-forum-timestamp'
|
|
@@ -94,3 +100,51 @@ export function verdictFor(status: number, attempt: number): DeliveryVerdict {
|
|
|
94
100
|
if (status === 410) return 'dead'
|
|
95
101
|
return nextRetryDelaySeconds(attempt) === null ? 'dead' : 'retry'
|
|
96
102
|
}
|
|
103
|
+
|
|
104
|
+
export const WEBHOOK_FORMATS = ['json', 'discord'] as const
|
|
105
|
+
|
|
106
|
+
export type WebhookFormat = (typeof WEBHOOK_FORMATS)[number]
|
|
107
|
+
|
|
108
|
+
export function isWebhookFormat(value: string): value is WebhookFormat {
|
|
109
|
+
return (WEBHOOK_FORMATS as readonly string[]).includes(value)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function webhookEventUrl(
|
|
113
|
+
boardUrl: string,
|
|
114
|
+
topic: WebhookTopic,
|
|
115
|
+
ids: Readonly<Record<string, unknown>>,
|
|
116
|
+
): string | null {
|
|
117
|
+
const base = boardUrl.replace(/\/+$/, '')
|
|
118
|
+
if (base === '') return null
|
|
119
|
+
|
|
120
|
+
const threadId = ids.threadId
|
|
121
|
+
const postId = ids.postId
|
|
122
|
+
|
|
123
|
+
if (topic === 'thread.created' && typeof threadId === 'number') {
|
|
124
|
+
return `${base}/threads/${threadId}`
|
|
125
|
+
}
|
|
126
|
+
if (
|
|
127
|
+
(topic === 'post.created' || topic === 'post.edited' || topic === 'post.deleted') &&
|
|
128
|
+
typeof threadId === 'number' &&
|
|
129
|
+
typeof postId === 'number'
|
|
130
|
+
) {
|
|
131
|
+
return `${base}/threads/${threadId}#post-${postId}`
|
|
132
|
+
}
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function formatWebhookPayload(
|
|
137
|
+
topic: WebhookTopic,
|
|
138
|
+
ids: Readonly<Record<string, unknown>>,
|
|
139
|
+
format: WebhookFormat,
|
|
140
|
+
boardUrl: string,
|
|
141
|
+
): Record<string, unknown> {
|
|
142
|
+
const url = webhookEventUrl(boardUrl, topic, ids)
|
|
143
|
+
|
|
144
|
+
if (format === 'discord') {
|
|
145
|
+
const base = boardUrl.replace(/\/+$/, '')
|
|
146
|
+
return { content: url ?? (base === '' ? topic : `${topic} — ${base}`) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { event: topic, ...ids, ...(url === null ? {} : { url }) }
|
|
150
|
+
}
|