@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,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TenantedPublicationRepository` — same surface as
|
|
3
|
+
* `PublicationRepository`, scoped to the tenanted schema. Callers
|
|
4
|
+
* MUST be inside a `TenantManager.withTenant(...)` scope; INSERTs
|
|
5
|
+
* rely on the session's `app.tenant_id` setting (RLS).
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the non-tenanted Repository line-for-line — minor
|
|
8
|
+
* duplication is worth keeping both variants narrowly scoped + avoid
|
|
9
|
+
* runtime branching on a tenancy flag.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { quoteIdent, Repository } from '@strav/database'
|
|
13
|
+
import { ulid } from '@strav/kernel'
|
|
14
|
+
import type { PublishResult } from '../dto/publish_result.ts'
|
|
15
|
+
import type { PublishTarget } from '../dto/publish_target.ts'
|
|
16
|
+
import { TenantedPublication } from './tenanted_publication.ts'
|
|
17
|
+
import { tenantedPublicationSchema } from './tenanted_publication_schema.ts'
|
|
18
|
+
|
|
19
|
+
export interface RecordInput {
|
|
20
|
+
sourceId: string
|
|
21
|
+
instanceName: string
|
|
22
|
+
target: PublishTarget
|
|
23
|
+
result: PublishResult
|
|
24
|
+
metadata?: Record<string, unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MarkPublishedInput {
|
|
28
|
+
providerPostId: string
|
|
29
|
+
url?: string
|
|
30
|
+
publishedAt?: Date
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class TenantedPublicationRepository extends Repository<TenantedPublication> {
|
|
34
|
+
static override readonly schema = tenantedPublicationSchema
|
|
35
|
+
static override readonly model = TenantedPublication
|
|
36
|
+
|
|
37
|
+
async record(input: RecordInput): Promise<TenantedPublication> {
|
|
38
|
+
const now = new Date()
|
|
39
|
+
|
|
40
|
+
if (input.result.providerPostId) {
|
|
41
|
+
const existing = await this.findByProviderPostId(
|
|
42
|
+
input.target.provider,
|
|
43
|
+
input.instanceName,
|
|
44
|
+
input.result.providerPostId,
|
|
45
|
+
)
|
|
46
|
+
if (existing) {
|
|
47
|
+
return this.update(existing, {
|
|
48
|
+
source_id: input.sourceId,
|
|
49
|
+
account_id: input.target.accountId,
|
|
50
|
+
url: input.result.url ?? null,
|
|
51
|
+
status: input.result.status,
|
|
52
|
+
published_at: input.result.status === 'published' ? now : existing.published_at,
|
|
53
|
+
error_message: null,
|
|
54
|
+
metadata: { ...existing.metadata, ...(input.metadata ?? {}) },
|
|
55
|
+
updated_at: now,
|
|
56
|
+
} as Partial<TenantedPublication>)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return this.create({
|
|
61
|
+
id: ulid(),
|
|
62
|
+
source_id: input.sourceId,
|
|
63
|
+
provider: input.target.provider,
|
|
64
|
+
instance_name: input.instanceName,
|
|
65
|
+
account_id: input.target.accountId,
|
|
66
|
+
provider_post_id: input.result.providerPostId || null,
|
|
67
|
+
url: input.result.url ?? null,
|
|
68
|
+
status: input.result.status,
|
|
69
|
+
published_at: input.result.status === 'published' ? now : null,
|
|
70
|
+
error_message: null,
|
|
71
|
+
metadata: input.metadata ?? {},
|
|
72
|
+
created_at: now,
|
|
73
|
+
updated_at: now,
|
|
74
|
+
} as Partial<TenantedPublication>)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async markPublished(id: string, input: MarkPublishedInput): Promise<void> {
|
|
78
|
+
const table = quoteIdent(tenantedPublicationSchema.name)
|
|
79
|
+
await this.db.execute(
|
|
80
|
+
`UPDATE ${table}
|
|
81
|
+
SET "provider_post_id" = $2,
|
|
82
|
+
"url" = $3,
|
|
83
|
+
"status" = 'published',
|
|
84
|
+
"published_at" = $4,
|
|
85
|
+
"error_message" = NULL,
|
|
86
|
+
"updated_at" = $5
|
|
87
|
+
WHERE "id" = $1`,
|
|
88
|
+
[
|
|
89
|
+
id,
|
|
90
|
+
input.providerPostId,
|
|
91
|
+
input.url ?? null,
|
|
92
|
+
input.publishedAt ?? new Date(),
|
|
93
|
+
new Date(),
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async markFailed(id: string, errorMessage: string): Promise<void> {
|
|
99
|
+
const table = quoteIdent(tenantedPublicationSchema.name)
|
|
100
|
+
await this.db.execute(
|
|
101
|
+
`UPDATE ${table}
|
|
102
|
+
SET "status" = 'failed',
|
|
103
|
+
"error_message" = $2,
|
|
104
|
+
"updated_at" = $3
|
|
105
|
+
WHERE "id" = $1`,
|
|
106
|
+
[id, errorMessage, new Date()],
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async markDeleted(id: string): Promise<void> {
|
|
111
|
+
const table = quoteIdent(tenantedPublicationSchema.name)
|
|
112
|
+
await this.db.execute(
|
|
113
|
+
`UPDATE ${table}
|
|
114
|
+
SET "status" = 'deleted',
|
|
115
|
+
"updated_at" = $2
|
|
116
|
+
WHERE "id" = $1`,
|
|
117
|
+
[id, new Date()],
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async findBySource(sourceId: string): Promise<TenantedPublication[]> {
|
|
122
|
+
const table = quoteIdent(tenantedPublicationSchema.name)
|
|
123
|
+
const rows = await this.db.query<Record<string, unknown>>(
|
|
124
|
+
`SELECT * FROM ${table} WHERE "source_id" = $1 ORDER BY "created_at"`,
|
|
125
|
+
[sourceId],
|
|
126
|
+
)
|
|
127
|
+
return Promise.all(rows.map((r) => this.hydrate(r)))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async findByProviderPostId(
|
|
131
|
+
provider: string,
|
|
132
|
+
instanceName: string,
|
|
133
|
+
providerPostId: string,
|
|
134
|
+
): Promise<TenantedPublication | null> {
|
|
135
|
+
const table = quoteIdent(tenantedPublicationSchema.name)
|
|
136
|
+
const rows = await this.db.query<Record<string, unknown>>(
|
|
137
|
+
`SELECT * FROM ${table}
|
|
138
|
+
WHERE "provider" = $1 AND "instance_name" = $2 AND "provider_post_id" = $3
|
|
139
|
+
LIMIT 1`,
|
|
140
|
+
[provider, instanceName, providerPostId],
|
|
141
|
+
)
|
|
142
|
+
if (rows.length === 0) return null
|
|
143
|
+
return this.hydrate(rows[0]!)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tenantedPublicationSchema` — opt-in tenant-scoped variant of the
|
|
3
|
+
* publication ledger. Imported from `@strav/herald/tenanted` so apps
|
|
4
|
+
* that don't need multitenancy don't pay for it.
|
|
5
|
+
*
|
|
6
|
+
* Same columns as the default `publicationSchema`, with
|
|
7
|
+
* `tenanted: true` so `@strav/database` injects the `tenant_id` FK
|
|
8
|
+
* + RLS policy. Composite unique becomes
|
|
9
|
+
* `(tenant_id, provider, instance_name, provider_post_id)` — two
|
|
10
|
+
* tenants can each have a publication for the same provider id
|
|
11
|
+
* (rare in practice; collision-safe by construction).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Archetype, defineSchema } from '@strav/database'
|
|
15
|
+
|
|
16
|
+
export const tenantedPublicationSchema = defineSchema(
|
|
17
|
+
'publication',
|
|
18
|
+
Archetype.Entity,
|
|
19
|
+
(t) => {
|
|
20
|
+
t.id()
|
|
21
|
+
t.string('source_id').max(64).notNull()
|
|
22
|
+
t.string('provider').max(64).notNull()
|
|
23
|
+
t.string('instance_name').max(64).notNull()
|
|
24
|
+
t.string('account_id').max(255).notNull()
|
|
25
|
+
t.string('provider_post_id').max(255).nullable()
|
|
26
|
+
t.string('url').max(2048).nullable()
|
|
27
|
+
t.string('status').max(16).notNull()
|
|
28
|
+
t.timestamp('published_at').nullable()
|
|
29
|
+
t.string('error_message').max(1024).nullable()
|
|
30
|
+
t.json('metadata').notNull().default({})
|
|
31
|
+
t.timestamp('created_at').notNull()
|
|
32
|
+
t.timestamp('updated_at').notNull()
|
|
33
|
+
},
|
|
34
|
+
{ tenanted: true },
|
|
35
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `applyHeraldWebhookEventMigration` — DDL for the webhook dedup
|
|
3
|
+
* ledger plus the composite-unique index it relies on.
|
|
4
|
+
*
|
|
5
|
+
* Non-tenanted by construction: webhooks arrive before tenant
|
|
6
|
+
* context is known. Apps include this migration regardless of
|
|
7
|
+
* whether they use the tenanted publication ledger variant.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
emitCreateTable,
|
|
12
|
+
type DatabaseExecutor,
|
|
13
|
+
type SchemaRegistry,
|
|
14
|
+
} from '@strav/database'
|
|
15
|
+
import { heraldWebhookEventSchema } from './herald_webhook_event_schema.ts'
|
|
16
|
+
|
|
17
|
+
export interface ApplyHeraldWebhookEventMigrationOptions {
|
|
18
|
+
registry: SchemaRegistry
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function applyHeraldWebhookEventMigration(
|
|
22
|
+
db: DatabaseExecutor,
|
|
23
|
+
options: ApplyHeraldWebhookEventMigrationOptions,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const { registry } = options
|
|
26
|
+
|
|
27
|
+
await db.execute(emitCreateTable(heraldWebhookEventSchema, { registry }).sql)
|
|
28
|
+
|
|
29
|
+
// Dedup contract — first INSERT for a `(provider, provider_event_id)`
|
|
30
|
+
// wins; replays return 200 without re-firing handlers.
|
|
31
|
+
await db.execute(
|
|
32
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "idx_herald_webhook_event_dedup"
|
|
33
|
+
ON "${heraldWebhookEventSchema.name}" ("provider", "provider_event_id")`,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// Ops query: "stuck deliveries" — received but never processed.
|
|
37
|
+
await db.execute(
|
|
38
|
+
`CREATE INDEX IF NOT EXISTS "idx_herald_webhook_event_unprocessed"
|
|
39
|
+
ON "${heraldWebhookEventSchema.name}" ("received_at")
|
|
40
|
+
WHERE "processed_at" IS NULL`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized webhook event types for `@strav/herald`.
|
|
3
|
+
*
|
|
4
|
+
* Drivers map their native event shapes (GBP `LOCAL_POST_REVIEW`,
|
|
5
|
+
* Meta `feed.comment`, WP REST custom hooks) onto this closed union.
|
|
6
|
+
* Apps register handlers by normalized type via
|
|
7
|
+
* `HeraldWebhookRegistry`; the native event is on `ctx.raw`.
|
|
8
|
+
*
|
|
9
|
+
* Two event families:
|
|
10
|
+
*
|
|
11
|
+
* - **post.** — async confirmation of a publish that initially
|
|
12
|
+
* returned `pending`. Apps reconcile by calling
|
|
13
|
+
* `PublicationRepository.markPublished` / `markFailed` /
|
|
14
|
+
* `markDeleted` from the handler.
|
|
15
|
+
*
|
|
16
|
+
* - **engagement.** — inbound interactions on a published post
|
|
17
|
+
* (comments, reactions, reviews). Apps surface these to
|
|
18
|
+
* operators, notify the owner, run moderation, etc.
|
|
19
|
+
*
|
|
20
|
+
* The `unknown` variant is for events the driver can't (or won't)
|
|
21
|
+
* map onto the closed union — the dedup row still records receipt
|
|
22
|
+
* so ops dashboards can spot novel events.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type HeraldEventType =
|
|
26
|
+
// publish lifecycle (async confirmation)
|
|
27
|
+
| 'post.published'
|
|
28
|
+
| 'post.failed'
|
|
29
|
+
| 'post.deleted'
|
|
30
|
+
// engagement on a published post
|
|
31
|
+
| 'engagement.comment'
|
|
32
|
+
| 'engagement.review'
|
|
33
|
+
| 'engagement.reaction'
|
|
34
|
+
// catch-all
|
|
35
|
+
| 'unknown'
|
|
36
|
+
|
|
37
|
+
export interface HeraldWebhookEventBase {
|
|
38
|
+
/** Driver-assigned id; the dedup key. */
|
|
39
|
+
id: string
|
|
40
|
+
/** Normalized type. */
|
|
41
|
+
type: HeraldEventType
|
|
42
|
+
/**
|
|
43
|
+
* App-chosen instance name (the `:provider` route param, matches
|
|
44
|
+
* `herald.use(name)`). Drivers' `parse` sets this to the driver
|
|
45
|
+
* name (`'gbp'` / `'meta'`); the dispatcher overrides with the
|
|
46
|
+
* instance name.
|
|
47
|
+
*/
|
|
48
|
+
provider: string
|
|
49
|
+
/**
|
|
50
|
+
* Provider-side account the event belongs to (FB Page id, GBP
|
|
51
|
+
* location id, WP site host). Matches `PublishTarget.accountId`.
|
|
52
|
+
* Lets apps reverse-lookup the tenant via their account → tenant
|
|
53
|
+
* map; webhooks themselves arrive without tenant context.
|
|
54
|
+
*/
|
|
55
|
+
accountId: string
|
|
56
|
+
/** Provider-issued post id the event refers to, when applicable. */
|
|
57
|
+
providerPostId?: string
|
|
58
|
+
/** Best-known platform-side timestamp of the event. */
|
|
59
|
+
occurredAt?: Date
|
|
60
|
+
/** Native provider event payload. */
|
|
61
|
+
raw: unknown
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PostPublishedEvent extends HeraldWebhookEventBase {
|
|
65
|
+
type: 'post.published'
|
|
66
|
+
providerPostId: string
|
|
67
|
+
url?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PostFailedEvent extends HeraldWebhookEventBase {
|
|
71
|
+
type: 'post.failed'
|
|
72
|
+
errorMessage: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PostDeletedEvent extends HeraldWebhookEventBase {
|
|
76
|
+
type: 'post.deleted'
|
|
77
|
+
providerPostId: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CommentEvent extends HeraldWebhookEventBase {
|
|
81
|
+
type: 'engagement.comment'
|
|
82
|
+
providerPostId: string
|
|
83
|
+
commentId: string
|
|
84
|
+
authorId?: string
|
|
85
|
+
authorName?: string
|
|
86
|
+
text?: string
|
|
87
|
+
parentCommentId?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ReviewEvent extends HeraldWebhookEventBase {
|
|
91
|
+
type: 'engagement.review'
|
|
92
|
+
reviewId: string
|
|
93
|
+
rating?: number
|
|
94
|
+
text?: string
|
|
95
|
+
authorName?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ReactionEvent extends HeraldWebhookEventBase {
|
|
99
|
+
type: 'engagement.reaction'
|
|
100
|
+
providerPostId: string
|
|
101
|
+
reactionType: string
|
|
102
|
+
authorId?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface UnknownEvent extends HeraldWebhookEventBase {
|
|
106
|
+
type: 'unknown'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type HeraldWebhookEvent =
|
|
110
|
+
| PostPublishedEvent
|
|
111
|
+
| PostFailedEvent
|
|
112
|
+
| PostDeletedEvent
|
|
113
|
+
| CommentEvent
|
|
114
|
+
| ReviewEvent
|
|
115
|
+
| ReactionEvent
|
|
116
|
+
| UnknownEvent
|
|
117
|
+
|
|
118
|
+
// ─── Handler types ──────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export interface WebhookHandlerContext {
|
|
121
|
+
event: HeraldWebhookEvent
|
|
122
|
+
/** Convenience shortcut for `event.id`. */
|
|
123
|
+
eventId: string
|
|
124
|
+
/** Convenience shortcut for `event.type`. */
|
|
125
|
+
eventType: HeraldEventType
|
|
126
|
+
/** Convenience shortcut for `event.provider`. */
|
|
127
|
+
provider: string
|
|
128
|
+
/** Convenience shortcut for `event.accountId`. */
|
|
129
|
+
accountId: string
|
|
130
|
+
/** Convenience shortcut for `event.raw`. */
|
|
131
|
+
raw: unknown
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type WebhookHandler = (ctx: WebhookHandlerContext) => void | Promise<void>
|
|
135
|
+
|
|
136
|
+
export interface WebhookHandlerFilter {
|
|
137
|
+
/** Restrict the handler to one provider instance. Omitted = fires for any provider that emits the type. */
|
|
138
|
+
provider?: string
|
|
139
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic webhook route handler.
|
|
3
|
+
*
|
|
4
|
+
* Mount once per app:
|
|
5
|
+
*
|
|
6
|
+
* router.post('/webhooks/herald/:provider', heraldWebhook())
|
|
7
|
+
*
|
|
8
|
+
* Per request flow:
|
|
9
|
+
*
|
|
10
|
+
* 1. Read the `:provider` route param — picks the driver instance
|
|
11
|
+
* to verify against.
|
|
12
|
+
* 2. Read the raw body. Signature is computed over the bytes, so
|
|
13
|
+
* JSON-parsing first invalidates verification.
|
|
14
|
+
* 3. Resolve the signature header — drivers carry their own header
|
|
15
|
+
* name (`x-hub-signature-256` for Meta, `x-wp-signature` for
|
|
16
|
+
* WP, …); the handler reads them all.
|
|
17
|
+
* 4. `driver.webhook.verifySignature(rawBody, signature)` — 400 on
|
|
18
|
+
* missing / malformed; `false` on clean mismatch also returns
|
|
19
|
+
* 400 (provider retries).
|
|
20
|
+
* 5. `driver.webhook.parse(rawBody)` — driver normalizes every
|
|
21
|
+
* event in the batch into the `HeraldWebhookEvent` union.
|
|
22
|
+
* 6. Per event: idempotency claim against
|
|
23
|
+
* `herald_webhook_event(provider, provider_event_id)`. The
|
|
24
|
+
* first delivery wins; replays are skipped.
|
|
25
|
+
* 7. Dispatch matching handlers from `HeraldWebhookRegistry`.
|
|
26
|
+
* Handlers run in registration order. Throwing leaves
|
|
27
|
+
* `processed_at` NULL so dashboards surface stuck events; the
|
|
28
|
+
* route returns 500 so the provider retries the batch.
|
|
29
|
+
* 8. Mark `processed_at` on success.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { HttpContext } from '@strav/http'
|
|
33
|
+
import { UnknownProviderError, WebhookSignatureError } from '../errors.ts'
|
|
34
|
+
import { HeraldManager } from '../herald_manager.ts'
|
|
35
|
+
import type { HeraldWebhookEvent, WebhookHandlerContext } from './herald_event.ts'
|
|
36
|
+
import { HeraldWebhookEventRepository } from './herald_webhook_event_repository.ts'
|
|
37
|
+
|
|
38
|
+
export interface HeraldWebhookOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Extra header names to probe for the provider signature. The
|
|
41
|
+
* built-in list covers the v1 drivers; apps with custom drivers
|
|
42
|
+
* append theirs.
|
|
43
|
+
*/
|
|
44
|
+
signatureHeaders?: readonly string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_SIGNATURE_HEADERS = [
|
|
48
|
+
// Meta (Facebook + Instagram)
|
|
49
|
+
'x-hub-signature-256',
|
|
50
|
+
'x-hub-signature',
|
|
51
|
+
// WordPress (custom plugin)
|
|
52
|
+
'x-wp-signature',
|
|
53
|
+
// Google (X-Goog-Channel-Token + signature)
|
|
54
|
+
'x-goog-signature',
|
|
55
|
+
// Generic fallback for custom drivers
|
|
56
|
+
'webhook-signature',
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
export function heraldWebhook(
|
|
60
|
+
options: HeraldWebhookOptions = {},
|
|
61
|
+
): (ctx: HttpContext) => Promise<Response> {
|
|
62
|
+
const headerNames = options.signatureHeaders ?? DEFAULT_SIGNATURE_HEADERS
|
|
63
|
+
|
|
64
|
+
return async (ctx: HttpContext): Promise<Response> => {
|
|
65
|
+
const providerName = ctx.request.params['provider']
|
|
66
|
+
if (!providerName) {
|
|
67
|
+
return ctx.response.json(
|
|
68
|
+
{ error: 'Missing :provider route param. Mount as `/webhooks/herald/:provider`.' },
|
|
69
|
+
{ status: 400 },
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const manager = ctx.container.resolve(HeraldManager)
|
|
74
|
+
let driver
|
|
75
|
+
try {
|
|
76
|
+
driver = manager.use(providerName)
|
|
77
|
+
} catch (cause) {
|
|
78
|
+
if (cause instanceof UnknownProviderError) {
|
|
79
|
+
return ctx.response.json({ error: cause.message }, { status: 404 })
|
|
80
|
+
}
|
|
81
|
+
throw cause
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const signature = findSignatureHeader(ctx.request.headers, headerNames)
|
|
85
|
+
const rawBody = await ctx.request.raw.text()
|
|
86
|
+
|
|
87
|
+
let verified: boolean
|
|
88
|
+
try {
|
|
89
|
+
verified = driver.webhook.verifySignature(rawBody, signature)
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
if (cause instanceof WebhookSignatureError) {
|
|
92
|
+
return ctx.response.json({ error: cause.message }, { status: 400 })
|
|
93
|
+
}
|
|
94
|
+
throw cause
|
|
95
|
+
}
|
|
96
|
+
if (!verified) {
|
|
97
|
+
return ctx.response.json({ error: 'Webhook signature verification failed.' }, { status: 400 })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const events = driver.webhook.parse(rawBody) as HeraldWebhookEvent[]
|
|
101
|
+
const repo = ctx.container.resolve(HeraldWebhookEventRepository)
|
|
102
|
+
const results: Array<{ id: string; type: string; duplicate: boolean }> = []
|
|
103
|
+
|
|
104
|
+
for (const event of events) {
|
|
105
|
+
// Honour instance-name routing — drivers stamp `provider` with
|
|
106
|
+
// their own name (`'meta'`); the route param is the instance
|
|
107
|
+
// name apps configured under (`'meta-marketing'`). Handlers
|
|
108
|
+
// resolve against the instance name.
|
|
109
|
+
const routed: HeraldWebhookEvent = { ...event, provider: providerName }
|
|
110
|
+
const claimed = await repo.claim(providerName, routed.id, routed.type)
|
|
111
|
+
if (!claimed) {
|
|
112
|
+
results.push({ id: routed.id, type: routed.type, duplicate: true })
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
await dispatch(manager, routed)
|
|
116
|
+
await repo.markProcessed(providerName, routed.id)
|
|
117
|
+
results.push({ id: routed.id, type: routed.type, duplicate: false })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return ctx.response.json({ received: true, events: results })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function dispatch(
|
|
125
|
+
manager: HeraldManager,
|
|
126
|
+
event: HeraldWebhookEvent,
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const handlers = manager.webhookRegistry.resolve(event.type, event.provider)
|
|
129
|
+
if (handlers.length === 0) return
|
|
130
|
+
const ctx: WebhookHandlerContext = {
|
|
131
|
+
event,
|
|
132
|
+
eventId: event.id,
|
|
133
|
+
eventType: event.type,
|
|
134
|
+
provider: event.provider,
|
|
135
|
+
accountId: event.accountId,
|
|
136
|
+
raw: event.raw,
|
|
137
|
+
}
|
|
138
|
+
for (const handler of handlers) {
|
|
139
|
+
await handler(ctx)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function findSignatureHeader(headers: Headers, names: readonly string[]): string | null {
|
|
144
|
+
for (const name of names) {
|
|
145
|
+
const value = headers.get(name)
|
|
146
|
+
if (value) return value
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HeraldWebhookEventRecord` — typed row of the dedup ledger.
|
|
3
|
+
*
|
|
4
|
+
* Apps rarely touch this directly. Operator dashboards use it to list
|
|
5
|
+
* recent events, surface processing latencies, or flag stuck
|
|
6
|
+
* deliveries (rows where `processed_at` is still NULL after dispatch
|
|
7
|
+
* should have completed).
|
|
8
|
+
*
|
|
9
|
+
* Named `…Record` (not `…Event`) to avoid colliding with the
|
|
10
|
+
* normalized `HeraldWebhookEvent` union in `herald_event.ts`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Model } from '@strav/database'
|
|
14
|
+
import { heraldWebhookEventSchema } from './herald_webhook_event_schema.ts'
|
|
15
|
+
|
|
16
|
+
export class HeraldWebhookEventRecord extends Model {
|
|
17
|
+
static override readonly schema = heraldWebhookEventSchema
|
|
18
|
+
|
|
19
|
+
id!: string
|
|
20
|
+
provider!: string
|
|
21
|
+
provider_event_id!: string
|
|
22
|
+
event_type!: string
|
|
23
|
+
received_at!: Date
|
|
24
|
+
processed_at!: Date | null
|
|
25
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HeraldWebhookEventRepository` — data access for the webhook
|
|
3
|
+
* dedup ledger.
|
|
4
|
+
*
|
|
5
|
+
* Two custom helpers on top of the generic CRUD surface:
|
|
6
|
+
*
|
|
7
|
+
* - `claim(provider, eventId, type)` — atomic INSERT ... ON
|
|
8
|
+
* CONFLICT DO NOTHING RETURNING *. Returns the row on win,
|
|
9
|
+
* `null` when another delivery already recorded the
|
|
10
|
+
* `(provider, provider_event_id)` pair.
|
|
11
|
+
*
|
|
12
|
+
* - `markProcessed(provider, eventId)` — bumps `processed_at` to
|
|
13
|
+
* NOW(). Observability-only; not part of the dedup decision.
|
|
14
|
+
* Rows with `received_at` set but `processed_at` NULL surface
|
|
15
|
+
* stuck deliveries (handler threw / process crashed
|
|
16
|
+
* mid-dispatch).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { quoteIdent, Repository } from '@strav/database'
|
|
20
|
+
import { ulid } from '@strav/kernel'
|
|
21
|
+
import { HeraldWebhookEventRecord } from './herald_webhook_event.ts'
|
|
22
|
+
import { heraldWebhookEventSchema } from './herald_webhook_event_schema.ts'
|
|
23
|
+
|
|
24
|
+
export class HeraldWebhookEventRepository extends Repository<HeraldWebhookEventRecord> {
|
|
25
|
+
static override readonly schema = heraldWebhookEventSchema
|
|
26
|
+
static override readonly model = HeraldWebhookEventRecord
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Atomically record receipt of an event. Returns the inserted row
|
|
30
|
+
* when this call won the race, `null` when the
|
|
31
|
+
* `(provider, provider_event_id)` pair was already recorded.
|
|
32
|
+
*/
|
|
33
|
+
async claim(
|
|
34
|
+
provider: string,
|
|
35
|
+
providerEventId: string,
|
|
36
|
+
eventType: string,
|
|
37
|
+
): Promise<HeraldWebhookEventRecord | null> {
|
|
38
|
+
const table = quoteIdent(heraldWebhookEventSchema.name)
|
|
39
|
+
const sql = `
|
|
40
|
+
INSERT INTO ${table}
|
|
41
|
+
("id", "provider", "provider_event_id", "event_type", "received_at")
|
|
42
|
+
VALUES ($1, $2, $3, $4, NOW())
|
|
43
|
+
ON CONFLICT ("provider", "provider_event_id") DO NOTHING
|
|
44
|
+
RETURNING *
|
|
45
|
+
`
|
|
46
|
+
const rows = await this.db.query<Record<string, unknown>>(sql, [
|
|
47
|
+
ulid(),
|
|
48
|
+
provider,
|
|
49
|
+
providerEventId,
|
|
50
|
+
eventType,
|
|
51
|
+
])
|
|
52
|
+
if (rows.length === 0) return null
|
|
53
|
+
return this.hydrate(rows[0]!)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Bump `processed_at` to NOW(). No-op when the row doesn't exist. */
|
|
57
|
+
async markProcessed(provider: string, providerEventId: string): Promise<void> {
|
|
58
|
+
const table = quoteIdent(heraldWebhookEventSchema.name)
|
|
59
|
+
await this.db.execute(
|
|
60
|
+
`UPDATE ${table} SET "processed_at" = NOW()
|
|
61
|
+
WHERE "provider" = $1 AND "provider_event_id" = $2`,
|
|
62
|
+
[provider, providerEventId],
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `heraldWebhookEventSchema` — system-wide dedup ledger for inbound
|
|
3
|
+
* webhooks from every configured Herald provider.
|
|
4
|
+
*
|
|
5
|
+
* On every delivery (after signature verification + parse), the
|
|
6
|
+
* framework does:
|
|
7
|
+
*
|
|
8
|
+
* INSERT INTO herald_webhook_event (...) ON CONFLICT DO NOTHING
|
|
9
|
+
*
|
|
10
|
+
* The first delivery wins the INSERT and fires user handlers;
|
|
11
|
+
* subsequent deliveries (provider retries, concurrent webhook
|
|
12
|
+
* workers) see the conflict and return 200 without re-firing.
|
|
13
|
+
*
|
|
14
|
+
* **Why NOT tenanted:** webhooks arrive without tenant context.
|
|
15
|
+
* The dedup INSERT must run before any payload inspection (it gates
|
|
16
|
+
* handler dispatch); tenant routing — when needed — happens at the
|
|
17
|
+
* handler layer once the app has resolved `accountId → tenantId`.
|
|
18
|
+
*
|
|
19
|
+
* Composite unique `(provider, provider_event_id)` lives in the
|
|
20
|
+
* migration; different providers may emit colliding id formats so
|
|
21
|
+
* the pair is the actual uniqueness contract.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Archetype, defineSchema } from '@strav/database'
|
|
25
|
+
|
|
26
|
+
export const heraldWebhookEventSchema = defineSchema(
|
|
27
|
+
'herald_webhook_event',
|
|
28
|
+
Archetype.Event,
|
|
29
|
+
(t) => {
|
|
30
|
+
t.id()
|
|
31
|
+
t.string('provider').max(64).notNull()
|
|
32
|
+
t.string('provider_event_id').max(255).notNull()
|
|
33
|
+
t.string('event_type').max(128).notNull()
|
|
34
|
+
t.timestamp('received_at').notNull()
|
|
35
|
+
t.timestamp('processed_at').nullable()
|
|
36
|
+
},
|
|
37
|
+
)
|