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"]
}
+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"]
}