@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,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `WordPressDriver` — `HeraldDriver` implementation for the WordPress
|
|
3
|
+
* REST API (`/wp-json/wp/v2`).
|
|
4
|
+
*
|
|
5
|
+
* Synchronous publish lifecycle: WP returns the created post with
|
|
6
|
+
* its id + URL in the same request — no async reconciliation needed.
|
|
7
|
+
* Scheduled posts (`scheduledFor`) come back as status `pending`
|
|
8
|
+
* until the cron flips them to `published`; apps that care reconcile
|
|
9
|
+
* by polling `get()`.
|
|
10
|
+
*
|
|
11
|
+
* Content mapping (LCD `PublishInput` → WP):
|
|
12
|
+
*
|
|
13
|
+
* - `text` becomes the post `content`. WP requires a `title` but
|
|
14
|
+
* accepts empty string — the driver passes `''` so the body is
|
|
15
|
+
* the canonical place to read. Apps that want a real title
|
|
16
|
+
* supply it via `raw.title` (the raw object is shallow-merged
|
|
17
|
+
* over the LCD body).
|
|
18
|
+
*
|
|
19
|
+
* - First `image` attachment becomes `featured_media` (uploaded
|
|
20
|
+
* via `/wp/v2/media` first). Additional images are passed
|
|
21
|
+
* through in `content` as `<figure>` tags. Apps doing structured
|
|
22
|
+
* galleries reach for `raw.content` directly.
|
|
23
|
+
*
|
|
24
|
+
* - `link` attachments are appended to `content` as `<p><a>` tags.
|
|
25
|
+
*
|
|
26
|
+
* - `hashtags` are NOT mapped to WP tags by default (WP tags are
|
|
27
|
+
* site-managed taxonomy entities, not free-form hashtags). Apps
|
|
28
|
+
* that want this set `raw.tags` to existing tag ids.
|
|
29
|
+
*
|
|
30
|
+
* - `scheduledFor` flips status to `future` + sets `date_gmt`.
|
|
31
|
+
*
|
|
32
|
+
* Webhooks: WP doesn't ship engagement webhooks out of the box.
|
|
33
|
+
* `webhook.verifySignature` and `webhook.parse` throw
|
|
34
|
+
* `ProviderUnsupportedError`. Apps that wire a webhook plugin
|
|
35
|
+
* (WP Webhooks, JetPack, …) provide their own `WebhookOps`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { PostStatus } from '../../dto/post_status.ts'
|
|
39
|
+
import type { PublishAttachment, PublishInput } from '../../dto/publish_input.ts'
|
|
40
|
+
import type { PublishResult } from '../../dto/publish_result.ts'
|
|
41
|
+
import type { PublishTarget } from '../../dto/publish_target.ts'
|
|
42
|
+
import { HeraldProviderError, ProviderUnsupportedError } from '../../errors.ts'
|
|
43
|
+
import type { HeraldCapability } from '../../herald_capabilities.ts'
|
|
44
|
+
import type { HeraldDriver, WebhookOps } from '../../herald_driver.ts'
|
|
45
|
+
import type { HeraldWebhookEvent } from '../../webhook/herald_event.ts'
|
|
46
|
+
import type {
|
|
47
|
+
WordPressCredentials,
|
|
48
|
+
WordPressCredentialsResolver,
|
|
49
|
+
WordPressProviderConfig,
|
|
50
|
+
} from './wordpress_config.ts'
|
|
51
|
+
import { type WPPostBody, WordPressClient, type WPPost } from './wordpress_client.ts'
|
|
52
|
+
|
|
53
|
+
export interface WordPressDriverOptions {
|
|
54
|
+
instanceName: string
|
|
55
|
+
sites?: Record<string, WordPressCredentials>
|
|
56
|
+
credentialsFor?: WordPressCredentialsResolver
|
|
57
|
+
fetch?: typeof fetch
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CAPABILITIES: ReadonlySet<HeraldCapability> = new Set<HeraldCapability>([
|
|
61
|
+
'post.create',
|
|
62
|
+
'post.update',
|
|
63
|
+
'post.delete',
|
|
64
|
+
'post.get',
|
|
65
|
+
'post.schedule',
|
|
66
|
+
'media.image',
|
|
67
|
+
'media.link',
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
const UNSUPPORTED_WEBHOOK: WebhookOps = {
|
|
71
|
+
verifySignature(): boolean {
|
|
72
|
+
throw new ProviderUnsupportedError('wordpress', 'webhook.verifySignature', {
|
|
73
|
+
reason: 'WordPress core does not ship engagement webhooks.',
|
|
74
|
+
})
|
|
75
|
+
},
|
|
76
|
+
parse(): HeraldWebhookEvent[] {
|
|
77
|
+
throw new ProviderUnsupportedError('wordpress', 'webhook.parse', {
|
|
78
|
+
reason: 'WordPress core does not ship engagement webhooks.',
|
|
79
|
+
})
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class WordPressDriver implements HeraldDriver {
|
|
84
|
+
readonly name = 'wordpress'
|
|
85
|
+
readonly instanceName: string
|
|
86
|
+
readonly capabilities = CAPABILITIES
|
|
87
|
+
readonly webhook: WebhookOps = UNSUPPORTED_WEBHOOK
|
|
88
|
+
|
|
89
|
+
private readonly sites: Record<string, WordPressCredentials>
|
|
90
|
+
private readonly resolver: WordPressCredentialsResolver | undefined
|
|
91
|
+
private readonly fetcher: typeof fetch
|
|
92
|
+
|
|
93
|
+
constructor(options: WordPressDriverOptions) {
|
|
94
|
+
this.instanceName = options.instanceName
|
|
95
|
+
this.sites = options.sites ?? {}
|
|
96
|
+
this.resolver = options.credentialsFor
|
|
97
|
+
this.fetcher = options.fetch ?? fetch
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static fromConfig(
|
|
101
|
+
instanceName: string,
|
|
102
|
+
config: WordPressProviderConfig,
|
|
103
|
+
): WordPressDriver {
|
|
104
|
+
return new WordPressDriver({
|
|
105
|
+
instanceName,
|
|
106
|
+
...(config.sites ? { sites: config.sites } : {}),
|
|
107
|
+
...(config.credentialsFor ? { credentialsFor: config.credentialsFor } : {}),
|
|
108
|
+
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async publish(target: PublishTarget, input: PublishInput): Promise<PublishResult> {
|
|
113
|
+
const client = await this.clientFor(target.accountId)
|
|
114
|
+
const body = await this.buildBody(client, input)
|
|
115
|
+
const created = await client.createPost(body)
|
|
116
|
+
return this.toResult(created)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async update(
|
|
120
|
+
target: PublishTarget,
|
|
121
|
+
providerPostId: string,
|
|
122
|
+
input: PublishInput,
|
|
123
|
+
): Promise<PublishResult> {
|
|
124
|
+
const client = await this.clientFor(target.accountId)
|
|
125
|
+
const body = await this.buildBody(client, input)
|
|
126
|
+
const updated = await client.updatePost(toInt(providerPostId), body)
|
|
127
|
+
return this.toResult(updated)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async delete(target: PublishTarget, providerPostId: string): Promise<void> {
|
|
131
|
+
const client = await this.clientFor(target.accountId)
|
|
132
|
+
await client.deletePost(toInt(providerPostId), { force: true })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async get(target: PublishTarget, providerPostId: string): Promise<PostStatus> {
|
|
136
|
+
const client = await this.clientFor(target.accountId)
|
|
137
|
+
const post = await client.getPost(toInt(providerPostId))
|
|
138
|
+
return mapStatus(post.status)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── internals ───────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
private async clientFor(accountId: string): Promise<WordPressClient> {
|
|
144
|
+
const creds = await this.credentialsFor(accountId)
|
|
145
|
+
if (!creds) {
|
|
146
|
+
throw new HeraldProviderError(
|
|
147
|
+
`WordPress driver: no credentials registered for account "${accountId}". Add it to \`config.herald.providers.${this.instanceName}.sites\` or return it from \`credentialsFor\`.`,
|
|
148
|
+
{ provider: 'wordpress', operation: 'clientFor', status: 500 },
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
return new WordPressClient(creds, this.fetcher)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async credentialsFor(accountId: string): Promise<WordPressCredentials | null> {
|
|
155
|
+
if (this.resolver) {
|
|
156
|
+
const resolved = await this.resolver(accountId)
|
|
157
|
+
if (resolved) return resolved
|
|
158
|
+
}
|
|
159
|
+
return this.sites[accountId] ?? null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async buildBody(
|
|
163
|
+
client: WordPressClient,
|
|
164
|
+
input: PublishInput,
|
|
165
|
+
): Promise<WPPostBody> {
|
|
166
|
+
const raw = (input.raw as Partial<WPPostBody> | undefined) ?? {}
|
|
167
|
+
|
|
168
|
+
const content = raw.content ?? renderContent(input)
|
|
169
|
+
const status: WPPostBody['status'] =
|
|
170
|
+
raw.status ?? (input.scheduledFor ? 'future' : 'publish')
|
|
171
|
+
|
|
172
|
+
const body: WPPostBody = {
|
|
173
|
+
title: raw.title ?? '',
|
|
174
|
+
content,
|
|
175
|
+
status,
|
|
176
|
+
...(input.scheduledFor && !raw.date_gmt
|
|
177
|
+
? { date_gmt: toGmtString(input.scheduledFor) }
|
|
178
|
+
: {}),
|
|
179
|
+
...raw,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// featured image — first image attachment unless raw.featured_media is set
|
|
183
|
+
if (body.featured_media === undefined) {
|
|
184
|
+
const firstImage = (input.attachments ?? []).find((a) => a.type === 'image')
|
|
185
|
+
if (firstImage) {
|
|
186
|
+
const media = await client.uploadMediaFromUrl(firstImage.url, {
|
|
187
|
+
...(firstImage.altText ? { altText: firstImage.altText } : {}),
|
|
188
|
+
})
|
|
189
|
+
body.featured_media = media.id
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return body
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private toResult(post: WPPost): PublishResult {
|
|
197
|
+
return {
|
|
198
|
+
provider: 'wordpress',
|
|
199
|
+
providerPostId: String(post.id),
|
|
200
|
+
url: post.link,
|
|
201
|
+
status: mapStatus(post.status),
|
|
202
|
+
raw: post,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderContent(input: PublishInput): string {
|
|
208
|
+
const parts: string[] = []
|
|
209
|
+
if (input.text) {
|
|
210
|
+
for (const para of input.text.split(/\n{2,}/)) {
|
|
211
|
+
parts.push(`<p>${escapeHtml(para.trim())}</p>`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const attachment of input.attachments ?? []) {
|
|
215
|
+
parts.push(renderAttachment(attachment))
|
|
216
|
+
}
|
|
217
|
+
if (input.hashtags && input.hashtags.length > 0) {
|
|
218
|
+
parts.push(
|
|
219
|
+
`<p>${input.hashtags.map((h) => `#${escapeHtml(h)}`).join(' ')}</p>`,
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
return parts.join('\n')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function renderAttachment(attachment: PublishAttachment): string {
|
|
226
|
+
switch (attachment.type) {
|
|
227
|
+
case 'image':
|
|
228
|
+
return `<figure><img src="${escapeAttr(attachment.url)}" alt="${escapeAttr(
|
|
229
|
+
attachment.altText ?? '',
|
|
230
|
+
)}" /></figure>`
|
|
231
|
+
case 'video':
|
|
232
|
+
return `<video controls src="${escapeAttr(attachment.url)}"></video>`
|
|
233
|
+
case 'link':
|
|
234
|
+
return `<p><a href="${escapeAttr(attachment.url)}">${escapeHtml(
|
|
235
|
+
attachment.title ?? attachment.url,
|
|
236
|
+
)}</a></p>`
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function mapStatus(wpStatus: WPPostBody['status']): PostStatus {
|
|
241
|
+
switch (wpStatus) {
|
|
242
|
+
case 'publish':
|
|
243
|
+
return 'published'
|
|
244
|
+
case 'future':
|
|
245
|
+
case 'draft':
|
|
246
|
+
case 'pending':
|
|
247
|
+
case 'private':
|
|
248
|
+
return 'pending'
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function toGmtString(date: Date): string {
|
|
253
|
+
// WP expects ISO without timezone, interpreted as UTC.
|
|
254
|
+
return date.toISOString().replace(/\.\d+Z$/, '')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function toInt(id: string): number {
|
|
258
|
+
const n = Number.parseInt(id, 10)
|
|
259
|
+
if (!Number.isFinite(n)) {
|
|
260
|
+
throw new HeraldProviderError(
|
|
261
|
+
`WordPress driver: providerPostId "${id}" is not a valid integer.`,
|
|
262
|
+
{ provider: 'wordpress', operation: 'toInt', status: 400 },
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
return n
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function escapeHtml(s: string): string {
|
|
269
|
+
return s
|
|
270
|
+
.replace(/&/g, '&')
|
|
271
|
+
.replace(/</g, '<')
|
|
272
|
+
.replace(/>/g, '>')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function escapeAttr(s: string): string {
|
|
276
|
+
return escapeHtml(s).replace(/"/g, '"')
|
|
277
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `WordPressHeraldProvider` — `ServiceProvider` that registers the
|
|
3
|
+
* WordPress driver factory on the `HeraldManager`.
|
|
4
|
+
*
|
|
5
|
+
* Apps list this AFTER `HeraldProvider` in `bootstrap/providers.ts`.
|
|
6
|
+
* Driver instances construct lazily on first `herald.use(name)` call.
|
|
7
|
+
*
|
|
8
|
+
* The driver self-validates at first use: a missing `sites` map AND
|
|
9
|
+
* missing `credentialsFor` resolver throws at the first `publish()`
|
|
10
|
+
* call. Apps can pre-validate by force-resolving a provider during
|
|
11
|
+
* boot.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type Application, ServiceProvider } from '@strav/kernel'
|
|
15
|
+
import { HeraldConfigError } from '../../errors.ts'
|
|
16
|
+
import { HeraldManager } from '../../herald_manager.ts'
|
|
17
|
+
import type { WordPressProviderConfig } from './wordpress_config.ts'
|
|
18
|
+
import { WordPressDriver } from './wordpress_driver.ts'
|
|
19
|
+
|
|
20
|
+
export class WordPressHeraldProvider extends ServiceProvider {
|
|
21
|
+
override readonly name = 'herald-wordpress'
|
|
22
|
+
override readonly dependencies = ['herald']
|
|
23
|
+
|
|
24
|
+
override register(app: Application): void {
|
|
25
|
+
const manager = app.resolve(HeraldManager)
|
|
26
|
+
manager.extend('wordpress', ({ instanceName, config }) => {
|
|
27
|
+
const cfg = config as WordPressProviderConfig
|
|
28
|
+
if (!cfg.sites && !cfg.credentialsFor) {
|
|
29
|
+
throw new HeraldConfigError(
|
|
30
|
+
`WordPressHeraldProvider: provider "${instanceName}" needs either an inline \`sites\` map or a \`credentialsFor\` resolver.`,
|
|
31
|
+
{ context: { instanceName } },
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return WordPressDriver.fromConfig(instanceName, cfg)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `validateWordPressCredentials` — confirm a `(siteUrl, username,
|
|
3
|
+
* applicationPassword)` triple authenticates against the WP REST API.
|
|
4
|
+
*
|
|
5
|
+
* Unlike the OAuth-flow providers (Meta, GBP) WordPress connections
|
|
6
|
+
* are typically set up by an owner pasting an Application Password
|
|
7
|
+
* into a form. This helper is the "validate on submit" step apps run
|
|
8
|
+
* before persisting credentials into `social_account`. Hits
|
|
9
|
+
* `GET /wp-json/wp/v2/users/me?context=edit` — `context=edit`
|
|
10
|
+
* forces WP to check capabilities, so a stolen-token-with-no-permissions
|
|
11
|
+
* doesn't slip through.
|
|
12
|
+
*
|
|
13
|
+
* Returns the public identity bits the connect UI usually echoes back
|
|
14
|
+
* ("Connected as Liva on example.com").
|
|
15
|
+
*
|
|
16
|
+
* Throws `HeraldProviderError` (status 401) when WP rejects the
|
|
17
|
+
* credentials, and (status 502) when the request fails for any other
|
|
18
|
+
* reason. Apps wrap the call in a try/catch to render a friendly
|
|
19
|
+
* "couldn't connect — check your password" message on the form.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { HeraldProviderError } from '../../errors.ts'
|
|
23
|
+
import type { WordPressCredentials } from './wordpress_config.ts'
|
|
24
|
+
|
|
25
|
+
export interface WordPressConnectionInfo {
|
|
26
|
+
siteUrl: string
|
|
27
|
+
userId: number
|
|
28
|
+
username: string
|
|
29
|
+
displayName?: string
|
|
30
|
+
email?: string
|
|
31
|
+
/** WP capabilities the credential grants (`publish_posts`, `edit_others_posts`, …). */
|
|
32
|
+
capabilities: Record<string, boolean>
|
|
33
|
+
raw: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface WPUserMeResponse {
|
|
37
|
+
id?: number
|
|
38
|
+
username?: string
|
|
39
|
+
slug?: string
|
|
40
|
+
name?: string
|
|
41
|
+
email?: string
|
|
42
|
+
capabilities?: Record<string, boolean>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function validateWordPressCredentials(
|
|
46
|
+
creds: WordPressCredentials,
|
|
47
|
+
options: { fetch?: typeof fetch } = {},
|
|
48
|
+
): Promise<WordPressConnectionInfo> {
|
|
49
|
+
const fetcher = options.fetch ?? fetch
|
|
50
|
+
const siteUrl = creds.siteUrl.replace(/\/+$/, '')
|
|
51
|
+
const url = `${siteUrl}/wp-json/wp/v2/users/me?context=edit`
|
|
52
|
+
const auth = `Basic ${Buffer.from(`${creds.username}:${creds.applicationPassword}`, 'utf-8').toString('base64')}`
|
|
53
|
+
|
|
54
|
+
let res: Response
|
|
55
|
+
try {
|
|
56
|
+
res = await fetcher(url, {
|
|
57
|
+
method: 'GET',
|
|
58
|
+
headers: { Authorization: auth, Accept: 'application/json' },
|
|
59
|
+
})
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
throw new HeraldProviderError(
|
|
62
|
+
`validateWordPressCredentials: network failure reaching ${siteUrl}.`,
|
|
63
|
+
{ provider: 'wordpress', operation: 'validateCredentials', status: 502, cause },
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (res.status === 401 || res.status === 403) {
|
|
68
|
+
const body = await safeText(res)
|
|
69
|
+
throw new HeraldProviderError(
|
|
70
|
+
`validateWordPressCredentials: WordPress rejected the credentials (HTTP ${res.status}). Confirm the username matches the Application Password owner and that the user has at least Editor role.`,
|
|
71
|
+
{
|
|
72
|
+
provider: 'wordpress',
|
|
73
|
+
operation: 'validateCredentials',
|
|
74
|
+
status: 401,
|
|
75
|
+
context: { responseBody: body },
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
const body = await safeText(res)
|
|
81
|
+
throw new HeraldProviderError(
|
|
82
|
+
`validateWordPressCredentials: WordPress responded HTTP ${res.status}.`,
|
|
83
|
+
{
|
|
84
|
+
provider: 'wordpress',
|
|
85
|
+
operation: 'validateCredentials',
|
|
86
|
+
status: 502,
|
|
87
|
+
context: { responseBody: body },
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const body = (await res.json()) as WPUserMeResponse
|
|
93
|
+
if (typeof body.id !== 'number') {
|
|
94
|
+
throw new HeraldProviderError(
|
|
95
|
+
`validateWordPressCredentials: /users/me response missing \`id\`.`,
|
|
96
|
+
{
|
|
97
|
+
provider: 'wordpress',
|
|
98
|
+
operation: 'validateCredentials',
|
|
99
|
+
status: 502,
|
|
100
|
+
context: { responseBody: body },
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
siteUrl,
|
|
107
|
+
userId: body.id,
|
|
108
|
+
username: body.username ?? body.slug ?? String(body.id),
|
|
109
|
+
...(body.name ? { displayName: body.name } : {}),
|
|
110
|
+
...(body.email ? { email: body.email } : {}),
|
|
111
|
+
capabilities: body.capabilities ?? {},
|
|
112
|
+
raw: body,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function safeText(res: Response): Promise<string> {
|
|
117
|
+
try {
|
|
118
|
+
return await res.text()
|
|
119
|
+
} catch {
|
|
120
|
+
return ''
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PostStatus` — normalized lifecycle state of a published post.
|
|
3
|
+
*
|
|
4
|
+
* - `pending` — accepted by the platform but not yet visible
|
|
5
|
+
* (moderation, scheduled future delivery).
|
|
6
|
+
* - `published` — live and visible on the platform.
|
|
7
|
+
* - `failed` — platform rejected the post (policy violation,
|
|
8
|
+
* media transcoding failure, …). See driver
|
|
9
|
+
* `raw` for the platform-specific reason.
|
|
10
|
+
* - `deleted` — removed from the platform (by us or by the
|
|
11
|
+
* platform).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type PostStatus = 'pending' | 'published' | 'failed' | 'deleted'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PublishInput` — the lowest-common-denominator wire shape for a post
|
|
3
|
+
* the `HeraldManager` accepts. Apps targeting multiple platforms stick
|
|
4
|
+
* to these fields; apps needing platform-specific richness (a GBP Call
|
|
5
|
+
* To Action button, an IG carousel ordering) drop down to `raw` or
|
|
6
|
+
* call into the subpath driver directly.
|
|
7
|
+
*
|
|
8
|
+
* Every field is optional so the same shape can carry "just text",
|
|
9
|
+
* "text + image", "link preview only", etc. Drivers throw
|
|
10
|
+
* `ProviderUnsupportedError` for fields they can't fulfil — apps
|
|
11
|
+
* gate on `driver.capabilities` to branch ahead of the call.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface PublishInput {
|
|
15
|
+
/** Plain-text body of the post. */
|
|
16
|
+
text?: string
|
|
17
|
+
/** Media + link attached to the post. */
|
|
18
|
+
attachments?: PublishAttachment[]
|
|
19
|
+
/** Hashtags, where the platform recognises them as first-class. */
|
|
20
|
+
hashtags?: string[]
|
|
21
|
+
/**
|
|
22
|
+
* Schedule the post for future delivery. Honoured only when the
|
|
23
|
+
* driver declares `post.schedule`; otherwise the call throws
|
|
24
|
+
* `ProviderUnsupportedError`.
|
|
25
|
+
*/
|
|
26
|
+
scheduledFor?: Date
|
|
27
|
+
/**
|
|
28
|
+
* Provider-native post object. When set, the driver forwards it
|
|
29
|
+
* verbatim and ignores the LCD fields above. Use this when reaching
|
|
30
|
+
* for provider-specific richness (e.g. a GBP CTA, a WP excerpt).
|
|
31
|
+
*/
|
|
32
|
+
raw?: unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type PublishAttachment =
|
|
36
|
+
| { type: 'image'; url: string; altText?: string }
|
|
37
|
+
| { type: 'video'; url: string; thumbnailUrl?: string; durationMs?: number }
|
|
38
|
+
| { type: 'link'; url: string; title?: string; description?: string }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PublishResult` — what every publish call returns. Drivers populate
|
|
3
|
+
* `providerPostId` and `url` when the platform hands them back at
|
|
4
|
+
* create time. Asynchronous-publish platforms (GBP Posts that need a
|
|
5
|
+
* moderation pass, scheduled posts) return `status: 'pending'` and
|
|
6
|
+
* leave `url` undefined until the platform confirms via webhook —
|
|
7
|
+
* apps reconcile by listening for `post.published` on the registry.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostStatus } from './post_status.ts'
|
|
11
|
+
|
|
12
|
+
export interface PublishResult {
|
|
13
|
+
/** Driver name (`'gbp'`, `'meta'`, `'wordpress'`, …). */
|
|
14
|
+
provider: string
|
|
15
|
+
/** Provider-issued post id. Required once the platform has accepted the post. */
|
|
16
|
+
providerPostId: string
|
|
17
|
+
/** Public URL of the post, when the platform exposes one. */
|
|
18
|
+
url?: string
|
|
19
|
+
/** Current platform-side status. */
|
|
20
|
+
status: PostStatus
|
|
21
|
+
/** Raw provider response for advanced inspection. */
|
|
22
|
+
raw?: unknown
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PublishTarget` — addresses a single configured publishing endpoint.
|
|
3
|
+
*
|
|
4
|
+
* `provider` selects the manager instance (key in `config.herald.providers`).
|
|
5
|
+
* `accountId` identifies the connected social account whose OAuth
|
|
6
|
+
* tokens the driver should use — typically the `provider_user_id` on
|
|
7
|
+
* a `@strav/social` `SocialAccount` row (e.g. a Facebook Page id, a
|
|
8
|
+
* GBP location id, a WordPress site host). Drivers resolve the actual
|
|
9
|
+
* access/refresh tokens via the social ledger at call time.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface PublishTarget {
|
|
13
|
+
provider: string
|
|
14
|
+
accountId: string
|
|
15
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HeraldError` hierarchy — typed wrappers for failures across the
|
|
3
|
+
* publishing stack. Vendor-native errors (Graph API rejections, WP REST
|
|
4
|
+
* 4xx responses) are preserved on `.cause` so apps can still
|
|
5
|
+
* `instanceof` the underlying type for retry / recovery logic; the
|
|
6
|
+
* wrapping just gives the framework a consistent `StravError` for the
|
|
7
|
+
* standard exception handler.
|
|
8
|
+
*
|
|
9
|
+
* Subclasses:
|
|
10
|
+
*
|
|
11
|
+
* - `HeraldConfigError` — `config.herald` missing required fields.
|
|
12
|
+
* Thrown at boot from `HeraldProvider`.
|
|
13
|
+
*
|
|
14
|
+
* - `ProviderUnsupportedError` — driver doesn't implement the
|
|
15
|
+
* requested operation (e.g. `gbp.update(...)` — GBP Posts are
|
|
16
|
+
* immutable). Thrown synchronously so apps fail fast.
|
|
17
|
+
*
|
|
18
|
+
* - `UnknownProviderError` — `herald.use('x')` for a name not
|
|
19
|
+
* configured.
|
|
20
|
+
*
|
|
21
|
+
* - `WebhookSignatureError` — signature header missing or doesn't
|
|
22
|
+
* verify. Webhook route returns 400; providers retry.
|
|
23
|
+
*
|
|
24
|
+
* - `HeraldProviderError` — generic wrapper around a vendor
|
|
25
|
+
* exception that doesn't map to a more specific subclass.
|
|
26
|
+
* Preserves `.cause`; default status 502.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { StravError } from '@strav/kernel'
|
|
30
|
+
|
|
31
|
+
export class HeraldError extends StravError {
|
|
32
|
+
constructor(
|
|
33
|
+
message: string,
|
|
34
|
+
options: {
|
|
35
|
+
code?: string
|
|
36
|
+
status?: number
|
|
37
|
+
context?: Record<string, unknown>
|
|
38
|
+
cause?: unknown
|
|
39
|
+
} = {},
|
|
40
|
+
) {
|
|
41
|
+
super(
|
|
42
|
+
message,
|
|
43
|
+
{ code: options.code ?? 'herald.error', status: options.status ?? 500 },
|
|
44
|
+
{
|
|
45
|
+
...(options.context ? { context: options.context } : {}),
|
|
46
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class HeraldConfigError extends HeraldError {
|
|
53
|
+
constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
|
|
54
|
+
super(message, {
|
|
55
|
+
code: 'herald.config',
|
|
56
|
+
status: 500,
|
|
57
|
+
...(options.context ? { context: options.context } : {}),
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class UnknownProviderError extends HeraldError {
|
|
63
|
+
constructor(name: string, available: readonly string[]) {
|
|
64
|
+
super(
|
|
65
|
+
`Herald provider "${name}" is not configured. Available: ${available.join(', ') || '<none>'}.`,
|
|
66
|
+
{
|
|
67
|
+
code: 'herald.unknown_provider',
|
|
68
|
+
status: 400,
|
|
69
|
+
context: { requested: name, available },
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class ProviderUnsupportedError extends HeraldError {
|
|
76
|
+
constructor(provider: string, operation: string, options: { reason?: string } = {}) {
|
|
77
|
+
const trailer = options.reason ? ` ${options.reason}` : ''
|
|
78
|
+
super(`Herald provider "${provider}" does not support "${operation}".${trailer}`, {
|
|
79
|
+
code: 'herald.provider_unsupported',
|
|
80
|
+
status: 400,
|
|
81
|
+
context: { provider, operation, ...(options.reason ? { reason: options.reason } : {}) },
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class WebhookSignatureError extends HeraldError {
|
|
87
|
+
constructor(
|
|
88
|
+
message: string,
|
|
89
|
+
options: { context?: Record<string, unknown>; cause?: unknown } = {},
|
|
90
|
+
) {
|
|
91
|
+
super(message, {
|
|
92
|
+
code: 'herald.webhook_signature',
|
|
93
|
+
status: 400,
|
|
94
|
+
...(options.context ? { context: options.context } : {}),
|
|
95
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class HeraldProviderError extends HeraldError {
|
|
101
|
+
constructor(
|
|
102
|
+
message: string,
|
|
103
|
+
options: {
|
|
104
|
+
provider: string
|
|
105
|
+
operation: string
|
|
106
|
+
context?: Record<string, unknown>
|
|
107
|
+
cause?: unknown
|
|
108
|
+
status?: number
|
|
109
|
+
},
|
|
110
|
+
) {
|
|
111
|
+
super(message, {
|
|
112
|
+
code: 'herald.provider_error',
|
|
113
|
+
status: options.status ?? 502,
|
|
114
|
+
context: {
|
|
115
|
+
provider: options.provider,
|
|
116
|
+
operation: options.operation,
|
|
117
|
+
...(options.context ?? {}),
|
|
118
|
+
},
|
|
119
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HeraldCapability` — granular feature flags every driver declares
|
|
3
|
+
* in `driver.capabilities`. Apps that build provider-neutral flows
|
|
4
|
+
* branch on capability before calling:
|
|
5
|
+
*
|
|
6
|
+
* if (herald.use('meta').capabilities.has('post.schedule')) { ... }
|
|
7
|
+
*
|
|
8
|
+
* Granularity is intentionally fine — one flag per non-trivial surface
|
|
9
|
+
* — so e.g. GBP can claim `post.create` + `post.delete` but omit
|
|
10
|
+
* `post.update` (Posts are immutable), and Instagram can claim
|
|
11
|
+
* `media.video` without `media.carousel`.
|
|
12
|
+
*
|
|
13
|
+
* Drivers omit a capability when they can't fulfil it faithfully. Apps
|
|
14
|
+
* reach into provider-specific subpath imports (`@strav/herald/meta`)
|
|
15
|
+
* when they need behaviour that doesn't map to a common capability.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type HeraldCapability =
|
|
19
|
+
// outbound post lifecycle
|
|
20
|
+
| 'post.create'
|
|
21
|
+
| 'post.update'
|
|
22
|
+
| 'post.delete'
|
|
23
|
+
| 'post.get'
|
|
24
|
+
| 'post.schedule'
|
|
25
|
+
// content shapes
|
|
26
|
+
| 'media.image'
|
|
27
|
+
| 'media.video'
|
|
28
|
+
| 'media.carousel'
|
|
29
|
+
| 'media.link'
|
|
30
|
+
| 'text.hashtags'
|
|
31
|
+
| 'text.mentions'
|
|
32
|
+
// inbound webhook surfaces
|
|
33
|
+
| 'webhook.signature'
|
|
34
|
+
| 'webhook.parse'
|
|
35
|
+
| 'webhook.engagement.comment'
|
|
36
|
+
| 'webhook.engagement.review'
|
|
37
|
+
| 'webhook.engagement.reaction'
|