@strav/social 1.0.0-alpha.34 → 1.0.0-alpha.35
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.
|
|
3
|
+
"version": "1.0.0-alpha.35",
|
|
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.
|
|
27
|
-
"@strav/http": "1.0.0-alpha.
|
|
28
|
-
"@strav/kernel": "1.0.0-alpha.
|
|
26
|
+
"@strav/database": "1.0.0-alpha.35",
|
|
27
|
+
"@strav/http": "1.0.0-alpha.35",
|
|
28
|
+
"@strav/kernel": "1.0.0-alpha.35"
|
|
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
|
-
|
|
9
|
+
type GbpLocation,
|
|
10
10
|
type GoogleDriverOptions,
|
|
11
|
+
GoogleSocialDriver,
|
|
11
12
|
} from './google_driver.ts'
|
|
12
13
|
export { GoogleSocialProvider } from './google_provider.ts'
|