@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/api",
3
- "version": "0.31.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.31.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
- export function issueToken(): IssuedToken {
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: `${TOKEN_PREFIX}_${lookup}_${secret}`,
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 = `${TOKEN_PREFIX}_`
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
- 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)) {
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
+ }