@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,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `publicationSchema` — ledger of posts published to external
|
|
3
|
+
* platforms via `@strav/herald`. **Non-tenanted by default**
|
|
4
|
+
* (framework policy: multitenancy is opt-in). Apps that need
|
|
5
|
+
* per-tenant scoping import `tenantedPublicationSchema` from
|
|
6
|
+
* `@strav/herald/tenanted` instead.
|
|
7
|
+
*
|
|
8
|
+
* One row per publish attempt that the platform accepted. Apps that
|
|
9
|
+
* fan out a single conceptual "post" to GBP + Facebook + Instagram +
|
|
10
|
+
* WordPress get four rows here — one per provider — all linked back
|
|
11
|
+
* to the app-side post via `source_id`. Failed publishes are recorded
|
|
12
|
+
* too (status = `failed`) so retry logic + ops dashboards see them.
|
|
13
|
+
*
|
|
14
|
+
* Composite uniqueness lives in the migration:
|
|
15
|
+
* `(provider, instance_name, provider_post_id)` — one canonical
|
|
16
|
+
* row per platform-side post. Re-publishing the same provider_post_id
|
|
17
|
+
* (e.g. on webhook reconciliation) upserts.
|
|
18
|
+
*
|
|
19
|
+
* Columns:
|
|
20
|
+
*
|
|
21
|
+
* - `id` ULID PK.
|
|
22
|
+
* - `source_id` App-side post / draft id (free-form
|
|
23
|
+
* string so apps with ULID / int / uuid
|
|
24
|
+
* PKs all fit). Lets apps query "all
|
|
25
|
+
* channels this post went to".
|
|
26
|
+
* - `provider` Driver identifier (`'gbp'` /
|
|
27
|
+
* `'meta'` / `'wordpress'` / custom).
|
|
28
|
+
* - `instance_name` `config.herald.providers[name]` —
|
|
29
|
+
* distinguishes two configs of the same
|
|
30
|
+
* driver (e.g. `meta-th` vs `meta-en`).
|
|
31
|
+
* - `account_id` Provider-side account this was posted
|
|
32
|
+
* to (FB Page id, GBP location id, WP
|
|
33
|
+
* site host). Matches `PublishTarget.accountId`.
|
|
34
|
+
* - `provider_post_id` Provider-issued id of the resulting
|
|
35
|
+
* post. Required once status leaves
|
|
36
|
+
* `pending`; nullable while waiting.
|
|
37
|
+
* - `url` Public URL of the post (when the
|
|
38
|
+
* platform exposes one).
|
|
39
|
+
* - `status` `pending` / `published` / `failed` /
|
|
40
|
+
* `deleted`. Matches `PostStatus`.
|
|
41
|
+
* - `published_at` When the platform confirmed (or our
|
|
42
|
+
* best-known timestamp from the webhook).
|
|
43
|
+
* - `error_message` Last platform-reported failure reason
|
|
44
|
+
* (set when status flips to `failed`).
|
|
45
|
+
* - `metadata` Free-form jsonb — driver extras (GBP
|
|
46
|
+
* search-url, IG media-type, WP post-type, …).
|
|
47
|
+
* - `created_at` / `updated_at`
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { Archetype, defineSchema } from '@strav/database'
|
|
51
|
+
|
|
52
|
+
export const publicationSchema = defineSchema(
|
|
53
|
+
'publication',
|
|
54
|
+
Archetype.Entity,
|
|
55
|
+
(t) => {
|
|
56
|
+
t.id()
|
|
57
|
+
t.string('source_id').max(64).notNull()
|
|
58
|
+
t.string('provider').max(64).notNull()
|
|
59
|
+
t.string('instance_name').max(64).notNull()
|
|
60
|
+
t.string('account_id').max(255).notNull()
|
|
61
|
+
t.string('provider_post_id').max(255).nullable()
|
|
62
|
+
t.string('url').max(2048).nullable()
|
|
63
|
+
t.string('status').max(16).notNull()
|
|
64
|
+
t.timestamp('published_at').nullable()
|
|
65
|
+
t.string('error_message').max(1024).nullable()
|
|
66
|
+
t.json('metadata').notNull().default({})
|
|
67
|
+
t.timestamp('created_at').notNull()
|
|
68
|
+
t.timestamp('updated_at').notNull()
|
|
69
|
+
},
|
|
70
|
+
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `completeChannelConnect` — pure async function that runs the
|
|
3
|
+
* OAuth-callback half of "connect a publishing channel" against
|
|
4
|
+
* `@strav/social`.
|
|
5
|
+
*
|
|
6
|
+
* Flow:
|
|
7
|
+
*
|
|
8
|
+
* 1. `social.use(provider).exchange({ code, state?, expectedState?,
|
|
9
|
+
* codeVerifier? })` → `OAuthTokens`.
|
|
10
|
+
* 2. `social.use(provider).profile(tokens.accessToken)` →
|
|
11
|
+
* `SocialProfile` (the user we just authenticated).
|
|
12
|
+
* 3. Per `enumerate` strategy, fetch the connectable list — FB
|
|
13
|
+
* pages, IG Business accounts (via FB), or GBP locations.
|
|
14
|
+
*
|
|
15
|
+
* Returns `{ provider, profile, tokens, connectables }`. Apps render
|
|
16
|
+
* the connectable list, let the user pick one (or many), and persist
|
|
17
|
+
* their choice into `social_account` (or wherever the publishing
|
|
18
|
+
* driver's `credentialsFor` resolver reads from).
|
|
19
|
+
*
|
|
20
|
+
* No state / cookie / session handling lives here — apps verify state
|
|
21
|
+
* themselves (or via the `connectChannelCallback()` route handler
|
|
22
|
+
* which wraps this fn plus a cookie-based verifier).
|
|
23
|
+
*
|
|
24
|
+
* Duck-type checks the driver for the enumerate-helper method so
|
|
25
|
+
* `@strav/herald` doesn't take a hard `@strav/social` dep at the
|
|
26
|
+
* type level (the `SocialManager` peer dep is the only contract).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { OAuthTokens, SocialManager, SocialProfile } from '@strav/social'
|
|
30
|
+
import { HeraldProviderError } from '../errors.ts'
|
|
31
|
+
import type { ChannelConnectable, EnumerationStrategy } from './types.ts'
|
|
32
|
+
|
|
33
|
+
export interface CompleteChannelConnectInput {
|
|
34
|
+
social: SocialManager
|
|
35
|
+
/** Name of the social provider that handles the OAuth dance (typically `'facebook'` or `'google'`). */
|
|
36
|
+
provider: string
|
|
37
|
+
/** OAuth callback `code` query param. */
|
|
38
|
+
code: string
|
|
39
|
+
/** Redirect URI registered with the provider — must match the one passed at authorize-time. */
|
|
40
|
+
redirectUri: string
|
|
41
|
+
/** OAuth callback `state` query param — the value the user came back with. */
|
|
42
|
+
state?: string
|
|
43
|
+
/** State the app issued at authorize-time (the trust anchor for verification). */
|
|
44
|
+
expectedState?: string
|
|
45
|
+
/** PKCE verifier the app persisted at authorize-time. Required for drivers with PKCE. */
|
|
46
|
+
codeVerifier?: string
|
|
47
|
+
/** Which enumeration to run after exchange. `'none'` skips it (apps that only want tokens). */
|
|
48
|
+
enumerate?: EnumerationStrategy
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ChannelConnectResult {
|
|
52
|
+
provider: string
|
|
53
|
+
profile: SocialProfile
|
|
54
|
+
tokens: OAuthTokens
|
|
55
|
+
connectables: ChannelConnectable[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface FacebookEnumerateOps {
|
|
59
|
+
listPages(token: string): Promise<
|
|
60
|
+
Array<{ id: string; name: string; category?: string; accessToken: string; tasks?: readonly string[]; raw: unknown }>
|
|
61
|
+
>
|
|
62
|
+
listInstagramBusinessAccounts(token: string): Promise<
|
|
63
|
+
Array<{
|
|
64
|
+
id: string
|
|
65
|
+
username?: string
|
|
66
|
+
name?: string
|
|
67
|
+
profilePictureUrl?: string
|
|
68
|
+
pageId: string
|
|
69
|
+
pageAccessToken: string
|
|
70
|
+
raw: unknown
|
|
71
|
+
}>
|
|
72
|
+
>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface GoogleEnumerateOps {
|
|
76
|
+
listGbpLocations(token: string): Promise<
|
|
77
|
+
Array<{
|
|
78
|
+
name: string
|
|
79
|
+
accountName: string
|
|
80
|
+
title: string
|
|
81
|
+
address?: string
|
|
82
|
+
languageCode?: string
|
|
83
|
+
storeCode?: string
|
|
84
|
+
raw: unknown
|
|
85
|
+
}>
|
|
86
|
+
>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function completeChannelConnect(
|
|
90
|
+
input: CompleteChannelConnectInput,
|
|
91
|
+
): Promise<ChannelConnectResult> {
|
|
92
|
+
const driver = input.social.use(input.provider)
|
|
93
|
+
const tokens = await driver.exchange({
|
|
94
|
+
code: input.code,
|
|
95
|
+
redirectUri: input.redirectUri,
|
|
96
|
+
...(input.state !== undefined ? { state: input.state } : {}),
|
|
97
|
+
...(input.expectedState !== undefined ? { expectedState: input.expectedState } : {}),
|
|
98
|
+
...(input.codeVerifier !== undefined ? { codeVerifier: input.codeVerifier } : {}),
|
|
99
|
+
})
|
|
100
|
+
const profile = await driver.profile(tokens.accessToken)
|
|
101
|
+
|
|
102
|
+
const connectables = await enumerate(driver, tokens.accessToken, input.enumerate ?? 'none')
|
|
103
|
+
|
|
104
|
+
return { provider: input.provider, profile, tokens, connectables }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function enumerate(
|
|
108
|
+
driver: unknown,
|
|
109
|
+
accessToken: string,
|
|
110
|
+
strategy: EnumerationStrategy,
|
|
111
|
+
): Promise<ChannelConnectable[]> {
|
|
112
|
+
if (strategy === 'none') return []
|
|
113
|
+
|
|
114
|
+
if (strategy === 'facebook-pages') {
|
|
115
|
+
const ops = driver as Partial<FacebookEnumerateOps>
|
|
116
|
+
if (typeof ops.listPages !== 'function') {
|
|
117
|
+
throw new HeraldProviderError(
|
|
118
|
+
`completeChannelConnect: enumerate='facebook-pages' requires a social driver that exposes \`listPages\` (e.g. \`@strav/social/facebook\`).`,
|
|
119
|
+
{ provider: 'herald.onboarding', operation: 'enumerate', status: 500 },
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
const pages = await ops.listPages(accessToken)
|
|
123
|
+
return pages.map((p) => ({ kind: 'facebook-page' as const, ...p }))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (strategy === 'facebook-ig-accounts') {
|
|
127
|
+
const ops = driver as Partial<FacebookEnumerateOps>
|
|
128
|
+
if (typeof ops.listInstagramBusinessAccounts !== 'function') {
|
|
129
|
+
throw new HeraldProviderError(
|
|
130
|
+
`completeChannelConnect: enumerate='facebook-ig-accounts' requires a social driver that exposes \`listInstagramBusinessAccounts\` (e.g. \`@strav/social/facebook\`).`,
|
|
131
|
+
{ provider: 'herald.onboarding', operation: 'enumerate', status: 500 },
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
const accounts = await ops.listInstagramBusinessAccounts(accessToken)
|
|
135
|
+
return accounts.map((a) => ({ kind: 'facebook-ig-account' as const, ...a }))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (strategy === 'gbp-locations') {
|
|
139
|
+
const ops = driver as Partial<GoogleEnumerateOps>
|
|
140
|
+
if (typeof ops.listGbpLocations !== 'function') {
|
|
141
|
+
throw new HeraldProviderError(
|
|
142
|
+
`completeChannelConnect: enumerate='gbp-locations' requires a social driver that exposes \`listGbpLocations\` (e.g. \`@strav/social/google\`).`,
|
|
143
|
+
{ provider: 'herald.onboarding', operation: 'enumerate', status: 500 },
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
const locations = await ops.listGbpLocations(accessToken)
|
|
147
|
+
return locations.map((l) => ({ kind: 'gbp-location' as const, ...l }))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new HeraldProviderError(
|
|
151
|
+
`completeChannelConnect: unknown enumerate strategy "${strategy}".`,
|
|
152
|
+
{ provider: 'herald.onboarding', operation: 'enumerate', status: 500 },
|
|
153
|
+
)
|
|
154
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `connectChannelCallback` — HTTP route handler that wraps
|
|
3
|
+
* `completeChannelConnect` plus a small amount of state-lookup wiring.
|
|
4
|
+
*
|
|
5
|
+
* Mount once per app:
|
|
6
|
+
*
|
|
7
|
+
* router.get('/oauth/connect/:provider/callback', connectChannelCallback({
|
|
8
|
+
* social: app.resolve(SocialManager),
|
|
9
|
+
* strategies: {
|
|
10
|
+
* 'fb-page': { provider: 'facebook', enumerate: 'facebook-pages' },
|
|
11
|
+
* 'ig': { provider: 'facebook', enumerate: 'facebook-ig-accounts' },
|
|
12
|
+
* 'gbp': { provider: 'google', enumerate: 'gbp-locations' },
|
|
13
|
+
* },
|
|
14
|
+
* loadCodeVerifier: (state) => sessionStore.get(`pkce:${state}`),
|
|
15
|
+
* loadExpectedState: (state) => sessionStore.get(`state:${state}`),
|
|
16
|
+
* }))
|
|
17
|
+
*
|
|
18
|
+
* Per request flow:
|
|
19
|
+
*
|
|
20
|
+
* 1. Resolve `:provider` against the `strategies` map → the social
|
|
21
|
+
* provider name + enumeration strategy.
|
|
22
|
+
* 2. Read `code` + `state` from the query string.
|
|
23
|
+
* 3. Load the persisted `codeVerifier` + `expectedState` (apps that
|
|
24
|
+
* use cookie-based sessions plug their session store in via
|
|
25
|
+
* the callbacks).
|
|
26
|
+
* 4. Hand off to `completeChannelConnect`.
|
|
27
|
+
* 5. Return JSON: `{ provider, profile, tokens, connectables }`.
|
|
28
|
+
*
|
|
29
|
+
* Apps render the connectables (FB Pages / IG accounts / GBP
|
|
30
|
+
* locations), let the owner pick one, and persist to `social_account`.
|
|
31
|
+
*
|
|
32
|
+
* Apps that need a redirect-rendered HTML page instead of JSON wrap
|
|
33
|
+
* `completeChannelConnect` in their own route. The route handler here
|
|
34
|
+
* is opinionated: JSON in, JSON out.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { HttpContext } from '@strav/http'
|
|
38
|
+
import type { SocialManager } from '@strav/social'
|
|
39
|
+
import { HeraldConfigError } from '../errors.ts'
|
|
40
|
+
import { completeChannelConnect } from './complete_channel_connect.ts'
|
|
41
|
+
import type { EnumerationStrategy } from './types.ts'
|
|
42
|
+
|
|
43
|
+
export interface ConnectChannelStrategy {
|
|
44
|
+
/** Social provider name to exchange against (`'facebook'`, `'google'`). */
|
|
45
|
+
provider: string
|
|
46
|
+
/** Which enumeration helper to run after exchange. */
|
|
47
|
+
enumerate?: EnumerationStrategy
|
|
48
|
+
/**
|
|
49
|
+
* Redirect URI registered with the provider. Must match the URI
|
|
50
|
+
* passed to `authorize()` at flow-start. Omit to let the handler
|
|
51
|
+
* derive it from the inbound request URL (origin + path, no query).
|
|
52
|
+
*/
|
|
53
|
+
redirectUri?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ConnectChannelCallbackOptions {
|
|
57
|
+
social: SocialManager
|
|
58
|
+
/** Maps the `:provider` URL param to a social provider + enumeration strategy. */
|
|
59
|
+
strategies: Record<string, ConnectChannelStrategy>
|
|
60
|
+
/** Resolve the PKCE verifier the app stored at authorize-time, keyed by the OAuth state. */
|
|
61
|
+
loadCodeVerifier?: (
|
|
62
|
+
state: string,
|
|
63
|
+
) => Promise<string | undefined> | string | undefined
|
|
64
|
+
/** Resolve the expected state value the app stored at authorize-time, keyed by the OAuth state. */
|
|
65
|
+
loadExpectedState?: (
|
|
66
|
+
state: string,
|
|
67
|
+
) => Promise<string | undefined> | string | undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function connectChannelCallback(
|
|
71
|
+
options: ConnectChannelCallbackOptions,
|
|
72
|
+
): (ctx: HttpContext) => Promise<Response> {
|
|
73
|
+
if (!options.strategies || Object.keys(options.strategies).length === 0) {
|
|
74
|
+
throw new HeraldConfigError(
|
|
75
|
+
'connectChannelCallback: `strategies` map is empty. Declare at least one URL-param → social-provider mapping.',
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return async (ctx: HttpContext): Promise<Response> => {
|
|
80
|
+
const paramName = ctx.request.params['provider']
|
|
81
|
+
if (!paramName) {
|
|
82
|
+
return ctx.response.json(
|
|
83
|
+
{ error: 'Missing :provider route param. Mount as `/oauth/connect/:provider/callback`.' },
|
|
84
|
+
{ status: 400 },
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
const strategy = options.strategies[paramName]
|
|
88
|
+
if (!strategy) {
|
|
89
|
+
return ctx.response.json(
|
|
90
|
+
{
|
|
91
|
+
error: `Unknown provider "${paramName}".`,
|
|
92
|
+
available: Object.keys(options.strategies),
|
|
93
|
+
},
|
|
94
|
+
{ status: 404 },
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const url = new URL(ctx.request.raw.url)
|
|
99
|
+
const code = url.searchParams.get('code')
|
|
100
|
+
const state = url.searchParams.get('state') ?? undefined
|
|
101
|
+
|
|
102
|
+
if (!code) {
|
|
103
|
+
const error = url.searchParams.get('error')
|
|
104
|
+
const description = url.searchParams.get('error_description')
|
|
105
|
+
return ctx.response.json(
|
|
106
|
+
{
|
|
107
|
+
error: error ?? 'Missing `code` query param.',
|
|
108
|
+
...(description ? { error_description: description } : {}),
|
|
109
|
+
},
|
|
110
|
+
{ status: 400 },
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const codeVerifier =
|
|
115
|
+
state && options.loadCodeVerifier
|
|
116
|
+
? (await options.loadCodeVerifier(state)) ?? undefined
|
|
117
|
+
: undefined
|
|
118
|
+
const expectedState =
|
|
119
|
+
state && options.loadExpectedState
|
|
120
|
+
? (await options.loadExpectedState(state)) ?? undefined
|
|
121
|
+
: undefined
|
|
122
|
+
|
|
123
|
+
const redirectUri = strategy.redirectUri ?? `${url.origin}${url.pathname}`
|
|
124
|
+
|
|
125
|
+
const result = await completeChannelConnect({
|
|
126
|
+
social: options.social,
|
|
127
|
+
provider: strategy.provider,
|
|
128
|
+
code,
|
|
129
|
+
redirectUri,
|
|
130
|
+
...(state !== undefined ? { state } : {}),
|
|
131
|
+
...(expectedState !== undefined ? { expectedState } : {}),
|
|
132
|
+
...(codeVerifier !== undefined ? { codeVerifier } : {}),
|
|
133
|
+
...(strategy.enumerate !== undefined ? { enumerate: strategy.enumerate } : {}),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return ctx.response.json(result)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Public API of `@strav/herald/onboarding`.
|
|
2
|
+
//
|
|
3
|
+
// Glue for the "connect a publishing channel" OAuth callback:
|
|
4
|
+
// `completeChannelConnect` runs the social.exchange → profile →
|
|
5
|
+
// enumerate sequence; `connectChannelCallback` wraps it into a
|
|
6
|
+
// mountable HTTP route handler. Apps render the returned
|
|
7
|
+
// `connectables` and persist the owner's pick into their social
|
|
8
|
+
// account ledger.
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
type ChannelConnectResult,
|
|
12
|
+
type CompleteChannelConnectInput,
|
|
13
|
+
completeChannelConnect,
|
|
14
|
+
} from './complete_channel_connect.ts'
|
|
15
|
+
export {
|
|
16
|
+
type ConnectChannelCallbackOptions,
|
|
17
|
+
type ConnectChannelStrategy,
|
|
18
|
+
connectChannelCallback,
|
|
19
|
+
} from './connect_channel_callback.ts'
|
|
20
|
+
export type {
|
|
21
|
+
ChannelConnectable,
|
|
22
|
+
EnumerationStrategy,
|
|
23
|
+
FacebookIgAccountConnectable,
|
|
24
|
+
FacebookPageConnectable,
|
|
25
|
+
GbpLocationConnectable,
|
|
26
|
+
} from './types.ts'
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discriminated union of "connectable" rows the onboarding helpers
|
|
3
|
+
* return — what an authenticated user can be offered as publishing
|
|
4
|
+
* destinations after an OAuth exchange.
|
|
5
|
+
*
|
|
6
|
+
* Each variant carries the IDs + tokens the matching `@strav/herald/*`
|
|
7
|
+
* driver expects:
|
|
8
|
+
*
|
|
9
|
+
* - `facebook-page.id` + `accessToken` → `PublishTarget.accountId`
|
|
10
|
+
* and the credential the FB Meta driver consumes.
|
|
11
|
+
* - `facebook-ig-account.id` (IG Business id) + `pageAccessToken`
|
|
12
|
+
* (parent Page token) → IG Meta driver.
|
|
13
|
+
* - `gbp-location.name` (full `accounts/.../locations/...` resource
|
|
14
|
+
* path) → GBP driver. The access token is the OAuth-exchange
|
|
15
|
+
* `tokens.accessToken` (Google access tokens are short-lived
|
|
16
|
+
* and refreshable; apps store the `refresh_token` and rotate).
|
|
17
|
+
*
|
|
18
|
+
* The runtime shape mirrors `FacebookPage` / `FacebookIgBusinessAccount`
|
|
19
|
+
* / `GbpLocation` from `@strav/social` — re-declared here so apps that
|
|
20
|
+
* only consume the result don't have to depend on `@strav/social`
|
|
21
|
+
* types.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type ChannelConnectable =
|
|
25
|
+
| FacebookPageConnectable
|
|
26
|
+
| FacebookIgAccountConnectable
|
|
27
|
+
| GbpLocationConnectable
|
|
28
|
+
|
|
29
|
+
export interface FacebookPageConnectable {
|
|
30
|
+
kind: 'facebook-page'
|
|
31
|
+
id: string
|
|
32
|
+
name: string
|
|
33
|
+
category?: string
|
|
34
|
+
accessToken: string
|
|
35
|
+
tasks?: readonly string[]
|
|
36
|
+
raw: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FacebookIgAccountConnectable {
|
|
40
|
+
kind: 'facebook-ig-account'
|
|
41
|
+
id: string
|
|
42
|
+
username?: string
|
|
43
|
+
name?: string
|
|
44
|
+
profilePictureUrl?: string
|
|
45
|
+
pageId: string
|
|
46
|
+
pageAccessToken: string
|
|
47
|
+
raw: unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GbpLocationConnectable {
|
|
51
|
+
kind: 'gbp-location'
|
|
52
|
+
/** Full resource path: `accounts/{aid}/locations/{lid}`. */
|
|
53
|
+
name: string
|
|
54
|
+
accountName: string
|
|
55
|
+
title: string
|
|
56
|
+
address?: string
|
|
57
|
+
languageCode?: string
|
|
58
|
+
storeCode?: string
|
|
59
|
+
raw: unknown
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Strategy keys for the enumeration step in `completeChannelConnect`. */
|
|
63
|
+
export type EnumerationStrategy =
|
|
64
|
+
| 'facebook-pages'
|
|
65
|
+
| 'facebook-ig-accounts'
|
|
66
|
+
| 'gbp-locations'
|
|
67
|
+
| 'none'
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `applyTenantedPublicationMigration` — DDL for the opt-in tenanted
|
|
3
|
+
* variant of the publication ledger.
|
|
4
|
+
*
|
|
5
|
+
* Composite unique becomes `(tenant_id, provider, instance_name,
|
|
6
|
+
* provider_post_id)`. The `source_id` index serves the "all channels
|
|
7
|
+
* for source" lookup; the `provider/status` index serves operational
|
|
8
|
+
* queries like "all pending publishes for provider X".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
emitCreateTable,
|
|
13
|
+
type DatabaseExecutor,
|
|
14
|
+
type SchemaRegistry,
|
|
15
|
+
} from '@strav/database'
|
|
16
|
+
import { tenantedPublicationSchema } from './tenanted_publication_schema.ts'
|
|
17
|
+
|
|
18
|
+
export interface ApplyTenantedPublicationMigrationOptions {
|
|
19
|
+
/** Required for `emitCreateTable` to resolve the tenant FK ref. */
|
|
20
|
+
registry: SchemaRegistry
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function applyTenantedPublicationMigration(
|
|
24
|
+
db: DatabaseExecutor,
|
|
25
|
+
options: ApplyTenantedPublicationMigrationOptions,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const { registry } = options
|
|
28
|
+
|
|
29
|
+
await db.execute(emitCreateTable(tenantedPublicationSchema, { registry }).sql)
|
|
30
|
+
|
|
31
|
+
await db.execute(
|
|
32
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "idx_publication_tenant_provider_post"
|
|
33
|
+
ON "${tenantedPublicationSchema.name}" ("tenant_id", "provider", "instance_name", "provider_post_id")
|
|
34
|
+
WHERE "provider_post_id" IS NOT NULL`,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
await db.execute(
|
|
38
|
+
`CREATE INDEX IF NOT EXISTS "idx_publication_source"
|
|
39
|
+
ON "${tenantedPublicationSchema.name}" ("source_id")`,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
await db.execute(
|
|
43
|
+
`CREATE INDEX IF NOT EXISTS "idx_publication_provider_status"
|
|
44
|
+
ON "${tenantedPublicationSchema.name}" ("provider", "status")`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Public API of `@strav/herald/tenanted` — the opt-in tenant-scoped
|
|
2
|
+
// variant of the publication ledger.
|
|
3
|
+
//
|
|
4
|
+
// Apps that need per-tenant publications import from here. Default
|
|
5
|
+
// single-tenant apps stay on `@strav/herald` and never pay for the
|
|
6
|
+
// extra column / RLS / `withTenant` wrapping.
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
applyTenantedPublicationMigration,
|
|
10
|
+
type ApplyTenantedPublicationMigrationOptions,
|
|
11
|
+
} from './apply_tenanted_publication_migration.ts'
|
|
12
|
+
export { TenantedPublication } from './tenanted_publication.ts'
|
|
13
|
+
export {
|
|
14
|
+
type MarkPublishedInput,
|
|
15
|
+
type RecordInput,
|
|
16
|
+
TenantedPublicationRepository,
|
|
17
|
+
} from './tenanted_publication_repository.ts'
|
|
18
|
+
export { tenantedPublicationSchema } from './tenanted_publication_schema.ts'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TenantedPublication` — typed row of the opt-in tenanted ledger.
|
|
3
|
+
* Identical fields to `Publication`; its `static schema` points at
|
|
4
|
+
* the tenanted variant so the Repository runs against the right
|
|
5
|
+
* DDL + RLS policy.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Model } from '@strav/database'
|
|
9
|
+
import type { PostStatus } from '../dto/post_status.ts'
|
|
10
|
+
import { tenantedPublicationSchema } from './tenanted_publication_schema.ts'
|
|
11
|
+
|
|
12
|
+
export class TenantedPublication extends Model {
|
|
13
|
+
static override readonly schema = tenantedPublicationSchema
|
|
14
|
+
|
|
15
|
+
id!: string
|
|
16
|
+
source_id!: string
|
|
17
|
+
provider!: string
|
|
18
|
+
instance_name!: string
|
|
19
|
+
account_id!: string
|
|
20
|
+
provider_post_id!: string | null
|
|
21
|
+
url!: string | null
|
|
22
|
+
status!: PostStatus
|
|
23
|
+
published_at!: Date | null
|
|
24
|
+
error_message!: string | null
|
|
25
|
+
metadata!: Record<string, unknown>
|
|
26
|
+
created_at!: Date
|
|
27
|
+
updated_at!: Date
|
|
28
|
+
}
|