@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,103 @@
1
+ /**
2
+ * `MetaGraphClient` — thin wrapper over `graph.facebook.com`.
3
+ *
4
+ * Adds `access_token` + (when an `appSecret` is provided) the
5
+ * `appsecret_proof` parameter to every request — Meta strongly
6
+ * recommends it for hardened apps and it's mandatory when "Require
7
+ * App Secret" is on.
8
+ *
9
+ * Errors come back as `{ error: { message, code, type, ... } }`;
10
+ * the client maps them to `HeraldProviderError` with the original
11
+ * payload on `context.responseBody`.
12
+ */
13
+
14
+ import { createHmac } from 'node:crypto'
15
+ import { HeraldProviderError } from '../../errors.ts'
16
+
17
+ export interface GraphResponse {
18
+ id?: string
19
+ post_id?: string
20
+ permalink_url?: string
21
+ permalink?: string
22
+ [key: string]: unknown
23
+ }
24
+
25
+ export class MetaGraphClient {
26
+ private readonly baseUrl: string
27
+ private readonly accessToken: string
28
+ private readonly appSecret: string
29
+ private readonly fetcher: typeof fetch
30
+
31
+ constructor(input: {
32
+ accessToken: string
33
+ appSecret: string
34
+ apiVersion?: string
35
+ fetch?: typeof fetch
36
+ }) {
37
+ this.accessToken = input.accessToken
38
+ this.appSecret = input.appSecret
39
+ this.baseUrl = `https://graph.facebook.com/${input.apiVersion ?? 'v21.0'}`
40
+ this.fetcher = input.fetch ?? fetch
41
+ }
42
+
43
+ /** Hex-encoded HMAC-SHA256(appSecret, accessToken) — required for hardened Graph calls. */
44
+ appsecretProof(): string {
45
+ return createHmac('sha256', this.appSecret).update(this.accessToken).digest('hex')
46
+ }
47
+
48
+ async get<T = GraphResponse>(path: string, query: Record<string, unknown> = {}): Promise<T> {
49
+ return this.request<T>('GET', path, query, undefined)
50
+ }
51
+
52
+ async post<T = GraphResponse>(
53
+ path: string,
54
+ body: Record<string, unknown>,
55
+ ): Promise<T> {
56
+ return this.request<T>('POST', path, {}, body)
57
+ }
58
+
59
+ async delete<T = GraphResponse>(path: string): Promise<T> {
60
+ return this.request<T>('DELETE', path, {}, undefined)
61
+ }
62
+
63
+ private async request<T>(
64
+ method: 'GET' | 'POST' | 'DELETE',
65
+ path: string,
66
+ query: Record<string, unknown>,
67
+ body: Record<string, unknown> | undefined,
68
+ ): Promise<T> {
69
+ const url = new URL(`${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`)
70
+ url.searchParams.set('access_token', this.accessToken)
71
+ url.searchParams.set('appsecret_proof', this.appsecretProof())
72
+ for (const [k, v] of Object.entries(query)) {
73
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v))
74
+ }
75
+
76
+ const init: RequestInit = { method }
77
+ if (body !== undefined) {
78
+ init.headers = { 'Content-Type': 'application/json' }
79
+ init.body = JSON.stringify(body)
80
+ }
81
+
82
+ const res = await this.fetcher(url.toString(), init)
83
+ const text = await res.text()
84
+ let parsed: unknown
85
+ try {
86
+ parsed = text ? JSON.parse(text) : {}
87
+ } catch {
88
+ parsed = { raw: text }
89
+ }
90
+ if (!res.ok) {
91
+ throw new HeraldProviderError(
92
+ `Meta Graph: ${method} ${path} failed (${res.status}).`,
93
+ {
94
+ provider: 'meta',
95
+ operation: `${method} ${path}`,
96
+ status: 502,
97
+ context: { responseBody: parsed },
98
+ },
99
+ )
100
+ }
101
+ return parsed as T
102
+ }
103
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `MetaProviderConfig` — `config.herald.providers['...']` shape for
3
+ * the Meta Graph driver. One instance serves one platform — apps
4
+ * publishing to both Facebook Pages and Instagram Business declare
5
+ * two providers, each with its own `platform` setting:
6
+ *
7
+ * config.herald.providers = {
8
+ * 'facebook': { driver: 'meta', platform: 'facebook', ... },
9
+ * 'instagram': { driver: 'meta', platform: 'instagram', ... },
10
+ * }
11
+ *
12
+ * Auth model:
13
+ *
14
+ * - **Per-instance `appSecret`** — constant per Meta app. Used
15
+ * for `appsecret_proof` on hardened Graph calls and for webhook
16
+ * signature verification. Apps load it once from env.
17
+ *
18
+ * - **Per-account `accessToken`** — long-lived Page token (FB) or
19
+ * IG-User token (IG), one per connected account. The
20
+ * `credentialsFor(accountId)` resolver returns it; apps
21
+ * typically read from `@strav/social`'s `social_account` ledger.
22
+ */
23
+
24
+ export type MetaPlatform = 'facebook' | 'instagram'
25
+
26
+ export interface MetaCredentials {
27
+ /** Long-lived Page access token (FB) or IG-User access token (IG). */
28
+ accessToken: string
29
+ }
30
+
31
+ export type MetaCredentialsResolver = (
32
+ accountId: string,
33
+ ) => Promise<MetaCredentials | null> | MetaCredentials | null
34
+
35
+ export interface MetaProviderConfig {
36
+ driver: 'meta'
37
+ platform: MetaPlatform
38
+ /** Meta app secret — constant per Meta app. */
39
+ appSecret: string
40
+ credentialsFor: MetaCredentialsResolver
41
+ /** Graph API version. Default `v21.0`. */
42
+ apiVersion?: string
43
+ /** Optional `fetch` override for tests. */
44
+ fetch?: typeof fetch
45
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * `MetaDriver` — `HeraldDriver` implementation for Meta's Graph API.
3
+ *
4
+ * One instance serves **one platform** (`facebook` or `instagram`).
5
+ * Apps publishing to both declare two `config.herald.providers`
6
+ * entries — same driver class, different `platform` setting.
7
+ *
8
+ * Facebook Pages (synchronous):
9
+ *
10
+ * - Text-only / text-with-link → `POST /{page-id}/feed` with
11
+ * `message` (+ optional `link`).
12
+ * - With image → `POST /{page-id}/photos` with `url` (FB hosts
13
+ * the image). Multi-image posts are deferred to a follow-up
14
+ * slice (would require unpublished photo IDs + attached_media).
15
+ * - Scheduled (`scheduledFor`) → `published=false` +
16
+ * `scheduled_publish_time` (unix seconds).
17
+ * - Update: NOT supported (FB removed feed edits via API).
18
+ * - Delete: `DELETE /{post-id}`.
19
+ *
20
+ * Instagram Business (two-step container publish):
21
+ *
22
+ * - Step 1: `POST /{ig-user-id}/media` with `image_url` +
23
+ * `caption` → returns `creation_id`.
24
+ * - Step 2: `POST /{ig-user-id}/media_publish` with `creation_id`
25
+ * → returns the IG media `id`.
26
+ * - Image-only in v1. Videos require status polling; carousels
27
+ * require child containers — both deferred.
28
+ * - Scheduled posts are NOT supported on the IG Content Publishing
29
+ * API; `scheduledFor` throws `ProviderUnsupportedError`.
30
+ * - Update + delete: NOT supported via API.
31
+ *
32
+ * Webhook ops delegated to `MetaWebhookOps`.
33
+ */
34
+
35
+ import type { PostStatus } from '../../dto/post_status.ts'
36
+ import type { PublishInput } from '../../dto/publish_input.ts'
37
+ import type { PublishResult } from '../../dto/publish_result.ts'
38
+ import type { PublishTarget } from '../../dto/publish_target.ts'
39
+ import { HeraldProviderError, ProviderUnsupportedError } from '../../errors.ts'
40
+ import type { HeraldCapability } from '../../herald_capabilities.ts'
41
+ import type { HeraldDriver, WebhookOps } from '../../herald_driver.ts'
42
+ import { MetaGraphClient } from './meta_client.ts'
43
+ import type {
44
+ MetaCredentialsResolver,
45
+ MetaPlatform,
46
+ MetaProviderConfig,
47
+ } from './meta_config.ts'
48
+ import { MetaWebhookOps } from './meta_webhook_ops.ts'
49
+
50
+ export interface MetaDriverOptions {
51
+ instanceName: string
52
+ platform: MetaPlatform
53
+ /** Meta app secret — constant per Meta app. Required for `appsecret_proof` + webhook verification. */
54
+ appSecret: string
55
+ credentialsFor: MetaCredentialsResolver
56
+ apiVersion?: string
57
+ fetch?: typeof fetch
58
+ /** Optional webhook ops override (mainly for tests). */
59
+ webhook?: WebhookOps
60
+ }
61
+
62
+ const FACEBOOK_CAPS: ReadonlySet<HeraldCapability> = new Set<HeraldCapability>([
63
+ 'post.create',
64
+ 'post.delete',
65
+ 'post.get',
66
+ 'post.schedule',
67
+ 'media.image',
68
+ 'media.link',
69
+ 'webhook.signature',
70
+ 'webhook.parse',
71
+ 'webhook.engagement.comment',
72
+ 'webhook.engagement.review',
73
+ ])
74
+
75
+ const INSTAGRAM_CAPS: ReadonlySet<HeraldCapability> = new Set<HeraldCapability>([
76
+ 'post.create',
77
+ 'post.get',
78
+ 'media.image',
79
+ 'text.hashtags',
80
+ 'text.mentions',
81
+ 'webhook.signature',
82
+ 'webhook.parse',
83
+ 'webhook.engagement.comment',
84
+ ])
85
+
86
+ export class MetaDriver implements HeraldDriver {
87
+ readonly name = 'meta'
88
+ readonly instanceName: string
89
+ readonly platform: MetaPlatform
90
+ readonly capabilities: ReadonlySet<HeraldCapability>
91
+ readonly webhook: WebhookOps
92
+
93
+ private readonly appSecret: string
94
+ private readonly resolver: MetaCredentialsResolver
95
+ private readonly apiVersion: string | undefined
96
+ private readonly fetcher: typeof fetch
97
+
98
+ constructor(options: MetaDriverOptions) {
99
+ this.instanceName = options.instanceName
100
+ this.platform = options.platform
101
+ this.capabilities = options.platform === 'facebook' ? FACEBOOK_CAPS : INSTAGRAM_CAPS
102
+ this.appSecret = options.appSecret
103
+ this.resolver = options.credentialsFor
104
+ this.apiVersion = options.apiVersion
105
+ this.fetcher = options.fetch ?? fetch
106
+ this.webhook =
107
+ options.webhook ??
108
+ new MetaWebhookOps({ appSecret: options.appSecret, driverName: 'meta' })
109
+ }
110
+
111
+ static fromConfig(instanceName: string, config: MetaProviderConfig): MetaDriver {
112
+ return new MetaDriver({
113
+ instanceName,
114
+ platform: config.platform,
115
+ appSecret: config.appSecret,
116
+ credentialsFor: config.credentialsFor,
117
+ ...(config.apiVersion ? { apiVersion: config.apiVersion } : {}),
118
+ ...(config.fetch ? { fetch: config.fetch } : {}),
119
+ })
120
+ }
121
+
122
+ async publish(target: PublishTarget, input: PublishInput): Promise<PublishResult> {
123
+ const client = await this.clientFor(target.accountId)
124
+ return this.platform === 'facebook'
125
+ ? this.publishFacebook(target.accountId, client, input)
126
+ : this.publishInstagram(target.accountId, client, input)
127
+ }
128
+
129
+ async delete(target: PublishTarget, providerPostId: string): Promise<void> {
130
+ if (this.platform !== 'facebook') {
131
+ throw new ProviderUnsupportedError('meta-instagram', 'delete', {
132
+ reason: 'Instagram Graph API does not support post deletion.',
133
+ })
134
+ }
135
+ const client = await this.clientFor(target.accountId)
136
+ await client.delete(`/${providerPostId}`)
137
+ }
138
+
139
+ async get(target: PublishTarget, providerPostId: string): Promise<PostStatus> {
140
+ const client = await this.clientFor(target.accountId)
141
+ try {
142
+ await client.get(`/${providerPostId}`, { fields: 'id' })
143
+ return 'published'
144
+ } catch (cause) {
145
+ if (cause instanceof HeraldProviderError) return 'deleted'
146
+ throw cause
147
+ }
148
+ }
149
+
150
+ // ─── platform implementations ────────────────────────────────────────
151
+
152
+ private async publishFacebook(
153
+ accountId: string,
154
+ client: MetaGraphClient,
155
+ input: PublishInput,
156
+ ): Promise<PublishResult> {
157
+ const raw = (input.raw as Record<string, unknown> | undefined) ?? {}
158
+ const message = buildMessage(input)
159
+ const image = (input.attachments ?? []).find((a) => a.type === 'image')
160
+ const link = (input.attachments ?? []).find((a) => a.type === 'link')
161
+ const scheduled = !!input.scheduledFor
162
+
163
+ if (image) {
164
+ const body: Record<string, unknown> = {
165
+ url: image.url,
166
+ ...(message ? { caption: message } : {}),
167
+ ...raw,
168
+ }
169
+ applySchedule(body, input.scheduledFor)
170
+ const res = await client.post<{ id?: string; post_id?: string }>(
171
+ `/${accountId}/photos`,
172
+ body,
173
+ )
174
+ const postId = res.post_id ?? res.id
175
+ if (!postId) {
176
+ throw new HeraldProviderError(
177
+ 'Meta (Facebook): photos endpoint returned no post_id / id.',
178
+ { provider: 'meta', operation: 'publishFacebook', status: 502, context: { raw: res } },
179
+ )
180
+ }
181
+ return this.facebookResult(postId, scheduled, res)
182
+ }
183
+
184
+ const body: Record<string, unknown> = {
185
+ ...(message ? { message } : {}),
186
+ ...(link ? { link: link.url } : {}),
187
+ ...raw,
188
+ }
189
+ applySchedule(body, input.scheduledFor)
190
+ const res = await client.post<{ id?: string }>(`/${accountId}/feed`, body)
191
+ if (!res.id) {
192
+ throw new HeraldProviderError(
193
+ 'Meta (Facebook): feed endpoint returned no id.',
194
+ { provider: 'meta', operation: 'publishFacebook', status: 502, context: { raw: res } },
195
+ )
196
+ }
197
+ return this.facebookResult(res.id, scheduled, res)
198
+ }
199
+
200
+ private facebookResult(
201
+ postId: string,
202
+ scheduled: boolean,
203
+ raw: unknown,
204
+ ): PublishResult {
205
+ return {
206
+ provider: 'meta',
207
+ providerPostId: postId,
208
+ status: scheduled ? 'pending' : 'published',
209
+ raw,
210
+ }
211
+ }
212
+
213
+ private async publishInstagram(
214
+ accountId: string,
215
+ client: MetaGraphClient,
216
+ input: PublishInput,
217
+ ): Promise<PublishResult> {
218
+ if (input.scheduledFor) {
219
+ throw new ProviderUnsupportedError('meta-instagram', 'post.schedule', {
220
+ reason: 'IG Content Publishing API does not support scheduled posts.',
221
+ })
222
+ }
223
+ const image = (input.attachments ?? []).find((a) => a.type === 'image')
224
+ if (!image) {
225
+ throw new HeraldProviderError(
226
+ 'Meta (Instagram): publish requires an image attachment in v1 (video + carousel deferred).',
227
+ { provider: 'meta', operation: 'publishInstagram', status: 400 },
228
+ )
229
+ }
230
+
231
+ const caption = buildCaption(input)
232
+ const raw = (input.raw as Record<string, unknown> | undefined) ?? {}
233
+
234
+ // Step 1 — create media container
235
+ const container = await client.post<{ id?: string }>(`/${accountId}/media`, {
236
+ image_url: image.url,
237
+ ...(caption ? { caption } : {}),
238
+ ...raw,
239
+ })
240
+ if (!container.id) {
241
+ throw new HeraldProviderError(
242
+ 'Meta (Instagram): /media did not return a creation id.',
243
+ { provider: 'meta', operation: 'publishInstagram', status: 502, context: { raw: container } },
244
+ )
245
+ }
246
+
247
+ // Step 2 — publish container
248
+ const published = await client.post<{ id?: string }>(`/${accountId}/media_publish`, {
249
+ creation_id: container.id,
250
+ })
251
+ if (!published.id) {
252
+ throw new HeraldProviderError(
253
+ 'Meta (Instagram): /media_publish did not return an id.',
254
+ { provider: 'meta', operation: 'publishInstagram', status: 502, context: { raw: published } },
255
+ )
256
+ }
257
+
258
+ return {
259
+ provider: 'meta',
260
+ providerPostId: published.id,
261
+ status: 'published',
262
+ raw: { container, published },
263
+ }
264
+ }
265
+
266
+ // ─── internals ───────────────────────────────────────────────────────
267
+
268
+ private async clientFor(accountId: string): Promise<MetaGraphClient> {
269
+ const resolved = await this.resolver(accountId)
270
+ if (!resolved) {
271
+ throw new HeraldProviderError(
272
+ `Meta driver: no credentials registered for account "${accountId}".`,
273
+ { provider: 'meta', operation: 'clientFor', status: 500 },
274
+ )
275
+ }
276
+ return new MetaGraphClient({
277
+ accessToken: resolved.accessToken,
278
+ appSecret: this.appSecret,
279
+ ...(this.apiVersion ? { apiVersion: this.apiVersion } : {}),
280
+ fetch: this.fetcher,
281
+ })
282
+ }
283
+ }
284
+
285
+ function buildMessage(input: PublishInput): string {
286
+ const parts: string[] = []
287
+ if (input.text) parts.push(input.text)
288
+ if (input.hashtags && input.hashtags.length > 0) {
289
+ parts.push(input.hashtags.map((h) => `#${h}`).join(' '))
290
+ }
291
+ return parts.join('\n\n')
292
+ }
293
+
294
+ function buildCaption(input: PublishInput): string {
295
+ // IG captions accept hashtags + emoji freely; same shape as the
296
+ // FB message for now. Links in captions aren't clickable on IG
297
+ // but are commonly included anyway.
298
+ const parts: string[] = []
299
+ if (input.text) parts.push(input.text)
300
+ for (const att of input.attachments ?? []) {
301
+ if (att.type === 'link') parts.push(att.url)
302
+ }
303
+ if (input.hashtags && input.hashtags.length > 0) {
304
+ parts.push(input.hashtags.map((h) => `#${h}`).join(' '))
305
+ }
306
+ return parts.join('\n\n')
307
+ }
308
+
309
+ function applySchedule(body: Record<string, unknown>, scheduledFor: Date | undefined): void {
310
+ if (!scheduledFor) return
311
+ body['published'] = false
312
+ body['scheduled_publish_time'] = Math.floor(scheduledFor.getTime() / 1000)
313
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `MetaHeraldProvider` — `ServiceProvider` that registers the Meta
3
+ * driver factory on the `HeraldManager`.
4
+ *
5
+ * Apps list this AFTER `HeraldProvider`. One factory registration
6
+ * covers every Meta-driven instance — the per-instance `platform`
7
+ * field (`facebook` | `instagram`) flips behaviour inside the driver.
8
+ */
9
+
10
+ import { type Application, ServiceProvider } from '@strav/kernel'
11
+ import { HeraldConfigError } from '../../errors.ts'
12
+ import { HeraldManager } from '../../herald_manager.ts'
13
+ import type { MetaProviderConfig } from './meta_config.ts'
14
+ import { MetaDriver } from './meta_driver.ts'
15
+
16
+ export class MetaHeraldProvider extends ServiceProvider {
17
+ override readonly name = 'herald-meta'
18
+ override readonly dependencies = ['herald']
19
+
20
+ override register(app: Application): void {
21
+ const manager = app.resolve(HeraldManager)
22
+ manager.extend('meta', ({ instanceName, config }) => {
23
+ const cfg = config as unknown as MetaProviderConfig
24
+ if (!cfg.platform || (cfg.platform !== 'facebook' && cfg.platform !== 'instagram')) {
25
+ throw new HeraldConfigError(
26
+ `MetaHeraldProvider: provider "${instanceName}" needs \`platform: 'facebook' | 'instagram'\`.`,
27
+ { context: { instanceName, platform: cfg.platform } },
28
+ )
29
+ }
30
+ if (!cfg.appSecret) {
31
+ throw new HeraldConfigError(
32
+ `MetaHeraldProvider: provider "${instanceName}" needs \`appSecret\` (the Meta app secret).`,
33
+ { context: { instanceName } },
34
+ )
35
+ }
36
+ if (!cfg.credentialsFor) {
37
+ throw new HeraldConfigError(
38
+ `MetaHeraldProvider: provider "${instanceName}" needs a \`credentialsFor\` resolver.`,
39
+ { context: { instanceName } },
40
+ )
41
+ }
42
+ return MetaDriver.fromConfig(instanceName, cfg)
43
+ })
44
+ }
45
+ }