@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,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
+ }