@strav/social 1.0.0-alpha.34 → 1.0.0-alpha.36

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/social",
3
- "version": "1.0.0-alpha.34",
3
+ "version": "1.0.0-alpha.36",
4
4
  "description": "Strav social-login module — provider-agnostic OAuth/OIDC client. Normalized profile + token DTOs, state + PKCE helpers, capability gating, multi-provider routing. Line / Google / Facebook adapters ship as subpath imports (`@strav/social/line`, `@strav/social/google`, `@strav/social/facebook`).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -23,9 +23,9 @@
23
23
  "access": "public"
24
24
  },
25
25
  "dependencies": {
26
- "@strav/database": "1.0.0-alpha.34",
27
- "@strav/http": "1.0.0-alpha.34",
28
- "@strav/kernel": "1.0.0-alpha.34"
26
+ "@strav/database": "1.0.0-alpha.36",
27
+ "@strav/http": "1.0.0-alpha.36",
28
+ "@strav/kernel": "1.0.0-alpha.36"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@types/bun": ">=1.3.14"
@@ -34,6 +34,7 @@ export interface FacebookProviderConfig extends ProviderConfig {
34
34
  me?: string
35
35
  permissions?: string
36
36
  debugToken?: string
37
+ accounts?: string
37
38
  }
38
39
  fetch?: typeof fetch
39
40
  }
@@ -47,6 +48,7 @@ export function facebookEndpoints(version = DEFAULT_VERSION): {
47
48
  me: string
48
49
  permissions: string
49
50
  debugToken: string
51
+ accounts: string
50
52
  } {
51
53
  return {
52
54
  authorize: `https://www.facebook.com/${version}/dialog/oauth`,
@@ -54,6 +56,7 @@ export function facebookEndpoints(version = DEFAULT_VERSION): {
54
56
  me: `${GRAPH}/${version}/me`,
55
57
  permissions: `${GRAPH}/${version}/me/permissions`,
56
58
  debugToken: `${GRAPH}/${version}/debug_token`,
59
+ accounts: `${GRAPH}/${version}/me/accounts`,
57
60
  }
58
61
  }
59
62
 
@@ -107,6 +107,59 @@ interface DebugTokenResponse {
107
107
  }
108
108
  }
109
109
 
