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}`);
};
}
+19 -10
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) => {
throw new Error(
`LinkedIn dispatch not yet enabled (Gate 0.9 / CMA pending) — outlet=${target.outlet} publication=${target.id}`,
);
};
const dispatch = makeOutletDispatcher({
linkedin: async (target) => {
throw new Error(
`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"]
}