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:
Angelo B. J. Luidens
2026-06-03 16:14:53 -04:00
parent c24ea815f3
commit b9be5578f9
17 changed files with 276 additions and 10 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}
+38
View File
@@ -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/);
});
});
+30
View File
@@ -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}`);
};
}
+17 -8
View File
@@ -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"));
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}
+10
View File
@@ -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=="],
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}
+1
View File
@@ -5,6 +5,7 @@ export const OutletSchema = z.enum([
"stargue.net",
"linkedin.member",
"linkedin.org",
"twitter",
]);
export type Outlet = z.infer<typeof OutletSchema>;
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}
+21
View File
@@ -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);
});
});
+64
View File
@@ -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}`,
};
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./types";
export * from "./client";
+14
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}