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 { createDb } from "@stargue/schema/client";
|
||||||
import { makeRedisConnection } from "./queue";
|
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 DATABASE_URL = process.env.DATABASE_URL;
|
||||||
const REDIS_URL = process.env.REDIS_URL;
|
const REDIS_URL = process.env.REDIS_URL;
|
||||||
@@ -17,16 +18,24 @@ const { db, sql } = createDb(DATABASE_URL);
|
|||||||
const connection = makeRedisConnection(REDIS_URL);
|
const connection = makeRedisConnection(REDIS_URL);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate 0.9: the real LinkedIn dispatcher is wired once Community Management API
|
* Outlet dispatchers are wired once their credentials exist:
|
||||||
* access is granted. Until then the worker boots and consumes jobs but refuses
|
* - linkedin: Community Management API access (Gate 0.9 / CMA in review)
|
||||||
* to dispatch — surfacing a clear, audited failure rather than silently
|
* - twitter: an X developer app with write-tier access (not yet registered)
|
||||||
* dropping or fake-publishing.
|
* 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(
|
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 });
|
const worker = makePublishWorker({ db, connection, dispatch });
|
||||||
worker.on("ready", () => console.log("[scheduler] publish worker ready"));
|
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",
|
"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": {
|
"packages": {
|
||||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
"@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/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=="],
|
"@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=="],
|
"@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",
|
"stargue.net",
|
||||||
"linkedin.member",
|
"linkedin.member",
|
||||||
"linkedin.org",
|
"linkedin.org",
|
||||||
|
"twitter",
|
||||||
]);
|
]);
|
||||||
export type Outlet = z.infer<typeof OutletSchema>;
|
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