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