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}`);
|
||||
};
|
||||
}
|
||||
+19
-10
@@ -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"));
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user