X/Twitter structural support + per-package tsconfig scoping
X/Twitter (live posting gated on an X developer app, registered separately): - schema: add 'twitter' to OutletSchema (sanitize 280-char limit already present). - packages/twitter-client: X API v2 client (POST /2/tweets, OAuth2 user Bearer), injectable fetch, 280-char guard; 4 contract tests. - apps/scheduler/src/dispatch.ts: makeOutletDispatcher routes by outlet (linkedin.* -> linkedin, twitter -> twitter), fail-closed for unconfigured outlets; 4 tests. main.ts wires both outlets fail-closed until creds exist. Monorepo fix: add a local tsconfig.json (extends root, include src) to each package + non-admin app. Without it, every package's `tsc --noEmit` compiled the WHOLE repo via the root config (no JSX/DOM) and choked on the admin .tsx — masked until now by turbo build caching. Tests: 135 passing + 13 gated across 12 tasks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { makeOutletDispatcher } from "./dispatch";
|
||||
|
||||
const target = (outlet: string) => ({ id: 1, outlet, content_id: 1 });
|
||||
const ok = (id: string) => async () => ({ externalId: id, externalUrl: `https://x/${id}` });
|
||||
|
||||
describe("makeOutletDispatcher", () => {
|
||||
it("routes linkedin.* to the linkedin dispatcher", async () => {
|
||||
const linkedin = vi.fn(ok("li-1"));
|
||||
const d = makeOutletDispatcher({ linkedin });
|
||||
expect(await d(target("linkedin.org"))).toEqual({
|
||||
externalId: "li-1",
|
||||
externalUrl: "https://x/li-1",
|
||||
});
|
||||
await d(target("linkedin.member"));
|
||||
expect(linkedin).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("routes twitter to the twitter dispatcher", async () => {
|
||||
const twitter = vi.fn(ok("tw-1"));
|
||||
const d = makeOutletDispatcher({ twitter });
|
||||
expect(await d(target("twitter"))).toEqual({
|
||||
externalId: "tw-1",
|
||||
externalUrl: "https://x/tw-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when the target outlet dispatcher is not configured", async () => {
|
||||
const d = makeOutletDispatcher({});
|
||||
await expect(d(target("linkedin.org"))).rejects.toThrow(/not configured/);
|
||||
await expect(d(target("twitter"))).rejects.toThrow(/not configured/);
|
||||
});
|
||||
|
||||
it("throws for unknown outlets", async () => {
|
||||
const d = makeOutletDispatcher({ linkedin: ok("x") });
|
||||
await expect(d(target("mastodon"))).rejects.toThrow(/no dispatcher/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Dispatcher, DispatchTarget, DispatchResult } from "./worker";
|
||||
|
||||
export interface OutletDispatchers {
|
||||
linkedin?: Dispatcher;
|
||||
twitter?: Dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a publish target to the right outlet dispatcher by its `outlet`
|
||||
* string (linkedin.member / linkedin.org → linkedin; twitter → twitter).
|
||||
* Unconfigured outlets throw (fail-closed) so a missing integration is recorded
|
||||
* as an error rather than silently dropped.
|
||||
*/
|
||||
export function makeOutletDispatcher(map: OutletDispatchers): Dispatcher {
|
||||
return async (target: DispatchTarget): Promise<DispatchResult> => {
|
||||
if (target.outlet.startsWith("linkedin")) {
|
||||
if (!map.linkedin) {
|
||||
throw new Error(`linkedin dispatch not configured (outlet=${target.outlet})`);
|
||||
}
|
||||
return map.linkedin(target);
|
||||
}
|
||||
if (target.outlet === "twitter") {
|
||||
if (!map.twitter) {
|
||||
throw new Error(`twitter dispatch not configured (outlet=${target.outlet})`);
|
||||
}
|
||||
return map.twitter(target);
|
||||
}
|
||||
throw new Error(`no dispatcher configured for outlet ${target.outlet}`);
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
import { createDb } from "@stargue/schema/client";
|
||||
import { makeRedisConnection } from "./queue";
|
||||
import { makePublishWorker, type Dispatcher } from "./worker";
|
||||
import { makePublishWorker } from "./worker";
|
||||
import { makeOutletDispatcher } from "./dispatch";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
const REDIS_URL = process.env.REDIS_URL;
|
||||
@@ -17,16 +18,24 @@ const { db, sql } = createDb(DATABASE_URL);
|
||||
const connection = makeRedisConnection(REDIS_URL);
|
||||
|
||||
/**
|
||||
* Gate 0.9: the real LinkedIn dispatcher is wired once Community Management API
|
||||
* access is granted. Until then the worker boots and consumes jobs but refuses
|
||||
* to dispatch — surfacing a clear, audited failure rather than silently
|
||||
* dropping or fake-publishing.
|
||||
* Outlet dispatchers are wired once their credentials exist:
|
||||
* - linkedin: Community Management API access (Gate 0.9 / CMA in review)
|
||||
* - twitter: an X developer app with write-tier access (not yet registered)
|
||||
* Until then each fails closed — the worker boots and consumes jobs but records
|
||||
* a clear, audited failure rather than silently dropping or fake-publishing.
|
||||
*/
|
||||
const dispatch: Dispatcher = async (target) => {
|
||||
const dispatch = makeOutletDispatcher({
|
||||
linkedin: async (target) => {
|
||||
throw new Error(
|
||||
`LinkedIn dispatch not yet enabled (Gate 0.9 / CMA pending) — outlet=${target.outlet} publication=${target.id}`,
|
||||
`LinkedIn dispatch not yet enabled (Gate 0.9 / CMA pending) — publication=${target.id}`,
|
||||
);
|
||||
};
|
||||
},
|
||||
twitter: async (target) => {
|
||||
throw new Error(
|
||||
`Twitter/X dispatch not yet enabled (X app not registered) — publication=${target.id}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const worker = makePublishWorker({ db, connection, dispatch });
|
||||
worker.on("ready", () => console.log("[scheduler] publish worker ready"));
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -119,6 +119,14 @@
|
||||
"vitest": "^2.1.0",
|
||||
},
|
||||
},
|
||||
"packages/twitter-client": {
|
||||
"name": "@stargue/twitter-client",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
@@ -357,6 +365,8 @@
|
||||
|
||||
"@stargue/schema": ["@stargue/schema@workspace:packages/schema"],
|
||||
|
||||
"@stargue/twitter-client": ["@stargue/twitter-client@workspace:packages/twitter-client"],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export const OutletSchema = z.enum([
|
||||
"stargue.net",
|
||||
"linkedin.member",
|
||||
"linkedin.org",
|
||||
"twitter",
|
||||
]);
|
||||
export type Outlet = z.infer<typeof OutletSchema>;
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@stargue/twitter-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "echo 'lint pending'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^2.1.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { TwitterClient, TwitterApiError } from "./client";
|
||||
import { TWEET_MAX_LENGTH } from "./types";
|
||||
|
||||
const fakeFetch = (status: number, body: unknown): typeof fetch =>
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
describe("TwitterClient.createTweet", () => {
|
||||
it("posts text and returns the tweet id + url", async () => {
|
||||
const fetchImpl = fakeFetch(201, { data: { id: "1790000000000000000", text: "hi" } });
|
||||
const client = new TwitterClient({
|
||||
getAccessToken: async () => "token-abc",
|
||||
fetchImpl,
|
||||
baseUrl: "https://api.test",
|
||||
});
|
||||
const res = await client.createTweet("hi");
|
||||
expect(res.id).toBe("1790000000000000000");
|
||||
expect(res.url).toContain("1790000000000000000");
|
||||
|
||||
const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
expect(call[0]).toBe("https://api.test/2/tweets");
|
||||
expect(call[1].method).toBe("POST");
|
||||
expect(call[1].headers.Authorization).toBe("Bearer token-abc");
|
||||
expect(JSON.parse(call[1].body)).toEqual({ text: "hi" });
|
||||
});
|
||||
|
||||
it("rejects empty text", async () => {
|
||||
const client = new TwitterClient({ getAccessToken: async () => "t", fetchImpl: fakeFetch(201, {}) });
|
||||
await expect(client.createTweet("")).rejects.toThrow(/empty/);
|
||||
});
|
||||
|
||||
it("rejects text over the 280-char limit", async () => {
|
||||
const client = new TwitterClient({ getAccessToken: async () => "t", fetchImpl: fakeFetch(201, {}) });
|
||||
await expect(client.createTweet("x".repeat(TWEET_MAX_LENGTH + 1))).rejects.toThrow(/exceeds/);
|
||||
});
|
||||
|
||||
it("throws TwitterApiError on non-2xx", async () => {
|
||||
const client = new TwitterClient({
|
||||
getAccessToken: async () => "t",
|
||||
fetchImpl: fakeFetch(403, { title: "Forbidden" }),
|
||||
});
|
||||
await expect(client.createTweet("hi")).rejects.toBeInstanceOf(TwitterApiError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
type CreateTweetRequest,
|
||||
type CreateTweetResponse,
|
||||
TWEET_MAX_LENGTH,
|
||||
} from "./types";
|
||||
|
||||
export class TwitterApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly body: string,
|
||||
) {
|
||||
super(`Twitter API error ${status}: ${body}`);
|
||||
this.name = "TwitterApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TwitterClientOptions {
|
||||
/** Returns a valid OAuth 2.0 user-context access token (user.write/tweet.write). */
|
||||
getAccessToken: () => Promise<string>;
|
||||
/** Injectable for tests; defaults to global fetch. */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Override for tests / API mocks; defaults to the live X API. */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal X (Twitter) API v2 client for posting our own content. Mirrors the
|
||||
* shape of @stargue/linkedin-client: a thin, injectable HTTP wrapper. Live use
|
||||
* requires an X developer app + write-tier access (registered separately).
|
||||
*/
|
||||
export class TwitterClient {
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly opts: TwitterClientOptions) {
|
||||
this.fetchImpl = opts.fetchImpl ?? fetch;
|
||||
this.baseUrl = opts.baseUrl ?? "https://api.twitter.com";
|
||||
}
|
||||
|
||||
async createTweet(text: string): Promise<{ id: string; url: string }> {
|
||||
if (text.length === 0) throw new Error("tweet text is empty");
|
||||
if (text.length > TWEET_MAX_LENGTH) {
|
||||
throw new Error(`tweet exceeds ${TWEET_MAX_LENGTH} characters (${text.length})`);
|
||||
}
|
||||
const token = await this.opts.getAccessToken();
|
||||
const body: CreateTweetRequest = { text };
|
||||
const res = await this.fetchImpl(`${this.baseUrl}/2/tweets`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new TwitterApiError(res.status, await res.text().catch(() => ""));
|
||||
}
|
||||
const json = (await res.json()) as CreateTweetResponse;
|
||||
return {
|
||||
id: json.data.id,
|
||||
url: `https://twitter.com/i/web/status/${json.data.id}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./client";
|
||||
@@ -0,0 +1,14 @@
|
||||
/** X API v2 — Manage Tweets (POST /2/tweets). */
|
||||
|
||||
export interface CreateTweetRequest {
|
||||
text: string;
|
||||
/** Optional: reply settings, media ids, etc. — added as the integration grows. */
|
||||
reply?: { in_reply_to_tweet_id: string };
|
||||
}
|
||||
|
||||
export interface CreateTweetResponse {
|
||||
data: { id: string; text: string };
|
||||
}
|
||||
|
||||
/** X enforces a 280-character limit on standard tweets. */
|
||||
export const TWEET_MAX_LENGTH = 280;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user