110
+ /**
111
+ * Normalised view of a Facebook Page returned by `listPages()`.
112
+ * `accessToken` is the page-scoped long-lived token apps store +
113
+ * pass to publishing drivers (`@strav/herald/meta`) — the user token
114
+ * is short-lived and lacks page-level publish scopes by design.
115
+ */
116
+ export interface FacebookPage {
117
+ id: string
118
+ name: string
119
+ category?: string
120
+ /** Page-scoped access token. Treat as a long-lived secret. */
121
+ accessToken: string
122
+ /** `tasks` field — the management surfaces this token can exercise (`CREATE_CONTENT`, `MODERATE`, …). */
123
+ tasks?: readonly string[]
124
+ raw: unknown
125
+ }
126
+
127
+ /**
128
+ * Instagram Business Account exposed through a connected Facebook
129
+ * Page. Returned by `listInstagramBusinessAccounts()` — the page's
130
+ * `instagram_business_account` field. Apps publishing to IG via
131
+ * `@strav/herald/meta` use this id as the `accountId`; the
132
+ * publishing access token is the parent page's `accessToken`.
133
+ */
134
+ export interface FacebookIgBusinessAccount {
135
+ id: string
136
+ username?: string
137
+ name?: string
138
+ profilePictureUrl?: string
139
+ /** Parent Facebook Page id. */
140
+ pageId: string
141
+ /** Parent page access token — the credential the IG publishing driver expects. */
142
+ pageAccessToken: string
143
+ raw: unknown
144
+ }
145
+
146
+ interface AccountsResponse {
147
+ data?: Array<{
148
+ id?: string
149
+ name?: string
150
+ category?: string
151
+ access_token?: string
152
+ tasks?: string[]
153
+ instagram_business_account?: {
154
+ id?: string
155
+ username?: string
156
+ name?: string
157
+ profile_picture_url?: string
158
+ }
159
+ }>
160
+ paging?: { next?: string }
161
+ }
162
+
110
163
  export class FacebookSocialDriver implements SocialDriver {
111
164
  readonly name = PROVIDER
112
165
  readonly instanceName: string
@@ -303,8 +356,112 @@ export class FacebookSocialDriver implements SocialDriver {
303
356
  return (await res.json()) as DebugTokenResponse
304
357
  }
305
358
 
359
+ // ─── Publishing-side enumeration helpers ───────────────────────────
360
+
361
+ /**
362
+ * List the Facebook Pages this user can publish to.
363
+ *
364
+ * Calls `GET /me/accounts?fields=id,name,category,access_token,tasks`
365
+ * — the same endpoint used to mint **page-scoped** access tokens
366
+ * after a user OAuth exchange. The returned `accessToken` on each
367
+ * page is the long-lived page token apps store + hand to
368
+ * `@strav/herald/meta` for FB publishing.
369
+ *
370
+ * The user token must carry `pages_show_list`. To actually publish,
371
+ * the user must also have approved `pages_manage_posts` +
372
+ * `pages_read_engagement` for the app.
373
+ *
374
+ * Pagination: this helper auto-follows `paging.next` and returns
375
+ * every page. Apps with thousands of pages (rare) trim themselves
376
+ * client-side.
377
+ */
378
+ async listPages(userAccessToken: string): Promise<FacebookPage[]> {
379
+ const url = `${this.endpoints.accounts}?${new URLSearchParams({
380
+ access_token: userAccessToken,
381
+ fields: 'id,name,category,access_token,tasks',
382
+ limit: '100',
383
+ }).toString()}`
384
+ const pages = await this.collectAccounts(url)
385
+ return pages
386
+ .filter((p) => p.id && p.name && p.access_token)
387
+ .map((p) => ({
388
+ id: p.id!,
389
+ name: p.name!,
390
+ accessToken: p.access_token!,
391
+ ...(p.category ? { category: p.category } : {}),
392
+ ...(p.tasks ? { tasks: p.tasks } : {}),
393
+ raw: p,
394
+ }))
395
+ }
396
+
397
+ /**
398
+ * List the Instagram Business accounts reachable through this
399
+ * user's Facebook Pages. Each IG Business account is "owned" by a
400
+ * Page; the `pageAccessToken` is the credential the IG publishing
401
+ * driver expects (IG-User tokens for content publishing are
402
+ * derived from the parent page token).
403
+ *
404
+ * Calls `/me/accounts?fields=id,access_token,instagram_business_account{id,username,name,profile_picture_url}`
405
+ * and flattens out Pages that don't have an IG Business account.
406
+ *
407
+ * Scopes required: `pages_show_list` + `instagram_basic`.
408
+ * Publishing additionally needs `instagram_content_publish` +
409
+ * `instagram_manage_comments`.
410
+ */
411
+ async listInstagramBusinessAccounts(
412
+ userAccessToken: string,
413
+ ): Promise<FacebookIgBusinessAccount[]> {
414
+ const url = `${this.endpoints.accounts}?${new URLSearchParams({
415
+ access_token: userAccessToken,
416
+ fields:
417
+ 'id,access_token,instagram_business_account{id,username,name,profile_picture_url}',
418
+ limit: '100',
419
+ }).toString()}`
420
+ const pages = await this.collectAccounts(url)
421
+ const out: FacebookIgBusinessAccount[] = []
422
+ for (const p of pages) {
423
+ const ig = p.instagram_business_account
424
+ if (!ig?.id || !p.id || !p.access_token) continue
425
+ out.push({
426
+ id: ig.id,
427
+ ...(ig.username ? { username: ig.username } : {}),
428
+ ...(ig.name ? { name: ig.name } : {}),
429
+ ...(ig.profile_picture_url ? { profilePictureUrl: ig.profile_picture_url } : {}),
430
+ pageId: p.id,
431
+ pageAccessToken: p.access_token,
432
+ raw: p,
433
+ })
434
+ }
435
+ return out
436
+ }
437
+
306
438
  // ─── Internals ────────────────────────────────────────────────────────
307
439
 
440
+ private async collectAccounts(
441
+ url: string,
442
+ ): Promise<NonNullable<AccountsResponse['data']>> {
443
+ const out: NonNullable<AccountsResponse['data']> = []
444
+ let next: string | undefined = url
445
+ while (next) {
446
+ const res = await this.fetchFn(next)
447
+ if (!res.ok) {
448
+ const text = await res.text()
449
+ throw new SocialProviderError(
450
+ `FacebookSocialDriver.collectAccounts: Graph API returned ${res.status}.`,
451
+ {
452
+ provider: PROVIDER,
453
+ operation: 'list_accounts',
454
+ context: { status: res.status, body: text },
455
+ },
456
+ )
457
+ }
458
+ const body = (await res.json()) as AccountsResponse
459
+ if (body.data) out.push(...body.data)
460
+ next = body.paging?.next
461
+ }
462
+ return out
463
+ }
464
+
308
465
  private toOAuthTokens(t: TokenResponse): OAuthTokens {
309
466
  const expiresAt =
310
467
  typeof t.expires_in === 'number'
@@ -6,7 +6,9 @@ export {
6
6
  type FacebookProviderConfig,
7
7
  } from './facebook_config.ts'
8
8
  export {
9
- FacebookSocialDriver,
10
9
  type FacebookDriverOptions,
10
+ type FacebookIgBusinessAccount,
11
+ type FacebookPage,
12
+ FacebookSocialDriver,
11
13
  } from './facebook_driver.ts'
12
14
  export { FacebookSocialProvider } from './facebook_provider.ts'
@@ -94,6 +94,56 @@ interface JwtPayload {
94
94
  [k: string]: unknown
95
95
  }
96
96
 
97
+ /**
98
+ * Normalised view of a Google Business Profile location returned by
99
+ * `listGbpLocations()`. The `name` field is the **full GBP resource
100
+ * path** (`accounts/{aid}/locations/{lid}`) — exactly what
101
+ * `@strav/herald/gbp` expects as `PublishTarget.accountId`.
102
+ */
103
+ export interface GbpLocation {
104
+ /** Full resource path: `accounts/{accountId}/locations/{locationId}`. */
105
+ name: string
106
+ /** Account resource path the location belongs to. */
107
+ accountName: string
108
+ /** Display title — what the SME sees on their dashboard. */
109
+ title: string
110
+ /** Optional storefront address (when set on the listing). */
111
+ address?: string
112
+ /** Default language code for this location. */
113
+ languageCode?: string
114
+ /** Internal store code the SME assigned, if any. */
115
+ storeCode?: string
116
+ raw: unknown
117
+ }
118
+
119
+ interface GbpAccountsResponse {
120
+ accounts?: Array<{ name?: string; accountName?: string; type?: string }>
121
+ nextPageToken?: string
122
+ }
123
+
124
+ interface GbpLocationsResponse {
125
+ locations?: Array<{
126
+ name?: string
127
+ title?: string
128
+ languageCode?: string
129
+ storeCode?: string
130
+ storefrontAddress?: { addressLines?: string[]; locality?: string; regionCode?: string }
131
+ }>
132
+ nextPageToken?: string
133
+ }
134
+
135
+ function formatGbpAddress(addr: {
136
+ addressLines?: string[]
137
+ locality?: string
138
+ regionCode?: string
139
+ }): string {
140
+ const parts: string[] = []
141
+ if (addr.addressLines) parts.push(...addr.addressLines.filter(Boolean))
142
+ if (addr.locality) parts.push(addr.locality)
143
+ if (addr.regionCode) parts.push(addr.regionCode)
144
+ return parts.join(', ')
145
+ }
146
+
97
147
  export class GoogleSocialDriver implements SocialDriver {
98
148
  readonly name = PROVIDER
99
149
  readonly instanceName: string
@@ -262,8 +312,113 @@ export class GoogleSocialDriver implements SocialDriver {
262
312
  }
263
313
  }
264
314
 
315
+ // ─── Publishing-side enumeration helpers ───────────────────────────
316
+
317
+ /**
318
+ * List every Google Business Profile location this user can manage.
319
+ *
320
+ * Two-call walk (GBP splits accounts + locations across two APIs):
321
+ *
322
+ * 1. `mybusinessaccountmanagement.googleapis.com/v1/accounts` —
323
+ * every account (personal or business group) the user has a
324
+ * management role on.
325
+ *
326
+ * 2. For each account, `mybusinessbusinessinformation.googleapis.com
327
+ * /v1/{account}/locations?readMask=name,title,storefrontAddress,
328
+ * languageCode,storeCode` — every location attached to that
329
+ * account.
330
+ *
331
+ * Returns one entry per location with the **full resource path**
332
+ * (`accounts/{aid}/locations/{lid}`) as `name` — the shape
333
+ * `@strav/herald/gbp` expects as `PublishTarget.accountId`.
334
+ *
335
+ * Scope required: `https://www.googleapis.com/auth/business.manage`.
336
+ * Pagination is auto-followed.
337
+ */
338
+ async listGbpLocations(accessToken: string): Promise<GbpLocation[]> {
339
+ const accounts = await this.collectGbpAccounts(accessToken)
340
+ const out: GbpLocation[] = []
341
+ for (const account of accounts) {
342
+ if (!account.name) continue
343
+ const locations = await this.collectGbpLocations(accessToken, account.name)
344
+ for (const loc of locations) {
345
+ if (!loc.name || !loc.title) continue
346
+ out.push({
347
+ name: loc.name,
348
+ accountName: account.name,
349
+ title: loc.title,
350
+ ...(loc.languageCode ? { languageCode: loc.languageCode } : {}),
351
+ ...(loc.storeCode ? { storeCode: loc.storeCode } : {}),
352
+ ...(loc.storefrontAddress ? { address: formatGbpAddress(loc.storefrontAddress) } : {}),
353
+ raw: loc,
354
+ })
355
+ }
356
+ }
357
+ return out
358
+ }
359
+
265
360
  // ─── Internals ────────────────────────────────────────────────────────
266
361
 
362
+ private async collectGbpAccounts(
363
+ accessToken: string,
364
+ ): Promise<NonNullable<GbpAccountsResponse['accounts']>> {
365
+ const base = 'https://mybusinessaccountmanagement.googleapis.com/v1/accounts'
366
+ return this.collectPaged<GbpAccountsResponse, NonNullable<GbpAccountsResponse['accounts']>[number]>(
367
+ base,
368
+ accessToken,
369
+ 'list_gbp_accounts',
370
+ (body) => body.accounts ?? [],
371
+ (body) => body.nextPageToken,
372
+ )
373
+ }
374
+
375
+ private async collectGbpLocations(
376
+ accessToken: string,
377
+ accountName: string,
378
+ ): Promise<NonNullable<GbpLocationsResponse['locations']>> {
379
+ const base = `https://mybusinessbusinessinformation.googleapis.com/v1/${accountName}/locations?readMask=name,title,storefrontAddress,languageCode,storeCode&pageSize=100`
380
+ return this.collectPaged<
381
+ GbpLocationsResponse,
382
+ NonNullable<GbpLocationsResponse['locations']>[number]
383
+ >(
384
+ base,
385
+ accessToken,
386
+ 'list_gbp_locations',
387
+ (body) => body.locations ?? [],
388
+ (body) => body.nextPageToken,
389
+ )
390
+ }
391
+
392
+ private async collectPaged<TBody, TItem>(
393
+ baseUrl: string,
394
+ accessToken: string,
395
+ operation: string,
396
+ pick: (body: TBody) => readonly TItem[],
397
+ nextToken: (body: TBody) => string | undefined,
398
+ ): Promise<TItem[]> {
399
+ const out: TItem[] = []
400
+ let pageToken: string | undefined
401
+ do {
402
+ const url = pageToken
403
+ ? `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}pageToken=${encodeURIComponent(pageToken)}`
404
+ : baseUrl
405
+ const res = await this.fetchFn(url, {
406
+ headers: { Authorization: `Bearer ${accessToken}` },
407
+ })
408
+ if (!res.ok) {
409
+ const text = await res.text()
410
+ throw new SocialProviderError(
411
+ `GoogleSocialDriver.${operation}: GBP API returned ${res.status}.`,
412
+ { provider: PROVIDER, operation, context: { status: res.status, body: text } },
413
+ )
414
+ }
415
+ const body = (await res.json()) as TBody
416
+ for (const item of pick(body)) out.push(item)
417
+ pageToken = nextToken(body)
418
+ } while (pageToken)
419
+ return out
420
+ }
421
+
267
422
  private toOAuthTokens(t: TokenResponse): OAuthTokens {
268
423
  const expiresAt =
269
424
  typeof t.expires_in === 'number'
@@ -6,7 +6,8 @@ export {
6
6
  } from './google_config.ts'
7
7
  export {
8
8
  emailFromGoogleIdToken,
9
- GoogleSocialDriver,
9
+ type GbpLocation,
10
10
  type GoogleDriverOptions,
11
+ GoogleSocialDriver,
11
12
  } from './google_driver.ts'
12
13
  export { GoogleSocialProvider } from './google_provider.ts'