@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.
Files changed (52) hide show
  1. package/README.md +48 -0
  2. package/package.json +43 -0
  3. package/src/drivers/gbp/gbp_client.ts +155 -0
  4. package/src/drivers/gbp/gbp_config.ts +48 -0
  5. package/src/drivers/gbp/gbp_driver.ts +246 -0
  6. package/src/drivers/gbp/gbp_provider.ts +35 -0
  7. package/src/drivers/gbp/index.ts +23 -0
  8. package/src/drivers/meta/index.ts +21 -0
  9. package/src/drivers/meta/meta_client.ts +103 -0
  10. package/src/drivers/meta/meta_config.ts +45 -0
  11. package/src/drivers/meta/meta_driver.ts +313 -0
  12. package/src/drivers/meta/meta_provider.ts +45 -0
  13. package/src/drivers/meta/meta_webhook_ops.ts +227 -0
  14. package/src/drivers/wordpress/index.ts +24 -0
  15. package/src/drivers/wordpress/wordpress_client.ts +185 -0
  16. package/src/drivers/wordpress/wordpress_config.ts +51 -0
  17. package/src/drivers/wordpress/wordpress_driver.ts +277 -0
  18. package/src/drivers/wordpress/wordpress_provider.ts +37 -0
  19. package/src/drivers/wordpress/wordpress_validate.ts +122 -0
  20. package/src/dto/post_status.ts +14 -0
  21. package/src/dto/publish_input.ts +38 -0
  22. package/src/dto/publish_result.ts +23 -0
  23. package/src/dto/publish_target.ts +15 -0
  24. package/src/errors.ts +122 -0
  25. package/src/herald_capabilities.ts +37 -0
  26. package/src/herald_config.ts +32 -0
  27. package/src/herald_driver.ts +86 -0
  28. package/src/herald_manager.ts +169 -0
  29. package/src/herald_provider.ts +44 -0
  30. package/src/index.ts +65 -0
  31. package/src/ledger/apply_publication_migration.ts +53 -0
  32. package/src/ledger/index.ts +11 -0
  33. package/src/ledger/publication.ts +30 -0
  34. package/src/ledger/publication_repository.ts +172 -0
  35. package/src/ledger/publication_schema.ts +70 -0
  36. package/src/onboarding/complete_channel_connect.ts +154 -0
  37. package/src/onboarding/connect_channel_callback.ts +138 -0
  38. package/src/onboarding/index.ts +26 -0
  39. package/src/onboarding/types.ts +67 -0
  40. package/src/tenanted/apply_tenanted_publication_migration.ts +46 -0
  41. package/src/tenanted/index.ts +18 -0
  42. package/src/tenanted/tenanted_publication.ts +28 -0
  43. package/src/tenanted/tenanted_publication_repository.ts +145 -0
  44. package/src/tenanted/tenanted_publication_schema.ts +35 -0
  45. package/src/webhook/apply_herald_webhook_event_migration.ts +42 -0
  46. package/src/webhook/herald_event.ts +139 -0
  47. package/src/webhook/herald_webhook.ts +149 -0
  48. package/src/webhook/herald_webhook_event.ts +25 -0
  49. package/src/webhook/herald_webhook_event_repository.ts +65 -0
  50. package/src/webhook/herald_webhook_event_schema.ts +37 -0
  51. package/src/webhook/herald_webhook_registry.ts +65 -0
  52. 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, '&amp;')
271
+ .replace(/</g, '&lt;')
272
+ .replace(/>/g, '&gt;')
273
+ }
274
+
275
+ function escapeAttr(s: string): string {
276
+ return escapeHtml(s).replace(/"/g, '&quot;')
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'