From 3a5a676cf3579fafa1f080ae73d63480cd912974 Mon Sep 17 00:00:00 2001 From: fajarhide Date: Mon, 17 Aug 2026 12:13:27 +0700 Subject: [PATCH] feat: facebook, three read tools on the user token Every tool reads through /me, so the one user token a grant already stores is the only credential involved. That is what makes this half of Meta buildable today: Pages and Instagram are a separate use case in the console, and each Page there carries its own token the executor cannot swap per call. Paths carry no version so Meta applies the app's own default. Pinning one from memory would fail every call once that version is retired, for a reason nothing in the error explains. The token trade is Threads' minus the refresh. Facebook has no th_refresh_token equivalent for a user token, so a grant dies at sixty days and reconnecting is the only path. longLivedRefresh is absent on purpose. Ships experimental. No request in this file has reached Meta. Closes #20 --- .env.example | 9 ++ src/adapters/oauth/catalog.ts | 19 +++++ src/adapters/providers/boot.ts | 2 + src/adapters/providers/facebook.ts | 94 +++++++++++++++++++++ test/providers/facebook.test.ts | 129 +++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+) create mode 100644 src/adapters/providers/facebook.ts create mode 100644 test/providers/facebook.test.ts diff --git a/.env.example b/.env.example index b791531..d1669a1 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,15 @@ NOTION_CLIENT_SECRET= SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= +# developers.facebook.com, a Meta app with the Facebook Login use case added. +# That use case carries only user_* permissions, so every tool reads through /me +# on the one stored token. Pages and Instagram are a separate use case and are +# not covered here. Same https requirement as Threads: point PUBLIC_URL at a +# tunnel. +# Callback: PUBLIC_URL/v1/connections/facebook/callback +FACEBOOK_CLIENT_ID= +FACEBOOK_CLIENT_SECRET= + # developers.facebook.com, a Meta app with the Threads API use case added. The # client id is the app id. Meta calls the callback a Redirect Callback URL and # requires https, so localhost will not do: point PUBLIC_URL at a tunnel. diff --git a/src/adapters/oauth/catalog.ts b/src/adapters/oauth/catalog.ts index d5823f4..87e0877 100644 --- a/src/adapters/oauth/catalog.ts +++ b/src/adapters/oauth/catalog.ts @@ -51,6 +51,25 @@ export const GRANT_ENDPOINTS: Record = { // and falls back to the body form on its own. tokenAuth: 'basic', }, + // Unversioned on purpose, the same reason the manifest is: Meta applies the + // app's own default version, and a version pinned from memory would fail + // every call once it is retired. + facebook: { + authorizeUrl: 'https://www.facebook.com/dialog/oauth', + tokenUrl: 'https://graph.facebook.com/oauth/access_token', + scopeSeparator: ',', + // The code exchange yields a short lived token. Traded here for a sixty day + // one, which is the only form worth storing. + longLived: { + url: 'https://graph.facebook.com/oauth/access_token', + tokenParam: 'fb_exchange_token', + params: { grant_type: 'fb_exchange_token' }, + withClientSecret: true, + }, + // No longLivedRefresh on purpose. Facebook has no th_refresh_token + // equivalent for a user token, so a grant dies at sixty days and + // reconnecting is the only path. + }, // The authorize host is threads.net while the token host is graph.threads.net, // which is Meta's split and not a typo. threads: { diff --git a/src/adapters/providers/boot.ts b/src/adapters/providers/boot.ts index 52c44f1..a7f39b5 100644 --- a/src/adapters/providers/boot.ts +++ b/src/adapters/providers/boot.ts @@ -1,6 +1,7 @@ import { createRegistry, type ProviderAdapter, type Registry } from './registry.ts' import { fakeProvider } from './fake.ts' import { discordProvider } from './discord.ts' +import { facebookProvider } from './facebook.ts' import { githubProvider } from './github.ts' import { gcalendarProvider } from './gcalendar.ts' import { gdriveProvider } from './gdrive.ts' @@ -34,6 +35,7 @@ export function bootRegistry(env: NodeJS.ProcessEnv = process.env): Registry { ['GOOGLE_CLIENT_ID', gdriveProvider], ['NOTION_CLIENT_ID', notionProvider], ['SLACK_CLIENT_ID', slackProvider], + ['FACEBOOK_CLIENT_ID', facebookProvider], ['THREADS_CLIENT_ID', threadsProvider], ['TWITTER_CLIENT_ID', xProvider], ] as const diff --git a/src/adapters/providers/facebook.ts b/src/adapters/providers/facebook.ts new file mode 100644 index 0000000..734201b --- /dev/null +++ b/src/adapters/providers/facebook.ts @@ -0,0 +1,94 @@ +import { manifestProvider, type ProviderManifest } from './manifest.ts' +import type { ProviderAdapter } from './registry.ts' + +/** + * The personal half of Meta. Every tool here reads through `/me`, so the one + * user token a grant already stores is the only credential involved. Pages and + * Instagram live in a separate use case in Meta's console, and each Page there + * carries its own token, which the executor cannot swap per call. That is why + * they are absent rather than forgotten. + * + * Paths carry no version, so Meta applies the app's own default. Pinning one + * here would name a version from memory, and Graph versions are retired on a + * schedule, so the wrong guess would fail every call for a reason nothing in + * the error explains. Pin it once the console shows which version the app is on. + * + * Unproven against the vendor: the fixtures below are written from the + * documentation, which catches a wrong shape and cannot catch a wrong document. + */ + +const POST_FIELDS = 'id,message,story,permalink_url,created_time,status_type' + +export const facebookManifest: ProviderManifest = { + id: 'facebook', + prefix: 'facebook', + maturity: 'experimental', + baseUrl: 'https://graph.facebook.com', + scopes: ['public_profile', 'user_link', 'user_posts', 'user_likes'], + auth: { type: 'bearer' }, + pagination: { + style: 'cursor', + size: 25, + sizeParam: 'limit', + param: 'after', + nextPath: 'paging.cursors.after', + // Meta leaves a cursor behind on the last page, so the cursor alone would + // page forever. paging.next is present only while more exists. + hasMorePath: 'paging.next', + }, + tools: [ + { + name: 'get_me', + description: 'Read the Facebook account behind this connection', + write: false, + request: 'GET /me', + args: { + response_fields: { + type: 'string', + description: 'Meta fields selector', + default: 'id,name,link', + param: 'fields', + }, + }, + fields: ['id', 'name', 'link'], + }, + { + name: 'list_my_posts', + description: 'List the posts this account published, newest first', + write: false, + request: 'GET /me/posts', + args: { + since: { type: 'string', description: 'ISO date lower bound, for example 2026-08-01' }, + until: { type: 'string', description: 'ISO date upper bound' }, + response_fields: { + type: 'string', + description: 'Meta fields selector', + default: POST_FIELDS, + param: 'fields', + }, + }, + items: 'data', + fields: ['id', 'message', 'story', 'permalink_url', 'created_time', 'status_type'], + }, + { + name: 'list_liked_pages', + description: 'List the Pages this account has liked', + write: false, + request: 'GET /me/likes', + args: { + response_fields: { + type: 'string', + description: 'Meta fields selector', + default: 'id,name,link,category', + param: 'fields', + }, + }, + items: 'data', + fields: ['id', 'name', 'link', 'category'], + }, + ], +} + +export function facebookProvider(): ProviderAdapter { + return manifestProvider(facebookManifest) +} diff --git a/test/providers/facebook.test.ts b/test/providers/facebook.test.ts new file mode 100644 index 0000000..fc3fcb0 --- /dev/null +++ b/test/providers/facebook.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { facebookManifest, facebookProvider } from '../../src/adapters/providers/facebook.ts' +import { buildAuthorizeUrl } from '../../src/adapters/oauth/client.ts' +import { GRANT_ENDPOINTS } from '../../src/adapters/oauth/catalog.ts' +import { fakeUpstream, itemsPage, type FakeUpstream } from '../helpers/fake-upstream.ts' +import { runAdapterConformance } from '../conformance/adapter.ts' + +const facebook = facebookProvider() + +function ctx(upstream: FakeUpstream) { + return { workspaceId: 'ws-1', requestId: 'req-1', accessToken: 'EAAG...', fetch: upstream.fetch } +} + +function page(count: number, over: object) { + return { data: itemsPage(count), paging: { cursors: { after: 'QVFIU' }, ...over } } +} + +describe('facebook conformance', () => { + runAdapterConformance(facebook, { + pagedTool: 'list_my_posts', + fullPage: { + args: {}, + upstream: fakeUpstream([ + { + match: /me\/posts/, + body: page(25, { next: 'https://graph.facebook.com/me/posts?after=QVFIU' }), + }, + ]), + }, + lastPage: { + args: {}, + // The cursor is still there on the last page, which is the trap. + upstream: fakeUpstream([{ match: /me\/posts/, body: page(4, {}) }]), + }, + }) +}) + +describe('facebook', () => { + it('stops at the last page even though Meta leaves a cursor behind', async () => { + const more = fakeUpstream([ + { match: /me\/posts/, body: page(25, { next: 'https://graph.facebook.com/next' }) }, + ]) + expect(await facebook.callTool(ctx(more), 'list_my_posts', {})).toMatchObject({ + hasMore: true, + nextCursor: 'QVFIU', + }) + + const done = fakeUpstream([{ match: /me\/posts/, body: page(25, {}) }]) + expect(await facebook.callTool(ctx(done), 'list_my_posts', {})).toMatchObject({ + hasMore: false, + nextCursor: null, + }) + }) + + it('names the fields it wants, because Meta returns only an id otherwise', async () => { + const upstream = fakeUpstream([{ match: /me\/posts/, body: page(1, {}) }]) + await facebook.callTool(ctx(upstream), 'list_my_posts', {}) + const params = new URL(upstream.calls[0]?.url ?? '').searchParams + expect(params.get('fields')).toContain('permalink_url') + expect(params.get('limit')).toBe('25') + }) + + it('projects a post down to the declared fields', async () => { + const upstream = fakeUpstream([ + { + match: /me\/posts/, + body: { + data: [ + { + id: 'p1', + message: 'hello', + permalink_url: 'https://facebook.com/p1', + created_time: '2026-08-01T00:00:00+0000', + status_type: 'mobile_status_update', + // Present in a real payload and deliberately not declared. + privacy: { value: 'EVERYONE' }, + }, + ], + }, + }, + ]) + const result = await facebook.callTool(ctx(upstream), 'list_my_posts', {}) + expect(result.content).toEqual({ + items: [ + { + id: 'p1', + message: 'hello', + permalink_url: 'https://facebook.com/p1', + created_time: '2026-08-01T00:00:00+0000', + status_type: 'mobile_status_update', + }, + ], + }) + }) + + it('reads the account and the liked pages through /me', async () => { + const upstream = fakeUpstream([ + { match: /me\/likes/, body: { data: [{ id: 'g1', name: 'A Page', category: 'Bar' }] } }, + { match: /\/me\?/, body: { id: 'u1', name: 'Someone', link: 'https://facebook.com/u1' } }, + ]) + expect((await facebook.callTool(ctx(upstream), 'get_me', {})).content).toEqual({ + id: 'u1', + name: 'Someone', + link: 'https://facebook.com/u1', + }) + const liked = await facebook.callTool(ctx(upstream), 'list_liked_pages', {}) + expect(liked.content).toEqual({ items: [{ id: 'g1', name: 'A Page', category: 'Bar' }] }) + }) + + it('carries no version in any path, so Meta applies the app default', () => { + for (const tool of facebookManifest.tools) { + expect(tool.request).not.toMatch(/\/v\d+\.\d+\//) + } + expect(facebookManifest.baseUrl).not.toMatch(/\/v\d+\.\d+/) + }) + + it('asks for the user scopes and nothing a Page would need', () => { + const url = buildAuthorizeUrl( + { ...GRANT_ENDPOINTS.facebook!, clientId: 'cid' }, + { redirectUri: 'https://example.com/cb', state: 's', challenge: 'c', scopes: facebook.scopes }, + ) + const scope = new URL(url).searchParams.get('scope') ?? '' + expect(scope).toContain('user_posts') + expect(scope).not.toContain('pages_') + expect(scope).not.toContain('instagram_') + // Meta separates with commas, not spaces. + expect(scope).toContain(',') + }) +})