From 81ffb1da6c86643e62b6c5b5788463eaf419f35c Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Sat, 18 Jul 2026 13:59:35 -0600 Subject: [PATCH] feat(social): add social-connections read client for the gatekeeper broker Add createSocialProvider, a thin fail-open client that reads a user's connected social handles from the gatekeeper broker's token-free read endpoint (GET /social/connections/read) with a service-key bearer. Mirrors the existing gatekeeper apiKey provider. On any network error, non-2xx, or timeout it degrades to an empty result rather than throwing, so consumers render no connections instead of failing. --- build/index.d.ts | 2 + build/index.js | 45 +++++++++ build/social/gatekeeper.d.ts | 25 +++++ build/social/index.d.ts | 12 +++ build/social/index.js | 65 ++++++++++++ build/social/interface.d.ts | 39 ++++++++ build/social/social.test.d.ts | 1 + package.json | 4 + scripts/build.ts | 7 ++ src/index.ts | 10 ++ src/social/gatekeeper.ts | 87 ++++++++++++++++ src/social/index.ts | 20 ++++ src/social/interface.ts | 45 +++++++++ src/social/social.test.ts | 180 ++++++++++++++++++++++++++++++++++ 14 files changed, 542 insertions(+) create mode 100644 build/social/gatekeeper.d.ts create mode 100644 build/social/index.d.ts create mode 100644 build/social/index.js create mode 100644 build/social/interface.d.ts create mode 100644 build/social/social.test.d.ts create mode 100644 src/social/gatekeeper.ts create mode 100644 src/social/index.ts create mode 100644 src/social/interface.ts create mode 100644 src/social/social.test.ts diff --git a/build/index.d.ts b/build/index.d.ts index 67339a1..fb1adde 100644 --- a/build/index.d.ts +++ b/build/index.d.ts @@ -8,6 +8,7 @@ export { NoopFlagProvider, UnleashFlagProvider, createFlagProvider, } from "./fl export { LEGAL_BASE_URL, LEGAL_CONTACTS, LEGAL_FOOTER_LINKS, LEGAL_LINKS, LEGAL_URLS, } from "./legal"; export { NoopNotificationProvider, createNotificationProvider, } from "./notifications"; export { SECURITY_HEADERS } from "./server"; +export { GatekeeperSocialProvider, createSocialProvider, } from "./social"; export { NoopStorageProvider, S3StorageProvider, createStorageProvider, } from "./storage"; export { TtlCache } from "./util/cache"; export { CircuitBreaker } from "./util/circuitBreaker"; @@ -19,6 +20,7 @@ export type { FetchPublicCatalogOptions, PublicBundle, PublicCatalog, PublicConn export type { EmitResult, EventActor, EventInput, EventsProvider, EventsProviderConfig, EventsProviderStatus, HttpEventsProviderConfig, IggyEventsProviderConfig, NoopEventsProviderConfig, RegisteredSchema, SchemaRegistration, SubscriptionCreated, SubscriptionInput, } from "./events"; export type { FlagContext, FlagProvider, FlagProviderConfig, NoopFlagProviderConfig, UnleashFlagProviderConfig, } from "./flags"; export type { EmailParams, EmailResult, NoopNotificationProviderConfig, NotificationProvider, NotificationProviderConfig, } from "./notifications"; +export type { GatekeeperSocialProviderConfig, SocialConnectionDTO, SocialProvider, } from "./social"; export type { NoopStorageProviderConfig, PresignedParams, PresignedResult, S3StorageProviderConfig, StorageProvider, StorageProviderConfig, UploadParams, UploadResult, } from "./storage"; export type { TtlCacheConfig } from "./util/cache"; export type { CircuitBreakerConfig } from "./util/circuitBreaker"; diff --git a/build/index.js b/build/index.js index 8ab1bc4..e2e552e 100644 --- a/build/index.js +++ b/build/index.js @@ -80084,6 +80084,49 @@ var SECURITY_HEADERS = { "Referrer-Policy": "strict-origin-when-cross-origin" }; var headers_default = SECURITY_HEADERS; +// src/social/gatekeeper.ts +var REQUEST_TIMEOUT_MS9 = 5000; + +class GatekeeperSocialProvider { + config; + constructor(config) { + this.config = { + ...config, + timeoutMs: config.timeoutMs ?? REQUEST_TIMEOUT_MS9 + }; + } + async getConnections(userId) { + try { + const url = `${this.config.baseUrl}/social/connections/read?userId=${encodeURIComponent(userId)}`; + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${this.config.serviceKey}`, + Accept: "application/json" + }, + signal: AbortSignal.timeout(this.config.timeoutMs) + }); + if (!response.ok) { + throw new Error(`social connections read failed: ${response.status}`); + } + const raw = await response.json(); + return Array.isArray(raw) ? raw : raw.connections ?? []; + } catch (error) { + log("error", "social", "connections read failed", { + userId, + error: error instanceof Error ? error.message : String(error) + }); + return []; + } + } + async getConnection(userId, platform) { + const connections = await this.getConnections(userId); + return connections.find((c) => c.platform === platform) ?? null; + } +} + +// src/social/index.ts +var createSocialProvider = (config) => new GatekeeperSocialProvider(config); // src/storage/noop.ts var NOOP_BASE_URL = "https://noop.example.com"; @@ -80260,6 +80303,7 @@ export { eventMeta, ensureFreshAccessToken, createStorageProvider, + createSocialProvider, createOmniOAuthConfig, createOidcClient, createNotificationProvider, @@ -80291,6 +80335,7 @@ export { LEGAL_BASE_URL, IggyEventsProvider, HttpEventsProvider, + GatekeeperSocialProvider, GatekeeperOrgError, GatekeeperOrgClient, GatekeeperApiKeyProvider, diff --git a/build/social/gatekeeper.d.ts b/build/social/gatekeeper.d.ts new file mode 100644 index 0000000..e9b2491 --- /dev/null +++ b/build/social/gatekeeper.d.ts @@ -0,0 +1,25 @@ +import type { SocialConnectionDTO, SocialProvider } from "./interface"; +type GatekeeperSocialProviderConfig = { + /** Gatekeeper base URL (no trailing slash) */ + baseUrl: string; + /** Service key used for server-to-server auth */ + serviceKey: string; + /** Request timeout in milliseconds */ + timeoutMs?: number; +}; +/** + * Gatekeeper social connections provider. + * Reads a user's connected social handles from the Gatekeeper social API. + * + * Fail-open: on any network error, non-2xx, or timeout the reads degrade to + * "no connections" (`[]` / `null`) rather than throwing, so a consumer never + * crashes a request just because Gatekeeper is briefly unreachable. + */ +declare class GatekeeperSocialProvider implements SocialProvider { + private readonly config; + constructor(config: GatekeeperSocialProviderConfig); + getConnections(userId: string): Promise; + getConnection(userId: string, platform: string): Promise; +} +export { GatekeeperSocialProvider }; +export type { GatekeeperSocialProviderConfig }; diff --git a/build/social/index.d.ts b/build/social/index.d.ts new file mode 100644 index 0000000..4375552 --- /dev/null +++ b/build/social/index.d.ts @@ -0,0 +1,12 @@ +import type { GatekeeperSocialProviderConfig } from "./gatekeeper"; +import type { SocialProvider } from "./interface"; +/** + * Create a social connections provider backed by Gatekeeper. + * @param config - Gatekeeper base URL and service key + * @returns Configured social provider instance + */ +declare const createSocialProvider: (config: GatekeeperSocialProviderConfig) => SocialProvider; +export { createSocialProvider }; +export { GatekeeperSocialProvider } from "./gatekeeper"; +export type { GatekeeperSocialProviderConfig } from "./gatekeeper"; +export type { SocialConnectionDTO, SocialProvider } from "./interface"; diff --git a/build/social/index.js b/build/social/index.js new file mode 100644 index 0000000..2c0793e --- /dev/null +++ b/build/social/index.js @@ -0,0 +1,65 @@ +// src/util/log.ts +function log(level, module, message, data) { + const entry = { + level, + module, + message, + ...data, + timestamp: new Date().toISOString() + }; + if (level === "error") { + console.error(JSON.stringify(entry)); + } else if (level === "warn") { + console.warn(JSON.stringify(entry)); + } else { + console.info(JSON.stringify(entry)); + } +} + +// src/social/gatekeeper.ts +var REQUEST_TIMEOUT_MS = 5000; + +class GatekeeperSocialProvider { + config; + constructor(config) { + this.config = { + ...config, + timeoutMs: config.timeoutMs ?? REQUEST_TIMEOUT_MS + }; + } + async getConnections(userId) { + try { + const url = `${this.config.baseUrl}/social/connections/read?userId=${encodeURIComponent(userId)}`; + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${this.config.serviceKey}`, + Accept: "application/json" + }, + signal: AbortSignal.timeout(this.config.timeoutMs) + }); + if (!response.ok) { + throw new Error(`social connections read failed: ${response.status}`); + } + const raw = await response.json(); + return Array.isArray(raw) ? raw : raw.connections ?? []; + } catch (error) { + log("error", "social", "connections read failed", { + userId, + error: error instanceof Error ? error.message : String(error) + }); + return []; + } + } + async getConnection(userId, platform) { + const connections = await this.getConnections(userId); + return connections.find((c) => c.platform === platform) ?? null; + } +} + +// src/social/index.ts +var createSocialProvider = (config) => new GatekeeperSocialProvider(config); +export { + createSocialProvider, + GatekeeperSocialProvider +}; diff --git a/build/social/interface.d.ts b/build/social/interface.d.ts new file mode 100644 index 0000000..c193550 --- /dev/null +++ b/build/social/interface.d.ts @@ -0,0 +1,39 @@ +/** + * A user's connected social account, as read from Gatekeeper. + */ +type SocialConnectionDTO = { + /** Platform slug, e.g. "threads" or "bluesky" */ + platform: string; + /** Public handle on the platform, null until resolved */ + handle: string | null; + /** Canonical profile URL, null until resolved */ + profileUrl: string | null; + /** Avatar image URL, null until resolved */ + avatarUrl: string | null; + /** Connection status, e.g. "connected" or "pending" */ + status: string; + /** Whether the platform verified account ownership */ + verified: boolean; + /** ISO timestamp of when the connection was established */ + connectedAt: string; +}; +/** + * Social connections provider interface. + * Reads a user's connected social handles from Gatekeeper. + */ +interface SocialProvider { + /** + * Read all social connections for a user. + * @param userId - The Gatekeeper user id + * @returns The user's connections, or [] on any error (fail-open) + */ + getConnections(userId: string): Promise; + /** + * Read a single social connection for a user by platform. + * @param userId - The Gatekeeper user id + * @param platform - The platform slug to filter by + * @returns The matching connection, or null if absent or on error (fail-open) + */ + getConnection(userId: string, platform: string): Promise; +} +export type { SocialConnectionDTO, SocialProvider }; diff --git a/build/social/social.test.d.ts b/build/social/social.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/build/social/social.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/package.json b/package.json index 44bdcbb..7b5babd 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,10 @@ "import": "./build/server/index.js", "types": "./build/server/index.d.ts" }, + "./social": { + "import": "./build/social/index.js", + "types": "./build/social/index.d.ts" + }, "./storage": { "import": "./build/storage/index.js", "types": "./build/storage/index.d.ts" diff --git a/scripts/build.ts b/scripts/build.ts index b8f0fca..05285eb 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -81,6 +81,13 @@ const entries = [ target: "node" as const, external: [] as string[], }, + { + entrypoint: "./src/social/index.ts", + outdir: "./build/social", + // Fetch-only Gatekeeper client; safe in both browser and server consumers + target: "browser" as const, + external: [] as string[], + }, { entrypoint: "./src/storage/index.ts", outdir: "./build/storage", diff --git a/src/index.ts b/src/index.ts index 326f5cf..28f5ee4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,6 +74,11 @@ export { } from "./notifications"; // Server export { SECURITY_HEADERS } from "./server"; +// Social +export { + GatekeeperSocialProvider, + createSocialProvider, +} from "./social"; // Storage export { NoopStorageProvider, @@ -184,6 +189,11 @@ export type { NotificationProvider, NotificationProviderConfig, } from "./notifications"; +export type { + GatekeeperSocialProviderConfig, + SocialConnectionDTO, + SocialProvider, +} from "./social"; export type { NoopStorageProviderConfig, PresignedParams, diff --git a/src/social/gatekeeper.ts b/src/social/gatekeeper.ts new file mode 100644 index 0000000..d2aa761 --- /dev/null +++ b/src/social/gatekeeper.ts @@ -0,0 +1,87 @@ +import { log } from "../util/log"; + +import type { SocialConnectionDTO, SocialProvider } from "./interface"; + +/** Request timeout in milliseconds */ +const REQUEST_TIMEOUT_MS = 5000; + +type GatekeeperSocialProviderConfig = { + /** Gatekeeper base URL (no trailing slash) */ + baseUrl: string; + /** Service key used for server-to-server auth */ + serviceKey: string; + /** Request timeout in milliseconds */ + timeoutMs?: number; +}; + +/** + * Raw read response shape from Gatekeeper. The endpoint returns either a bare + * array or an object wrapping the array under `connections`, so accept both. + */ +type GatekeeperConnectionsResponse = + | SocialConnectionDTO[] + | { connections?: SocialConnectionDTO[] }; + +/** + * Gatekeeper social connections provider. + * Reads a user's connected social handles from the Gatekeeper social API. + * + * Fail-open: on any network error, non-2xx, or timeout the reads degrade to + * "no connections" (`[]` / `null`) rather than throwing, so a consumer never + * crashes a request just because Gatekeeper is briefly unreachable. + */ +class GatekeeperSocialProvider implements SocialProvider { + private readonly config: Required; + + constructor(config: GatekeeperSocialProviderConfig) { + this.config = { + ...config, + timeoutMs: config.timeoutMs ?? REQUEST_TIMEOUT_MS, + }; + } + + async getConnections(userId: string): Promise { + try { + const url = `${this.config.baseUrl}/social/connections/read?userId=${encodeURIComponent(userId)}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${this.config.serviceKey}`, + Accept: "application/json", + }, + signal: AbortSignal.timeout(this.config.timeoutMs), + }); + + if (!response.ok) { + throw new Error(`social connections read failed: ${response.status}`); + } + + const raw = (await response.json()) as GatekeeperConnectionsResponse; + + return Array.isArray(raw) ? raw : (raw.connections ?? []); + } catch (error) { + // Fail-open: degrade to "no connections" rather than throwing + log("error", "social", "connections read failed", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + + return []; + } + } + + async getConnection( + userId: string, + platform: string, + ): Promise { + // Gatekeeper has no per-platform read endpoint, so fetch all and filter + const connections = await this.getConnections(userId); + + return connections.find((c) => c.platform === platform) ?? null; + } +} + +export { GatekeeperSocialProvider }; + +export type { GatekeeperSocialProviderConfig }; diff --git a/src/social/index.ts b/src/social/index.ts new file mode 100644 index 0000000..40a259e --- /dev/null +++ b/src/social/index.ts @@ -0,0 +1,20 @@ +import { GatekeeperSocialProvider } from "./gatekeeper"; + +import type { GatekeeperSocialProviderConfig } from "./gatekeeper"; +import type { SocialProvider } from "./interface"; + +/** + * Create a social connections provider backed by Gatekeeper. + * @param config - Gatekeeper base URL and service key + * @returns Configured social provider instance + */ +const createSocialProvider = ( + config: GatekeeperSocialProviderConfig, +): SocialProvider => new GatekeeperSocialProvider(config); + +export { createSocialProvider }; + +export { GatekeeperSocialProvider } from "./gatekeeper"; + +export type { GatekeeperSocialProviderConfig } from "./gatekeeper"; +export type { SocialConnectionDTO, SocialProvider } from "./interface"; diff --git a/src/social/interface.ts b/src/social/interface.ts new file mode 100644 index 0000000..bf31cd5 --- /dev/null +++ b/src/social/interface.ts @@ -0,0 +1,45 @@ +/** + * A user's connected social account, as read from Gatekeeper. + */ +type SocialConnectionDTO = { + /** Platform slug, e.g. "threads" or "bluesky" */ + platform: string; + /** Public handle on the platform, null until resolved */ + handle: string | null; + /** Canonical profile URL, null until resolved */ + profileUrl: string | null; + /** Avatar image URL, null until resolved */ + avatarUrl: string | null; + /** Connection status, e.g. "connected" or "pending" */ + status: string; + /** Whether the platform verified account ownership */ + verified: boolean; + /** ISO timestamp of when the connection was established */ + connectedAt: string; +}; + +/** + * Social connections provider interface. + * Reads a user's connected social handles from Gatekeeper. + */ +interface SocialProvider { + /** + * Read all social connections for a user. + * @param userId - The Gatekeeper user id + * @returns The user's connections, or [] on any error (fail-open) + */ + getConnections(userId: string): Promise; + + /** + * Read a single social connection for a user by platform. + * @param userId - The Gatekeeper user id + * @param platform - The platform slug to filter by + * @returns The matching connection, or null if absent or on error (fail-open) + */ + getConnection( + userId: string, + platform: string, + ): Promise; +} + +export type { SocialConnectionDTO, SocialProvider }; diff --git a/src/social/social.test.ts b/src/social/social.test.ts new file mode 100644 index 0000000..633de26 --- /dev/null +++ b/src/social/social.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; + +import { GatekeeperSocialProvider } from "./gatekeeper"; +import { createSocialProvider } from "./index"; + +const BASE_URL = "https://gatekeeper.example.com"; +const SERVICE_KEY = "svc-test-key"; +const USER_ID = "user_123"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** Stub global fetch with a JSON response and capture the requests made. */ +const stubFetch = ( + status: number, + json: unknown, +): { calls: Array<{ url: string; init?: RequestInit }> } => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + + globalThis.fetch = mock( + async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }); + + return new Response(JSON.stringify(json), { + status, + headers: { "Content-Type": "application/json" }, + }); + }, + ) as unknown as typeof fetch; + + return { calls }; +}; + +/** Stub global fetch so it throws, simulating a connection failure. */ +const stubFetchNetworkError = (): { calls: () => number } => { + let i = 0; + globalThis.fetch = mock(async () => { + i += 1; + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + + return { calls: () => i }; +}; + +const sampleConnections = [ + { + platform: "threads", + handle: "coop_bri", + profileUrl: "https://threads.net/@coop_bri", + avatarUrl: "https://cdn.example.com/a.png", + status: "connected", + verified: true, + connectedAt: "2026-07-01T00:00:00.000Z", + }, + { + platform: "bluesky", + handle: null, + profileUrl: null, + avatarUrl: null, + status: "pending", + verified: false, + connectedAt: "2026-07-02T00:00:00.000Z", + }, +]; + +const makeProvider = () => + new GatekeeperSocialProvider({ + baseUrl: BASE_URL, + serviceKey: SERVICE_KEY, + }); + +describe("GatekeeperSocialProvider.getConnections", () => { + it("maps the bare-array payload in full and hits the read endpoint", async () => { + // Gatekeeper returns a bare JSON array, so this is the real contract + const { calls } = stubFetch(200, sampleConnections); + const provider = makeProvider(); + + const result = await provider.getConnections(USER_ID); + + expect(result).toEqual(sampleConnections); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe( + `${BASE_URL}/social/connections/read?userId=${USER_ID}`, + ); + }); + + it("tolerates a { connections } wrapper payload", async () => { + stubFetch(200, { connections: sampleConnections }); + const provider = makeProvider(); + + const result = await provider.getConnections(USER_ID); + + expect(result).toHaveLength(2); + expect(result[0]?.platform).toBe("threads"); + }); + + it("sends the bearer service key and requests JSON", async () => { + const { calls } = stubFetch(200, { connections: sampleConnections }); + const provider = makeProvider(); + + await provider.getConnections(USER_ID); + + const headers = calls[0]?.init?.headers as Record; + expect(headers.Authorization).toBe(`Bearer ${SERVICE_KEY}`); + expect(headers.Accept).toBe("application/json"); + }); + + it("URL-encodes the user id", async () => { + const { calls } = stubFetch(200, { connections: [] }); + const provider = makeProvider(); + + await provider.getConnections("user/with space"); + + expect(calls[0]?.url).toBe( + `${BASE_URL}/social/connections/read?userId=user%2Fwith%20space`, + ); + }); + + it("fails open with [] on a non-2xx response", async () => { + stubFetch(503, { error: "gatekeeper down" }); + const provider = makeProvider(); + + const result = await provider.getConnections(USER_ID); + + expect(result).toEqual([]); + }); + + it("fails open with [] on a network error", async () => { + stubFetchNetworkError(); + const provider = makeProvider(); + + const result = await provider.getConnections(USER_ID); + + expect(result).toEqual([]); + }); +}); + +describe("GatekeeperSocialProvider.getConnection", () => { + it("returns the matching platform connection", async () => { + stubFetch(200, { connections: sampleConnections }); + const provider = makeProvider(); + + const result = await provider.getConnection(USER_ID, "bluesky"); + + expect(result?.platform).toBe("bluesky"); + expect(result?.handle).toBeNull(); + }); + + it("returns null when no connection matches the platform", async () => { + stubFetch(200, { connections: sampleConnections }); + const provider = makeProvider(); + + const result = await provider.getConnection(USER_ID, "youtube"); + + expect(result).toBeNull(); + }); + + it("fails open with null on a network error", async () => { + stubFetchNetworkError(); + const provider = makeProvider(); + + const result = await provider.getConnection(USER_ID, "threads"); + + expect(result).toBeNull(); + }); +}); + +describe("createSocialProvider", () => { + it("returns a GatekeeperSocialProvider instance", () => { + const provider = createSocialProvider({ + baseUrl: BASE_URL, + serviceKey: SERVICE_KEY, + }); + + expect(provider).toBeInstanceOf(GatekeeperSocialProvider); + }); +});