@strav/herald 1.0.0-alpha.42
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/README.md +48 -0
- package/package.json +43 -0
- package/src/drivers/gbp/gbp_client.ts +155 -0
- package/src/drivers/gbp/gbp_config.ts +48 -0
- package/src/drivers/gbp/gbp_driver.ts +246 -0
- package/src/drivers/gbp/gbp_provider.ts +35 -0
- package/src/drivers/gbp/index.ts +23 -0
- package/src/drivers/meta/index.ts +21 -0
- package/src/drivers/meta/meta_client.ts +103 -0
- package/src/drivers/meta/meta_config.ts +45 -0
- package/src/drivers/meta/meta_driver.ts +313 -0
- package/src/drivers/meta/meta_provider.ts +45 -0
- package/src/drivers/meta/meta_webhook_ops.ts +227 -0
- package/src/drivers/wordpress/index.ts +24 -0
- package/src/drivers/wordpress/wordpress_client.ts +185 -0
- package/src/drivers/wordpress/wordpress_config.ts +51 -0
- package/src/drivers/wordpress/wordpress_driver.ts +277 -0
- package/src/drivers/wordpress/wordpress_provider.ts +37 -0
- package/src/drivers/wordpress/wordpress_validate.ts +122 -0
- package/src/dto/post_status.ts +14 -0
- package/src/dto/publish_input.ts +38 -0
- package/src/dto/publish_result.ts +23 -0
- package/src/dto/publish_target.ts +15 -0
- package/src/errors.ts +122 -0
- package/src/herald_capabilities.ts +37 -0
- package/src/herald_config.ts +32 -0
- package/src/herald_driver.ts +86 -0
- package/src/herald_manager.ts +169 -0
- package/src/herald_provider.ts +44 -0
- package/src/index.ts +65 -0
- package/src/ledger/apply_publication_migration.ts +53 -0
- package/src/ledger/index.ts +11 -0
- package/src/ledger/publication.ts +30 -0
- package/src/ledger/publication_repository.ts +172 -0
- package/src/ledger/publication_schema.ts +70 -0
- package/src/onboarding/complete_channel_connect.ts +154 -0
- package/src/onboarding/connect_channel_callback.ts +138 -0
- package/src/onboarding/index.ts +26 -0
- package/src/onboarding/types.ts +67 -0
- package/src/tenanted/apply_tenanted_publication_migration.ts +46 -0
- package/src/tenanted/index.ts +18 -0
- package/src/tenanted/tenanted_publication.ts +28 -0
- package/src/tenanted/tenanted_publication_repository.ts +145 -0
- package/src/tenanted/tenanted_publication_schema.ts +35 -0
- package/src/webhook/apply_herald_webhook_event_migration.ts +42 -0
- package/src/webhook/herald_event.ts +139 -0
- package/src/webhook/herald_webhook.ts +149 -0
- package/src/webhook/herald_webhook_event.ts +25 -0
- package/src/webhook/herald_webhook_event_repository.ts +65 -0
- package/src/webhook/herald_webhook_event_schema.ts +37 -0
- package/src/webhook/herald_webhook_registry.ts +65 -0
- package/src/webhook/index.ts +24 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MetaWebhookOps` — Graph API webhook verification + parsing.
|
|
3
|
+
*
|
|
4
|
+
* **Signature**: Meta signs the raw body with HMAC-SHA256 keyed by
|
|
5
|
+
* the app secret and sends the hex digest in `x-hub-signature-256`
|
|
6
|
+
* as `sha256={hex}`. (The legacy `x-hub-signature` is SHA-1; the
|
|
7
|
+
* herald route handler probes the 256 header first.) Drivers
|
|
8
|
+
* compare in constant time to defeat timing oracles.
|
|
9
|
+
*
|
|
10
|
+
* **Payload shape** (the bits we care about):
|
|
11
|
+
*
|
|
12
|
+
* {
|
|
13
|
+
* "object": "page" | "instagram",
|
|
14
|
+
* "entry": [{
|
|
15
|
+
* "id": "<page-or-ig-id>",
|
|
16
|
+
* "time": <unix>,
|
|
17
|
+
* "changes": [{
|
|
18
|
+
* "field": "feed" | "comments" | "mention" | "ratings" | ...,
|
|
19
|
+
* "value": { ... }
|
|
20
|
+
* }]
|
|
21
|
+
* }]
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* V1 normalization:
|
|
25
|
+
*
|
|
26
|
+
* - **Page feed comment** (`object='page'`, `field='feed'`,
|
|
27
|
+
* `value.item='comment'`, `value.verb='add'`)
|
|
28
|
+
* → `engagement.comment`
|
|
29
|
+
*
|
|
30
|
+
* - **Page rating/review** (`field='ratings'`) → `engagement.review`
|
|
31
|
+
*
|
|
32
|
+
* - **IG comment** (`object='instagram'`, `field='comments'`)
|
|
33
|
+
* → `engagement.comment`
|
|
34
|
+
*
|
|
35
|
+
* - Everything else → `unknown` (the dedup row still records
|
|
36
|
+
* receipt; ops can spot novel events).
|
|
37
|
+
*
|
|
38
|
+
* `event.id` is derived deterministically from the payload — Meta
|
|
39
|
+
* doesn't always include a stable event id, so we hash the salient
|
|
40
|
+
* fields (page id + change index + value.comment_id when present)
|
|
41
|
+
* to keep dedup idempotent under provider retries.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
|
|
45
|
+
import { WebhookSignatureError } from '../../errors.ts'
|
|
46
|
+
import type { WebhookOps } from '../../herald_driver.ts'
|
|
47
|
+
import type {
|
|
48
|
+
CommentEvent,
|
|
49
|
+
HeraldWebhookEvent,
|
|
50
|
+
ReviewEvent,
|
|
51
|
+
UnknownEvent,
|
|
52
|
+
} from '../../webhook/herald_event.ts'
|
|
53
|
+
|
|
54
|
+
export interface MetaWebhookOpsOptions {
|
|
55
|
+
/** Meta app secret — the HMAC key. Must match the secret configured for the Meta app. */
|
|
56
|
+
appSecret: string
|
|
57
|
+
/** Driver name to stamp onto every normalized event (`'meta'`). */
|
|
58
|
+
driverName: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface MetaPayload {
|
|
62
|
+
object?: string
|
|
63
|
+
entry?: Array<{
|
|
64
|
+
id?: string
|
|
65
|
+
time?: number
|
|
66
|
+
changes?: Array<{
|
|
67
|
+
field?: string
|
|
68
|
+
value?: Record<string, unknown>
|
|
69
|
+
}>
|
|
70
|
+
}>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class MetaWebhookOps implements WebhookOps {
|
|
74
|
+
private readonly appSecret: string
|
|
75
|
+
private readonly driverName: string
|
|
76
|
+
|
|
77
|
+
constructor(options: MetaWebhookOpsOptions) {
|
|
78
|
+
this.appSecret = options.appSecret
|
|
79
|
+
this.driverName = options.driverName
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
verifySignature(rawBody: string, signature: string | null | undefined): boolean {
|
|
83
|
+
if (!signature) {
|
|
84
|
+
throw new WebhookSignatureError('Meta webhook: missing x-hub-signature-256 header.')
|
|
85
|
+
}
|
|
86
|
+
const prefix = 'sha256='
|
|
87
|
+
if (!signature.startsWith(prefix)) {
|
|
88
|
+
throw new WebhookSignatureError(
|
|
89
|
+
`Meta webhook: signature header must start with "${prefix}", got "${signature.slice(0, 16)}…".`,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
const provided = signature.slice(prefix.length)
|
|
93
|
+
const expected = createHmac('sha256', this.appSecret).update(rawBody).digest('hex')
|
|
94
|
+
if (provided.length !== expected.length) return false
|
|
95
|
+
return timingSafeEqual(Buffer.from(provided, 'hex'), Buffer.from(expected, 'hex'))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
parse(rawBody: string): HeraldWebhookEvent[] {
|
|
99
|
+
let payload: MetaPayload
|
|
100
|
+
try {
|
|
101
|
+
payload = JSON.parse(rawBody) as MetaPayload
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
throw new WebhookSignatureError('Meta webhook: payload is not valid JSON.', {
|
|
104
|
+
cause,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const object = payload.object
|
|
109
|
+
const events: HeraldWebhookEvent[] = []
|
|
110
|
+
|
|
111
|
+
for (const [entryIdx, entry] of (payload.entry ?? []).entries()) {
|
|
112
|
+
const accountId = entry.id ?? ''
|
|
113
|
+
const occurredAt = entry.time ? new Date(entry.time * 1000) : undefined
|
|
114
|
+
const changes = entry.changes ?? []
|
|
115
|
+
for (const [changeIdx, change] of changes.entries()) {
|
|
116
|
+
const event = this.normalize({
|
|
117
|
+
object,
|
|
118
|
+
entryIdx,
|
|
119
|
+
changeIdx,
|
|
120
|
+
accountId,
|
|
121
|
+
occurredAt,
|
|
122
|
+
field: change.field,
|
|
123
|
+
value: change.value ?? {},
|
|
124
|
+
})
|
|
125
|
+
events.push(event)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return events
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private normalize(input: {
|
|
133
|
+
object: string | undefined
|
|
134
|
+
entryIdx: number
|
|
135
|
+
changeIdx: number
|
|
136
|
+
accountId: string
|
|
137
|
+
occurredAt: Date | undefined
|
|
138
|
+
field: string | undefined
|
|
139
|
+
value: Record<string, unknown>
|
|
140
|
+
}): HeraldWebhookEvent {
|
|
141
|
+
const { object, accountId, occurredAt, field, value } = input
|
|
142
|
+
const id = this.eventId(input)
|
|
143
|
+
|
|
144
|
+
const base = {
|
|
145
|
+
id,
|
|
146
|
+
provider: this.driverName,
|
|
147
|
+
accountId,
|
|
148
|
+
...(occurredAt ? { occurredAt } : {}),
|
|
149
|
+
raw: { object, field, value },
|
|
150
|
+
} as const
|
|
151
|
+
|
|
152
|
+
// Facebook Page feed comment
|
|
153
|
+
if (object === 'page' && field === 'feed' && value['item'] === 'comment') {
|
|
154
|
+
const commentId = str(value['comment_id']) ?? str(value['id']) ?? id
|
|
155
|
+
const postId = str(value['post_id'])
|
|
156
|
+
const event: CommentEvent = {
|
|
157
|
+
...base,
|
|
158
|
+
type: 'engagement.comment',
|
|
159
|
+
commentId,
|
|
160
|
+
...(postId ? { providerPostId: postId } : { providerPostId: accountId }),
|
|
161
|
+
...(str(value['from_id']) ? { authorId: str(value['from_id'])! } : {}),
|
|
162
|
+
...(str(value['from_name']) ? { authorName: str(value['from_name'])! } : {}),
|
|
163
|
+
...(str(value['message']) ? { text: str(value['message'])! } : {}),
|
|
164
|
+
...(str(value['parent_id']) ? { parentCommentId: str(value['parent_id'])! } : {}),
|
|
165
|
+
}
|
|
166
|
+
return event
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Facebook Page rating / review
|
|
170
|
+
if (object === 'page' && field === 'ratings') {
|
|
171
|
+
const reviewId = str(value['review_id']) ?? str(value['comment_id']) ?? id
|
|
172
|
+
const event: ReviewEvent = {
|
|
173
|
+
...base,
|
|
174
|
+
type: 'engagement.review',
|
|
175
|
+
reviewId,
|
|
176
|
+
...(typeof value['rating'] === 'number' ? { rating: value['rating'] as number } : {}),
|
|
177
|
+
...(str(value['review_text']) ? { text: str(value['review_text'])! } : {}),
|
|
178
|
+
...(str(value['reviewer_name']) ? { authorName: str(value['reviewer_name'])! } : {}),
|
|
179
|
+
}
|
|
180
|
+
return event
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Instagram comments
|
|
184
|
+
if (object === 'instagram' && field === 'comments') {
|
|
185
|
+
const commentId = str(value['id']) ?? id
|
|
186
|
+
const mediaId = str((value['media'] as Record<string, unknown> | undefined)?.['id'])
|
|
187
|
+
const event: CommentEvent = {
|
|
188
|
+
...base,
|
|
189
|
+
type: 'engagement.comment',
|
|
190
|
+
commentId,
|
|
191
|
+
...(mediaId ? { providerPostId: mediaId } : { providerPostId: accountId }),
|
|
192
|
+
...(str((value['from'] as Record<string, unknown> | undefined)?.['id'])
|
|
193
|
+
? { authorId: str((value['from'] as Record<string, unknown>)['id'])! }
|
|
194
|
+
: {}),
|
|
195
|
+
...(str((value['from'] as Record<string, unknown> | undefined)?.['username'])
|
|
196
|
+
? { authorName: str((value['from'] as Record<string, unknown>)['username'])! }
|
|
197
|
+
: {}),
|
|
198
|
+
...(str(value['text']) ? { text: str(value['text'])! } : {}),
|
|
199
|
+
}
|
|
200
|
+
return event
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const unknown: UnknownEvent = { ...base, type: 'unknown' }
|
|
204
|
+
return unknown
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private eventId(input: {
|
|
208
|
+
object: string | undefined
|
|
209
|
+
entryIdx: number
|
|
210
|
+
changeIdx: number
|
|
211
|
+
accountId: string
|
|
212
|
+
field: string | undefined
|
|
213
|
+
value: Record<string, unknown>
|
|
214
|
+
}): string {
|
|
215
|
+
const salient =
|
|
216
|
+
str(input.value['comment_id']) ??
|
|
217
|
+
str(input.value['id']) ??
|
|
218
|
+
str(input.value['review_id']) ??
|
|
219
|
+
`${input.entryIdx}:${input.changeIdx}`
|
|
220
|
+
const seed = `${input.object ?? ''}|${input.accountId}|${input.field ?? ''}|${salient}`
|
|
221
|
+
return createHash('sha256').update(seed).digest('hex').slice(0, 32)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function str(v: unknown): string | undefined {
|
|
226
|
+
return typeof v === 'string' ? v : undefined
|
|
227
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Public API of `@strav/herald/wordpress`.
|
|
2
|
+
//
|
|
3
|
+
// WordPress REST driver — auth via Application Password (built into
|
|
4
|
+
// WP core since 5.6, no plugin needed). Supports publish, update,
|
|
5
|
+
// delete, get, schedule, and image/link attachments. Engagement
|
|
6
|
+
// webhooks unsupported (WP doesn't ship them).
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
WordPressCredentials,
|
|
10
|
+
WordPressCredentialsResolver,
|
|
11
|
+
WordPressProviderConfig,
|
|
12
|
+
} from './wordpress_config.ts'
|
|
13
|
+
export {
|
|
14
|
+
WordPressClient,
|
|
15
|
+
type WPMedia,
|
|
16
|
+
type WPPost,
|
|
17
|
+
type WPPostBody,
|
|
18
|
+
} from './wordpress_client.ts'
|
|
19
|
+
export { WordPressDriver, type WordPressDriverOptions } from './wordpress_driver.ts'
|
|
20
|
+
export { WordPressHeraldProvider } from './wordpress_provider.ts'
|
|
21
|
+
export {
|
|
22
|
+
validateWordPressCredentials,
|
|
23
|
+
type WordPressConnectionInfo,
|
|
24
|
+
} from './wordpress_validate.ts'
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `WordPressClient` — thin wrapper over the WordPress REST API
|
|
3
|
+
* (`/wp-json/wp/v2`). Only the surfaces the driver uses are
|
|
4
|
+
* implemented; everything else apps reach for via the `raw`
|
|
5
|
+
* passthrough on `PublishInput.raw`.
|
|
6
|
+
*
|
|
7
|
+
* Auth is HTTP Basic with `(username, applicationPassword)`. The
|
|
8
|
+
* client constructs the header once at construction time.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { HeraldProviderError } from '../../errors.ts'
|
|
12
|
+
import type { WordPressCredentials } from './wordpress_config.ts'
|
|
13
|
+
|
|
14
|
+
export interface WPPostBody {
|
|
15
|
+
title?: string
|
|
16
|
+
content: string
|
|
17
|
+
status: 'publish' | 'future' | 'draft' | 'pending' | 'private'
|
|
18
|
+
date_gmt?: string
|
|
19
|
+
featured_media?: number
|
|
20
|
+
[key: string]: unknown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WPPost {
|
|
24
|
+
id: number
|
|
25
|
+
link: string
|
|
26
|
+
status: WPPostBody['status']
|
|
27
|
+
date_gmt: string
|
|
28
|
+
[key: string]: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WPMedia {
|
|
32
|
+
id: number
|
|
33
|
+
source_url: string
|
|
34
|
+
[key: string]: unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class WordPressClient {
|
|
38
|
+
private readonly fetcher: typeof fetch
|
|
39
|
+
private readonly authHeader: string
|
|
40
|
+
private readonly baseUrl: string
|
|
41
|
+
|
|
42
|
+
constructor(creds: WordPressCredentials, fetcher: typeof fetch = fetch) {
|
|
43
|
+
this.baseUrl = creds.siteUrl.replace(/\/+$/, '')
|
|
44
|
+
this.fetcher = fetcher
|
|
45
|
+
const raw = `${creds.username}:${creds.applicationPassword}`
|
|
46
|
+
this.authHeader = `Basic ${Buffer.from(raw, 'utf-8').toString('base64')}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private url(path: string): string {
|
|
50
|
+
return `${this.baseUrl}/wp-json/wp/v2${path}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async createPost(body: WPPostBody): Promise<WPPost> {
|
|
54
|
+
return this.json<WPPost>('POST', '/posts', body)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async updatePost(id: number, body: Partial<WPPostBody>): Promise<WPPost> {
|
|
58
|
+
return this.json<WPPost>('POST', `/posts/${id}`, body)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async deletePost(id: number, options: { force?: boolean } = {}): Promise<void> {
|
|
62
|
+
const force = options.force ? '?force=true' : ''
|
|
63
|
+
await this.bare('DELETE', `/posts/${id}${force}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getPost(id: number): Promise<WPPost> {
|
|
67
|
+
return this.json<WPPost>('GET', `/posts/${id}`, undefined)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fetch a remote image and upload it as a WP media attachment.
|
|
72
|
+
* Returns the created media row. Apps that already host media on
|
|
73
|
+
* WP pass the existing id via `raw.featured_media` instead.
|
|
74
|
+
*/
|
|
75
|
+
async uploadMediaFromUrl(
|
|
76
|
+
url: string,
|
|
77
|
+
options: { filename?: string; altText?: string } = {},
|
|
78
|
+
): Promise<WPMedia> {
|
|
79
|
+
const sourceRes = await this.fetcher(url)
|
|
80
|
+
if (!sourceRes.ok) {
|
|
81
|
+
throw new HeraldProviderError(
|
|
82
|
+
`WordPress driver: failed to download media from ${url} (${sourceRes.status}).`,
|
|
83
|
+
{ provider: 'wordpress', operation: 'uploadMediaFromUrl', status: 502 },
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
const bytes = await sourceRes.arrayBuffer()
|
|
87
|
+
const contentType = sourceRes.headers.get('content-type') ?? 'application/octet-stream'
|
|
88
|
+
const filename =
|
|
89
|
+
options.filename ?? url.split('/').pop()?.split('?')[0] ?? 'upload.bin'
|
|
90
|
+
|
|
91
|
+
const res = await this.fetcher(this.url('/media'), {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: this.authHeader,
|
|
95
|
+
'Content-Type': contentType,
|
|
96
|
+
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
97
|
+
},
|
|
98
|
+
body: bytes,
|
|
99
|
+
})
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const text = await safeText(res)
|
|
102
|
+
throw new HeraldProviderError(
|
|
103
|
+
`WordPress driver: media upload failed (${res.status}).`,
|
|
104
|
+
{
|
|
105
|
+
provider: 'wordpress',
|
|
106
|
+
operation: 'uploadMediaFromUrl',
|
|
107
|
+
status: 502,
|
|
108
|
+
context: { responseBody: text },
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
const media = (await res.json()) as WPMedia
|
|
113
|
+
|
|
114
|
+
if (options.altText) {
|
|
115
|
+
// alt_text is settable via PATCH on the media endpoint; failure
|
|
116
|
+
// here is non-fatal — return the media anyway.
|
|
117
|
+
await this.fetcher(this.url(`/media/${media.id}`), {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: {
|
|
120
|
+
Authorization: this.authHeader,
|
|
121
|
+
'Content-Type': 'application/json',
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify({ alt_text: options.altText }),
|
|
124
|
+
}).catch(() => {})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return media
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async json<T>(
|
|
131
|
+
method: 'GET' | 'POST' | 'DELETE',
|
|
132
|
+
path: string,
|
|
133
|
+
body: unknown,
|
|
134
|
+
): Promise<T> {
|
|
135
|
+
const res = await this.fetcher(this.url(path), {
|
|
136
|
+
method,
|
|
137
|
+
headers: {
|
|
138
|
+
Authorization: this.authHeader,
|
|
139
|
+
'Content-Type': 'application/json',
|
|
140
|
+
Accept: 'application/json',
|
|
141
|
+
},
|
|
142
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
143
|
+
})
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
const text = await safeText(res)
|
|
146
|
+
throw new HeraldProviderError(
|
|
147
|
+
`WordPress driver: ${method} ${path} failed (${res.status}).`,
|
|
148
|
+
{
|
|
149
|
+
provider: 'wordpress',
|
|
150
|
+
operation: `${method} ${path}`,
|
|
151
|
+
status: 502,
|
|
152
|
+
context: { responseBody: text },
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
return (await res.json()) as T
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async bare(method: 'DELETE', path: string): Promise<void> {
|
|
160
|
+
const res = await this.fetcher(this.url(path), {
|
|
161
|
+
method,
|
|
162
|
+
headers: { Authorization: this.authHeader },
|
|
163
|
+
})
|
|
164
|
+
if (!res.ok) {
|
|
165
|
+
const text = await safeText(res)
|
|
166
|
+
throw new HeraldProviderError(
|
|
167
|
+
`WordPress driver: ${method} ${path} failed (${res.status}).`,
|
|
168
|
+
{
|
|
169
|
+
provider: 'wordpress',
|
|
170
|
+
operation: `${method} ${path}`,
|
|
171
|
+
status: 502,
|
|
172
|
+
context: { responseBody: text },
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function safeText(res: Response): Promise<string> {
|
|
180
|
+
try {
|
|
181
|
+
return await res.text()
|
|
182
|
+
} catch {
|
|
183
|
+
return ''
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `WordPressProviderConfig` — `config.herald.providers['wordpress']`
|
|
3
|
+
* shape for the WordPress REST driver.
|
|
4
|
+
*
|
|
5
|
+
* WordPress sites authenticate per-call via HTTP Basic with a
|
|
6
|
+
* **WordPress Application Password** — built into WP core since 5.6,
|
|
7
|
+
* no OAuth plugin required. Each connected site supplies (siteUrl,
|
|
8
|
+
* username, applicationPassword).
|
|
9
|
+
*
|
|
10
|
+
* The driver supports two credential-sourcing strategies:
|
|
11
|
+
*
|
|
12
|
+
* - **Inline `sites` map** — for apps that publish to a small
|
|
13
|
+
* fixed set of sites known at boot. Keyed by `accountId` (the
|
|
14
|
+
* value `PublishTarget.accountId` resolves to — typically the
|
|
15
|
+
* site host or a stable internal id).
|
|
16
|
+
*
|
|
17
|
+
* - **`credentialsFor` resolver** — for multi-tenant apps where
|
|
18
|
+
* each tenant connects their own WP site. Apps typically fetch
|
|
19
|
+
* from a `@strav/social` `social_account` row (storing the
|
|
20
|
+
* Application Password in `access_token` and `siteUrl` in
|
|
21
|
+
* `metadata.site_url`) or from a bespoke tenant→site table.
|
|
22
|
+
*
|
|
23
|
+
* When both are provided, the resolver wins for accountIds it
|
|
24
|
+
* returns; otherwise the inline map is consulted as fallback.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface WordPressCredentials {
|
|
28
|
+
/** Absolute base URL of the WP site (e.g. `https://example.com`). No trailing slash required. */
|
|
29
|
+
siteUrl: string
|
|
30
|
+
/** WP username. */
|
|
31
|
+
username: string
|
|
32
|
+
/** WP Application Password (raw, not the obfuscated dashboard display). */
|
|
33
|
+
applicationPassword: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type WordPressCredentialsResolver = (
|
|
37
|
+
accountId: string,
|
|
38
|
+
) => Promise<WordPressCredentials | null> | WordPressCredentials | null
|
|
39
|
+
|
|
40
|
+
export interface WordPressProviderConfig {
|
|
41
|
+
driver: 'wordpress'
|
|
42
|
+
/** Inline credentials, keyed by accountId. Convenient for small-N deployments. */
|
|
43
|
+
sites?: Record<string, WordPressCredentials>
|
|
44
|
+
/** Async resolver for dynamic / multi-tenant deployments. Beats `sites` on accountId match. */
|
|
45
|
+
credentialsFor?: WordPressCredentialsResolver
|
|
46
|
+
/**
|
|
47
|
+
* Optional `fetch` override (defaults to global `fetch`). Tests
|
|
48
|
+
* inject a mock; production rarely needs this.
|
|
49
|
+
*/
|
|
50
|
+
fetch?: typeof fetch
|
|
51
|
+
}
|