Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
30
packages/connector-runtime/package.json
Normal file
30
packages/connector-runtime/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@tactical-media-broadcast-hub/connector-runtime",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tactical-media-broadcast-hub/connector-sdk": "workspace:*",
|
||||
"@tactical-media-broadcast-hub/core": "workspace:*",
|
||||
"pg": "^8.23.1",
|
||||
"@tactical-media-broadcast-hub/connector-mastodon": "workspace:*",
|
||||
"@tactical-media-broadcast-hub/connector-telegram": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^2.0.5",
|
||||
"@types/pg": "^8.23.1"
|
||||
}
|
||||
}
|
||||
34
packages/connector-runtime/src/credentials.ts
Normal file
34
packages/connector-runtime/src/credentials.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { Pool } from "pg";
|
||||
import { AuthError, type CredentialAccessor } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
||||
import { publishingContext } from "./execution-context.js";
|
||||
import { Vault } from "./vault.js";
|
||||
/** Caller owns pool lifecycle. Publishing never rotates or mutates credentials. */
|
||||
export function createPublishingCredentialAccessor(pool: Pool, vault = new Vault()): CredentialAccessor {
|
||||
async function getSecret(connection: ConnectorConnection, key: string): Promise<string> {
|
||||
const context = publishingContext(connection);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query("SELECT set_config('app.workspace_id', $1, true)", [context.workspaceId]);
|
||||
const result = await client.query<{ ciphertext: Buffer; key_version: number }>(`SELECT cr.ciphertext, cr.key_version FROM credentials cr
|
||||
JOIN connections c ON c.id = cr.connection_id AND c.workspace_id = cr.workspace_id
|
||||
WHERE cr.id = $1 AND cr.generation = $2 AND cr.connection_id = $3
|
||||
AND cr.kind = 'connector-oauth' AND cr.revoked_at IS NULL AND c.disconnected_at IS NULL`,
|
||||
[context.credentialId, context.credentialGeneration, context.connectionId]);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new AuthError("CREDENTIAL_CHANGED", connection.provider, false);
|
||||
const plaintext = await vault.open(context.workspaceId, context.connectionId, { ciphertext: row.ciphertext, keyVersion: row.key_version });
|
||||
const secrets: unknown = JSON.parse(plaintext.toString());
|
||||
if (!secrets || typeof secrets !== "object" || Array.isArray(secrets)) throw new AuthError("CREDENTIAL_SHAPE", connection.provider, false);
|
||||
const value = (secrets as Record<string, unknown>)[key];
|
||||
if (typeof value !== "string" || !value) throw new AuthError("CREDENTIAL_SECRET_MISSING", connection.provider, false);
|
||||
await client.query("COMMIT");
|
||||
return value;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw error;
|
||||
} finally { client.release(); }
|
||||
}
|
||||
return { getSecret, getAccessToken: (connection) => getSecret(connection, "access_token"), put: async () => { throw new Error("PUBLISHING_CREDENTIAL_MUTATION_REFUSED"); } };
|
||||
}
|
||||
41
packages/connector-runtime/src/execution-context.ts
Normal file
41
packages/connector-runtime/src/execution-context.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { setHttpRequestHookProvider, type HttpRequestHook } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
|
||||
/**
|
||||
* Per-execution HTTP hook carried through AsyncLocalStorage, so concurrent
|
||||
* publish activities in one process never share a "current job".
|
||||
*/
|
||||
const storage = new AsyncLocalStorage<HttpRequestHook>();
|
||||
let installed = false;
|
||||
|
||||
function install(): void {
|
||||
if (installed) return;
|
||||
setHttpRequestHookProvider(() => storage.getStore());
|
||||
installed = true;
|
||||
}
|
||||
|
||||
/** Run `fn` with `hook` observing every SDK HTTP request it makes. */
|
||||
export function runWithHttpHook<T>(hook: HttpRequestHook, fn: () => Promise<T>): Promise<T> {
|
||||
install();
|
||||
return storage.run(hook, fn);
|
||||
}
|
||||
|
||||
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
||||
export interface PublishingExecutionContext {
|
||||
workspaceId: string;
|
||||
connectionId: string;
|
||||
credentialId: string;
|
||||
credentialGeneration: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
const executionStorage = new AsyncLocalStorage<PublishingExecutionContext>();
|
||||
export function runWithPublishingContext<T>(context: PublishingExecutionContext, fn: () => Promise<T>): Promise<T> {
|
||||
return executionStorage.run(context, fn);
|
||||
}
|
||||
export function publishingContext(connection: ConnectorConnection): PublishingExecutionContext {
|
||||
const context = executionStorage.getStore();
|
||||
if (!context || connection.workspaceId !== context.workspaceId || connection.id !== context.connectionId || connection.credentialId.id !== context.connectionId) throw new Error("EXECUTION_CREDENTIAL_FENCE");
|
||||
context.signal?.throwIfAborted();
|
||||
return context;
|
||||
}
|
||||
export function executionSignal(): AbortSignal | undefined { return executionStorage.getStore()?.signal; }
|
||||
52
packages/connector-runtime/src/idempotency.ts
Normal file
52
packages/connector-runtime/src/idempotency.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { NormalizedPost } from "@tactical-media-broadcast-hub/core";
|
||||
|
||||
/** providerMetadata keys owned by the publishing runtime, never by the author. */
|
||||
export const EXECUTION_METADATA_KEYS = ["idempotencyKey", "draftId"] as const;
|
||||
|
||||
/** Author-supplied keys refused outright: remote media ids bypass asset ownership. */
|
||||
export const FORBIDDEN_POST_METADATA_KEYS = ["mediaIds"] as const;
|
||||
|
||||
export class ExecutionMetadataError extends Error {
|
||||
constructor(readonly key: string) {
|
||||
super(`providerMetadata.${key} may not be supplied by the post author`);
|
||||
this.name = "ExecutionMetadataError";
|
||||
}
|
||||
}
|
||||
|
||||
export { publishingIdempotencyKey } from "@tactical-media-broadcast-hub/core";
|
||||
export interface IdempotencyKeyParts { workspaceId: string; draftId: string; attemptNo: string | bigint | number }
|
||||
|
||||
/** The post without runtime-owned metadata — what approval hashes cover. */
|
||||
export function stripExecutionMetadata(post: NormalizedPost): NormalizedPost {
|
||||
const providerMetadata = { ...post.providerMetadata };
|
||||
for (const key of EXECUTION_METADATA_KEYS) delete providerMetadata[key];
|
||||
return { ...post, providerMetadata };
|
||||
}
|
||||
|
||||
export interface DispatchPreparation {
|
||||
idempotencyKey: string;
|
||||
/**
|
||||
* Keep post.scheduledAt so the provider schedules natively. Default false:
|
||||
* the pipeline owns timing and the provider must publish immediately.
|
||||
*/
|
||||
allowProviderScheduling?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the post handed to connector.publish: refuse forbidden author keys,
|
||||
* drop author-supplied execution keys, inject the attempt's idempotency key
|
||||
* and strip scheduledAt unless provider scheduling is explicitly allowed.
|
||||
*/
|
||||
export function preparePostForDispatch(post: NormalizedPost, prep: DispatchPreparation): NormalizedPost {
|
||||
for (const key of FORBIDDEN_POST_METADATA_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(post.providerMetadata, key)) throw new ExecutionMetadataError(key);
|
||||
}
|
||||
const stripped = stripExecutionMetadata(post);
|
||||
const prepared: NormalizedPost = {
|
||||
...stripped,
|
||||
mediaAssetIds: [...post.mediaAssetIds],
|
||||
providerMetadata: { ...stripped.providerMetadata, idempotencyKey: prep.idempotencyKey },
|
||||
};
|
||||
if (prep.allowProviderScheduling !== true) delete prepared.scheduledAt;
|
||||
return prepared;
|
||||
}
|
||||
9
packages/connector-runtime/src/index.ts
Normal file
9
packages/connector-runtime/src/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export * from "./execution-context.js";
|
||||
export * from "./idempotency.js";
|
||||
export * from "./outcome.js";
|
||||
export * from "./policy.js";
|
||||
export * from "./publish.js";
|
||||
export * from "./vault.js";
|
||||
export * from "./provider-fetch.js";
|
||||
export * from "./credentials.js";
|
||||
export * from "./registry.js";
|
||||
67
packages/connector-runtime/src/outcome.ts
Normal file
67
packages/connector-runtime/src/outcome.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Typed result of one publish dispatch. Only `retryable` permits calling
|
||||
* connector.publish again with the same idempotency key; `unknown` means the
|
||||
* send may have happened and must never be resent automatically.
|
||||
*/
|
||||
export type PublishFailureCode =
|
||||
| "RATE_LIMITED"
|
||||
| "PROVIDER_UNAVAILABLE"
|
||||
| "TRANSPORT_FAILURE"
|
||||
| "PUBLISH_AMBIGUOUS_DEDUPED"
|
||||
| "CREDENTIAL_REJECTED"
|
||||
| "PROVIDER_REJECTED"
|
||||
| "INVALID_POST"
|
||||
| "UNSUPPORTED"
|
||||
| "PUBLISH_OUTCOME_UNKNOWN";
|
||||
|
||||
/**
|
||||
* Sanitized provider evidence. Deliberately omits the connector's local
|
||||
* Publication.id and draftId: those are placeholders, never DB identities.
|
||||
*/
|
||||
export interface PublishReceipt {
|
||||
provider: string;
|
||||
connectionId: string;
|
||||
idempotencyKey: string;
|
||||
remoteId: string;
|
||||
remoteUrl?: string;
|
||||
publishedAt: Date;
|
||||
providerMetadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface FailureBase {
|
||||
code: PublishFailureCode;
|
||||
/** Whether any mutating provider request left the process in this call. */
|
||||
dispatched: boolean;
|
||||
/** SDK error class name — a fixed identifier, never the raw message. */
|
||||
errorName?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export type PublishOutcome =
|
||||
| { kind: "published"; receipt: PublishReceipt }
|
||||
| {
|
||||
kind: "provider_scheduled";
|
||||
receipt: PublishReceipt;
|
||||
scheduledRemoteId: string;
|
||||
scheduledAt: Date;
|
||||
}
|
||||
| (FailureBase & {
|
||||
kind: "rejected_permanent";
|
||||
code: "CREDENTIAL_REJECTED" | "PROVIDER_REJECTED" | "INVALID_POST" | "UNSUPPORTED";
|
||||
/** Credential refused: pause for reauthorization, do not retry. */
|
||||
needsReauthorization: boolean;
|
||||
})
|
||||
| (FailureBase & {
|
||||
kind: "retryable";
|
||||
code: "RATE_LIMITED" | "PROVIDER_UNAVAILABLE" | "TRANSPORT_FAILURE" | "PUBLISH_AMBIGUOUS_DEDUPED";
|
||||
retryAfterMs?: number;
|
||||
/**
|
||||
* "not_delivered": the provider definitively did not apply the send.
|
||||
* "deduplicated": delivery is ambiguous but a same-key resend is
|
||||
* deduped by the provider inside its verified window.
|
||||
*/
|
||||
resend: "not_delivered" | "deduplicated";
|
||||
})
|
||||
| (FailureBase & { kind: "unknown"; code: "PUBLISH_OUTCOME_UNKNOWN"; dispatched: true });
|
||||
|
||||
export type PublishOutcomeKind = PublishOutcome["kind"];
|
||||
48
packages/connector-runtime/src/policy.ts
Normal file
48
packages/connector-runtime/src/policy.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
||||
/**
|
||||
* Provider publishing policies, declared at the composition boundary so the
|
||||
* SDK publish/deletePublication signatures stay unchanged.
|
||||
*/
|
||||
export interface PublishingPolicy {
|
||||
/**
|
||||
* "token": the provider dedupes repeated sends carrying the same
|
||||
* idempotency key, so an ambiguous send may be replayed with that key.
|
||||
* "none": no key and no lookup — an ambiguous send is never replayed.
|
||||
*/
|
||||
idempotency: "token" | "none";
|
||||
/**
|
||||
* How long after the attempt's first dispatch a same-key replay is trusted.
|
||||
* Applies only to "token" policies and only on deployment-verified origins.
|
||||
*/
|
||||
dedupeWindowMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mastodon documents Idempotency-Key retention of up to one hour; 30 minutes
|
||||
* from first dispatch is the deployment cutoff (forks may keep less).
|
||||
*/
|
||||
export const MASTODON_DEDUPE_WINDOW_MS = 30 * 60 * 1000;
|
||||
|
||||
export const PUBLISHING_POLICIES: Readonly<Record<string, PublishingPolicy>> = {
|
||||
mastodon: { idempotency: "token", dedupeWindowMs: MASTODON_DEDUPE_WINDOW_MS },
|
||||
telegram: { idempotency: "none" },
|
||||
};
|
||||
|
||||
/** Unknown providers get the safe default: never replay an ambiguous send. */
|
||||
export function publishingPolicyFor(provider: string): PublishingPolicy {
|
||||
return PUBLISHING_POLICIES[provider] ?? { idempotency: "none" };
|
||||
}
|
||||
|
||||
export function verifiedDedupeOrigin(origin: string, configured = process.env.MASTODON_PUBLISH_DEDUPE_ORIGINS ?? ""): boolean {
|
||||
return configured.split(",").map((value) => value.trim()).filter(Boolean).some((value) => {
|
||||
try { const url = new URL(value); return url.protocol === "https:" && url.origin === value && url.origin === origin; } catch { return false; }
|
||||
});
|
||||
}
|
||||
|
||||
export function isPublishingProvider(provider: string): boolean { return Object.hasOwn(PUBLISHING_POLICIES, provider); }
|
||||
export function publishingDispatchPolicy(connection: ConnectorConnection): { policy: PublishingPolicy; origin: string; dedupeVerified: boolean } {
|
||||
const policy = publishingPolicyFor(connection.provider);
|
||||
let origin = "";
|
||||
try { origin = new URL(connection.fields.instanceUrl ?? "").origin; } catch { /* invalid origins never authorize dedupe */ }
|
||||
return { policy, origin, dedupeVerified: policy.idempotency === "token" && verifiedDedupeOrigin(origin) };
|
||||
}
|
||||
35
packages/connector-runtime/src/provider-fetch.test.ts
Normal file
35
packages/connector-runtime/src/provider-fetch.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { providerFetch, publicAddress } from "./provider-fetch.js";
|
||||
describe("bounded pinned provider transport (local fixture)", () => {
|
||||
let server: Server; let origin: string; let requests = 0;
|
||||
beforeAll(async () => {
|
||||
server = createServer((request, response) => {
|
||||
requests += 1;
|
||||
if (request.url === "/redirect") { response.writeHead(302, { location: "/ok" }); response.end(); }
|
||||
else if (request.url === "/large") response.end(Buffer.alloc(1_048_577, "x"));
|
||||
else if (request.url === "/stall") response.writeHead(200);
|
||||
else response.end('{"ok":true}');
|
||||
});
|
||||
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
|
||||
const address = server.address(); if (!address || typeof address === "string") throw new Error("fixture address missing");
|
||||
origin = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
afterAll(async () => { server.closeAllConnections(); await new Promise<void>((done) => server.close(() => done())); });
|
||||
it("blocks private/documentation addresses and allows public destinations", () => {
|
||||
for (const address of ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "::ffff:127.0.0.1", "2001:db8::1"]) expect(publicAddress(address)).toBe(false);
|
||||
expect(publicAddress("8.8.8.8")).toBe(true);
|
||||
});
|
||||
it("honors cancellation before DNS/send and refuses redirects", async () => {
|
||||
const before = requests; const controller = new AbortController(); controller.abort();
|
||||
await expect(providerFetch(`${origin}/ok`, { signal: controller.signal })).rejects.toThrow();
|
||||
expect(requests).toBe(before);
|
||||
await expect(providerFetch(`${origin}/redirect`)).rejects.toThrow("redirect refused");
|
||||
expect(requests).toBe(before+1);
|
||||
});
|
||||
it("bounds responses and cancels a stalled request", async () => {
|
||||
await expect(providerFetch(`${origin}/large`)).rejects.toThrow();
|
||||
await expect(providerFetch(`${origin}/stall`, { signal: AbortSignal.timeout(30) })).rejects.toThrow();
|
||||
expect((await (await providerFetch(`${origin}/ok`)).json()) as unknown).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
66
packages/connector-runtime/src/provider-fetch.ts
Normal file
66
packages/connector-runtime/src/provider-fetch.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { lookup } from "node:dns/promises";
|
||||
import { isIP, BlockList } from "node:net";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { request as httpRequest } from "node:http";
|
||||
|
||||
const blocked = new BlockList();
|
||||
for (const [network, prefix] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.168.0.0", 16], ["192.0.0.0", 24], ["192.0.2.0", 24], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 3]] as const) blocked.addSubnet(network, prefix);
|
||||
blocked.addSubnet("2001:db8::", 32, "ipv6");
|
||||
blocked.addSubnet("2001::", 32, "ipv6");
|
||||
blocked.addSubnet("2001:10::", 28, "ipv6");
|
||||
blocked.addSubnet("2001:20::", 28, "ipv6");
|
||||
blocked.addSubnet("2002::", 16, "ipv6");
|
||||
export function publicAddress(address: string): boolean {
|
||||
const family = isIP(address);
|
||||
if (family === 4) return !blocked.check(address);
|
||||
// Reject mapped IPv4, local, multicast, transition and documentation ranges.
|
||||
return family === 6 && /^[23][0-9a-f]{3}:/i.test(address) && !blocked.check(address, "ipv6");
|
||||
}
|
||||
|
||||
/** Connect-only transport: DNS-pinned public destinations, no redirects, bounded JSON. */
|
||||
export const providerFetch: typeof fetch = async (input, init = {}) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||
const local = process.env.NODE_ENV !== "production" && (hostname === "localhost" || hostname === "127.0.0.1");
|
||||
if (url.username || url.password || (url.protocol !== "https:" && !(local && url.protocol === "http:"))) throw new Error("Invalid provider URL");
|
||||
const signal = AbortSignal.any([AbortSignal.timeout(15_000), ...(init.signal ? [init.signal] : [])]);
|
||||
signal.throwIfAborted();
|
||||
const addresses = await Promise.race([lookup(hostname, { all: true }), new Promise<never>((_resolve, reject) => { signal.addEventListener("abort", () => reject(new Error("PROVIDER_TIMEOUT")), { once: true }); })]);
|
||||
signal.throwIfAborted();
|
||||
if (!addresses.length || (!local && addresses.some(({ address }) => !publicAddress(address)))) throw new Error("Provider must resolve to public addresses");
|
||||
const pinned = addresses[0];
|
||||
if (!pinned) throw new Error("Provider DNS unavailable");
|
||||
if (init.body !== undefined && init.body !== null && typeof init.body !== "string") throw new Error("Unsupported authorization body");
|
||||
const headers = Object.fromEntries(new Headers(init.headers).entries());
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, {
|
||||
method: init.method ?? "GET", headers, family: pinned.family, signal,
|
||||
lookup: (_name, _options, callback) => callback(null, pinned.address, pinned.family),
|
||||
}, (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
response.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > 1_048_576) request.destroy(new Error("Provider response too large"));
|
||||
else chunks.push(chunk);
|
||||
});
|
||||
response.on("error", reject);
|
||||
response.on("aborted", () => reject(new Error("Provider response aborted")));
|
||||
response.on("end", () => {
|
||||
const status = response.statusCode ?? 502;
|
||||
if (status >= 300 && status < 400) { reject(new Error("Provider redirect refused")); return; }
|
||||
const responseHeaders = new Headers();
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
if (value !== undefined) responseHeaders.set(key, Array.isArray(value) ? value.join(", ") : value);
|
||||
}
|
||||
try {
|
||||
resolve(new Response(status === 204 || status === 205 ? null : Buffer.concat(chunks), { status, headers: responseHeaders }));
|
||||
} catch { reject(new Error("Invalid provider response")); }
|
||||
});
|
||||
});
|
||||
request.setTimeout(15_000, () => request.destroy(new Error("Provider timeout")));
|
||||
request.on("error", reject);
|
||||
if (init.body) request.write(init.body);
|
||||
request.end();
|
||||
});
|
||||
};
|
||||
355
packages/connector-runtime/src/publish.test.ts
Normal file
355
packages/connector-runtime/src/publish.test.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectorConnection, NormalizedPost, Publication } from "@tactical-media-broadcast-hub/core";
|
||||
import {
|
||||
AuthError,
|
||||
ConnectorError,
|
||||
ProviderRejectedError,
|
||||
RateLimitedHttpClient,
|
||||
type PlatformConnector,
|
||||
} from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import {
|
||||
DispatchFenceError,
|
||||
ExecutionMetadataError,
|
||||
MASTODON_DEDUPE_WINDOW_MS,
|
||||
preparePostForDispatch,
|
||||
publishingIdempotencyKey,
|
||||
publishingPolicyFor,
|
||||
publishWithPolicy,
|
||||
stripExecutionMetadata,
|
||||
} from "./index.js";
|
||||
|
||||
const KEY = "wp3:ws-1:draft-1:a1:publish";
|
||||
const ORIGIN = "https://provider.example";
|
||||
|
||||
/** Records sends independently of the responses the connector sees. */
|
||||
interface Wire {
|
||||
posts: { key: string | null; body: Record<string, unknown> }[];
|
||||
gets: number;
|
||||
}
|
||||
|
||||
type Reply = "ok" | "scheduled" | "500" | "429" | "401" | "422" | "drop";
|
||||
|
||||
/**
|
||||
* Minimal connector over the real SDK HTTP client: an identity GET, then the
|
||||
* publish POST. The scripted reply for each POST is consumed in order.
|
||||
*/
|
||||
function fakeConnector(replies: Reply[], wire: Wire): PlatformConnector {
|
||||
const client = new RateLimitedHttpClient({
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => Promise.resolve(),
|
||||
fetchImpl: (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
const method = init?.method ?? "GET";
|
||||
if (method === "GET") {
|
||||
wire.gets += 1;
|
||||
return new Response("{}");
|
||||
}
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
wire.posts.push({ key: new Headers(init?.headers).get("idempotency-key"), body });
|
||||
const reply = replies.shift() ?? "ok";
|
||||
switch (reply) {
|
||||
case "ok":
|
||||
return Response.json({ id: `r${wire.posts.length}`, created_at: "2026-10-09T12:00:00Z" });
|
||||
case "scheduled":
|
||||
return Response.json({ id: "s9", scheduled_at: "2026-10-09T15:00:00.000Z" });
|
||||
case "500":
|
||||
return new Response("", { status: 500 });
|
||||
case "429":
|
||||
return new Response("", { status: 429, headers: { "retry-after": "12" } });
|
||||
case "401":
|
||||
return new Response("", { status: 401 });
|
||||
case "422":
|
||||
return new Response("", { status: 422 });
|
||||
case "drop":
|
||||
throw new TypeError("socket hang up");
|
||||
}
|
||||
void input;
|
||||
throw new Error("unreachable");
|
||||
}) as typeof fetch,
|
||||
});
|
||||
|
||||
return {
|
||||
manifest: () => {
|
||||
throw new Error("not needed");
|
||||
},
|
||||
authorize: async () => {
|
||||
throw new Error("not needed");
|
||||
},
|
||||
getIdentity: async () => {
|
||||
throw new Error("not needed");
|
||||
},
|
||||
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
|
||||
if (post.text === "too-long") throw new ConnectorError("text over limit", connection.provider);
|
||||
await client.request(connection.provider, `${ORIGIN}/me`);
|
||||
const key = post.providerMetadata["idempotencyKey"];
|
||||
const response = await client.request(connection.provider, `${ORIGIN}/statuses`, {
|
||||
method: "POST",
|
||||
headers: typeof key === "string" ? { "idempotency-key": key } : {},
|
||||
body: JSON.stringify({ status: post.text, scheduled_at: post.scheduledAt?.toISOString() }),
|
||||
});
|
||||
if (response.status === 401) throw new AuthError("revoked", connection.provider, false);
|
||||
if (!response.ok) throw new ProviderRejectedError("refused", connection.provider, response.status);
|
||||
const data = (await response.json()) as { id: string; created_at?: string; scheduled_at?: string };
|
||||
const scheduled = data.scheduled_at !== undefined;
|
||||
return {
|
||||
id: "local-placeholder",
|
||||
workspaceId: connection.workspaceId,
|
||||
draftId: "direct",
|
||||
connectionId: connection.id,
|
||||
remoteId: scheduled ? `scheduled:${data.id}` : data.id,
|
||||
publishedAt: new Date(data.scheduled_at ?? data.created_at ?? 0),
|
||||
providerMetadata: scheduled
|
||||
? { outcome: "provider_scheduled", scheduledAt: data.scheduled_at }
|
||||
: { outcome: "published" },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function connectionFor(provider: string): ConnectorConnection {
|
||||
return {
|
||||
id: `conn-${provider}`,
|
||||
workspaceId: "ws-1",
|
||||
provider,
|
||||
channelId: "c",
|
||||
credentialId: { kind: "credential-ref", id: "cred" },
|
||||
fields: {},
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
const post = (text = "hello", extra: Partial<NormalizedPost> = {}): NormalizedPost => ({
|
||||
text,
|
||||
mediaAssetIds: [],
|
||||
visibility: "public",
|
||||
providerMetadata: { connectionId: "conn-x", draftId: "user-draft", idempotencyKey: "user-key" },
|
||||
...extra,
|
||||
});
|
||||
|
||||
function setup(provider: string, replies: Reply[]): { wire: Wire; connector: PlatformConnector; connection: ConnectorConnection } {
|
||||
const wire: Wire = { posts: [], gets: 0 };
|
||||
return { wire, connector: fakeConnector(replies, wire), connection: connectionFor(provider) };
|
||||
}
|
||||
|
||||
describe("idempotency key + dispatch preparation", () => {
|
||||
it("formats the per-attempt key and validates its parts", () => {
|
||||
expect(publishingIdempotencyKey({ workspaceId: "ws-1", draftId: "draft-1", attemptNo: 1 })).toBe(KEY);
|
||||
expect(() => publishingIdempotencyKey({ workspaceId: "ws:1", draftId: "d", attemptNo: 1 })).toThrow(RangeError);
|
||||
expect(() => publishingIdempotencyKey({ workspaceId: "ws", draftId: "d", attemptNo: 0 })).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("injects the key, drops author execution keys and strips scheduledAt", () => {
|
||||
const original = post("x", { scheduledAt: new Date("2026-12-01T00:00:00Z") });
|
||||
const prepared = preparePostForDispatch(original, { idempotencyKey: KEY });
|
||||
expect(prepared.providerMetadata).toEqual({ connectionId: "conn-x", idempotencyKey: KEY });
|
||||
expect(prepared.scheduledAt).toBeUndefined();
|
||||
// Input untouched.
|
||||
expect(original.providerMetadata["draftId"]).toBe("user-draft");
|
||||
expect(original.scheduledAt).toBeDefined();
|
||||
expect(preparePostForDispatch(original, { idempotencyKey: KEY, allowProviderScheduling: true }).scheduledAt).toEqual(
|
||||
original.scheduledAt,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses author-supplied remote media ids", () => {
|
||||
expect(() => preparePostForDispatch(post("x", { providerMetadata: { mediaIds: ["m1"] } }), { idempotencyKey: KEY })).toThrow(
|
||||
ExecutionMetadataError,
|
||||
);
|
||||
});
|
||||
|
||||
it("stripExecutionMetadata removes only runtime-owned keys (approval hash input)", () => {
|
||||
expect(stripExecutionMetadata(post()).providerMetadata).toEqual({ connectionId: "conn-x" });
|
||||
});
|
||||
|
||||
it("declares token dedupe for Mastodon and none for Telegram/unknown providers", () => {
|
||||
expect(publishingPolicyFor("mastodon")).toEqual({ idempotency: "token", dedupeWindowMs: MASTODON_DEDUPE_WINDOW_MS });
|
||||
expect(publishingPolicyFor("telegram")).toEqual({ idempotency: "none" });
|
||||
expect(publishingPolicyFor("bluesky")).toEqual({ idempotency: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishWithPolicy outcomes", () => {
|
||||
it("published: receipt carries the injected key, never the local id/draftId", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", ["ok"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY });
|
||||
expect(wire.posts).toHaveLength(1);
|
||||
expect(wire.posts[0]?.key).toBe(KEY);
|
||||
expect(wire.posts[0]?.body["scheduled_at"]).toBeUndefined();
|
||||
expect(outcome).toMatchObject({ kind: "published", receipt: { remoteId: "r1", idempotencyKey: KEY, connectionId: "conn-telegram" } });
|
||||
expect(outcome.kind === "published" && "id" in outcome.receipt).toBe(false);
|
||||
expect(outcome.kind === "published" && "draftId" in outcome.receipt).toBe(false);
|
||||
});
|
||||
|
||||
it("provider_scheduled when native scheduling is explicitly allowed", async () => {
|
||||
const { connector, connection } = setup("mastodon", ["scheduled"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post("x", { scheduledAt: new Date("2026-10-09T15:00:00Z") }), {
|
||||
idempotencyKey: KEY,
|
||||
allowProviderScheduling: true,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
kind: "provider_scheduled",
|
||||
scheduledRemoteId: "scheduled:s9",
|
||||
scheduledAt: new Date("2026-10-09T15:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("429 → retryable RATE_LIMITED with Retry-After, one send", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", ["429"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY });
|
||||
expect(wire.posts).toHaveLength(1);
|
||||
expect(outcome).toMatchObject({ kind: "retryable", code: "RATE_LIMITED", retryAfterMs: 12_000, resend: "not_delivered", dispatched: true });
|
||||
});
|
||||
|
||||
it("401 → rejected_permanent needing reauthorization; 422 → PROVIDER_REJECTED", async () => {
|
||||
const auth = setup("telegram", ["401"]);
|
||||
expect(await publishWithPolicy(auth.connector, auth.connection, post(), { idempotencyKey: KEY })).toMatchObject({
|
||||
kind: "rejected_permanent",
|
||||
code: "CREDENTIAL_REJECTED",
|
||||
needsReauthorization: true,
|
||||
});
|
||||
const invalid = setup("telegram", ["422"]);
|
||||
expect(await publishWithPolicy(invalid.connector, invalid.connection, post(), { idempotencyKey: KEY })).toMatchObject({
|
||||
kind: "rejected_permanent",
|
||||
code: "PROVIDER_REJECTED",
|
||||
status: 422,
|
||||
needsReauthorization: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("connector validation before any send → INVALID_POST, not dispatched", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", []);
|
||||
const outcome = await publishWithPolicy(connector, connection, post("too-long"), { idempotencyKey: KEY });
|
||||
expect(outcome).toMatchObject({ kind: "rejected_permanent", code: "INVALID_POST", dispatched: false });
|
||||
expect(wire.posts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("author-supplied media ids → INVALID_POST with zero HTTP", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", []);
|
||||
const outcome = await publishWithPolicy(connector, connection, post("x", { providerMetadata: { mediaIds: ["1"] } }), {
|
||||
idempotencyKey: KEY,
|
||||
});
|
||||
expect(outcome).toMatchObject({ kind: "rejected_permanent", code: "INVALID_POST" });
|
||||
expect(wire.gets + wire.posts.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ambiguous sends", () => {
|
||||
it("no-token provider: 5xx and dropped responses are unknown, never retryable", async () => {
|
||||
for (const reply of ["500", "drop"] as const) {
|
||||
const { wire, connector, connection } = setup("telegram", [reply]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY });
|
||||
expect(outcome).toMatchObject({ kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN", dispatched: true });
|
||||
expect(wire.posts).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("token provider on a verified origin inside the window: retryable deduplicated", async () => {
|
||||
const { connector, connection } = setup("mastodon", ["500"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY, dedupeVerified: true });
|
||||
expect(outcome).toMatchObject({ kind: "retryable", code: "PUBLISH_AMBIGUOUS_DEDUPED", resend: "deduplicated", status: 500 });
|
||||
});
|
||||
|
||||
it("token provider on an unverified origin is unknown", async () => {
|
||||
const { connector, connection } = setup("mastodon", ["500"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), { idempotencyKey: KEY });
|
||||
expect(outcome.kind).toBe("unknown");
|
||||
});
|
||||
|
||||
it("token provider past the window (measured from first dispatch) is unknown", async () => {
|
||||
const { connector, connection } = setup("mastodon", ["drop"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), {
|
||||
idempotencyKey: KEY,
|
||||
dedupeVerified: true,
|
||||
firstDispatchAt: new Date(Date.now() - MASTODON_DEDUPE_WINDOW_MS - 1000),
|
||||
});
|
||||
expect(outcome.kind).toBe("unknown");
|
||||
});
|
||||
|
||||
it("unsettled prior dispatch without dedupe: unknown with zero HTTP", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", ["ok"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), {
|
||||
idempotencyKey: KEY,
|
||||
unsettledPriorDispatch: true,
|
||||
firstDispatchAt: new Date(),
|
||||
dedupeVerified: true,
|
||||
});
|
||||
expect(outcome).toMatchObject({ kind: "unknown", dispatched: true });
|
||||
expect(wire.gets + wire.posts.length).toBe(0);
|
||||
});
|
||||
|
||||
it("unsettled prior dispatch with verified dedupe: replays the same key", async () => {
|
||||
const { wire, connector, connection } = setup("mastodon", ["ok"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), {
|
||||
idempotencyKey: KEY,
|
||||
unsettledPriorDispatch: true,
|
||||
firstDispatchAt: new Date(Date.now() - 60_000),
|
||||
dedupeVerified: true,
|
||||
});
|
||||
expect(outcome.kind).toBe("published");
|
||||
expect(wire.posts.map((p) => p.key)).toEqual([KEY]);
|
||||
});
|
||||
|
||||
it("a definite refusal of a replay does not claim the earlier send was not delivered", async () => {
|
||||
const { connector, connection } = setup("mastodon", ["429"]);
|
||||
const outcome = await publishWithPolicy(connector, connection, post(), {
|
||||
idempotencyKey: KEY,
|
||||
unsettledPriorDispatch: true,
|
||||
firstDispatchAt: new Date(),
|
||||
dedupeVerified: true,
|
||||
});
|
||||
expect(outcome).toMatchObject({ kind: "retryable", code: "RATE_LIMITED", resend: "deduplicated" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatch fence", () => {
|
||||
it("a failing fence blocks the mutating send; reads are not fenced", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", ["ok"]);
|
||||
const error = await publishWithPolicy(connector, connection, post(), {
|
||||
idempotencyKey: KEY,
|
||||
beforeMutation: () => {
|
||||
throw new Error("lease lost");
|
||||
},
|
||||
}).catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(DispatchFenceError);
|
||||
expect(error).toMatchObject({ dispatched: false });
|
||||
expect(wire.gets).toBe(1);
|
||||
expect(wire.posts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("concurrent executions keep their own fences (AsyncLocalStorage isolation)", async () => {
|
||||
const blocked = setup("telegram", ["ok"]);
|
||||
const allowed = setup("telegram", ["ok"]);
|
||||
const seen: string[] = [];
|
||||
const [a, b] = await Promise.allSettled([
|
||||
publishWithPolicy(blocked.connector, blocked.connection, post(), {
|
||||
idempotencyKey: "wp3:ws-1:a:a1:publish",
|
||||
beforeMutation: async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
seen.push("blocked");
|
||||
throw new Error("revoked");
|
||||
},
|
||||
}),
|
||||
publishWithPolicy(allowed.connector, allowed.connection, post(), {
|
||||
idempotencyKey: "wp3:ws-1:b:a1:publish",
|
||||
beforeMutation: () => void seen.push("allowed"),
|
||||
}),
|
||||
]);
|
||||
expect(a.status).toBe("rejected");
|
||||
expect(b).toMatchObject({ status: "fulfilled", value: { kind: "published" } });
|
||||
expect(blocked.wire.posts).toHaveLength(0);
|
||||
expect(allowed.wire.posts.map((p) => p.key)).toEqual(["wp3:ws-1:b:a1:publish"]);
|
||||
expect(seen.sort()).toEqual(["allowed", "blocked"]);
|
||||
});
|
||||
|
||||
it("calls outside a runtime execution are unaffected by the hook", async () => {
|
||||
const { wire, connector, connection } = setup("telegram", ["ok"]);
|
||||
await publishWithPolicy(connector, connection, post(), {
|
||||
idempotencyKey: KEY,
|
||||
beforeMutation: () => {
|
||||
throw new Error("only inside");
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
const direct = await connector.publish?.(connection, post());
|
||||
expect(direct?.remoteId).toBe("r1");
|
||||
expect(wire.posts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
239
packages/connector-runtime/src/publish.ts
Normal file
239
packages/connector-runtime/src/publish.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* Safe-replay wrapper around connector.publish (plan §4). It never retries by
|
||||
* itself: it injects the attempt's idempotency key, observes whether any
|
||||
* mutating request left the process, and classifies the result into a
|
||||
* PublishOutcome the caller can act on without resending ambiguous work.
|
||||
*/
|
||||
import type { ConnectorConnection, NormalizedPost, Publication } from "@tactical-media-broadcast-hub/core";
|
||||
import {
|
||||
AuthError,
|
||||
CapabilityNotSupportedError,
|
||||
ConnectorError,
|
||||
deliveryCertaintyOf,
|
||||
ProviderRejectedError,
|
||||
ProviderServerError,
|
||||
RateLimitError,
|
||||
type HttpRequestEvent,
|
||||
type PlatformConnector,
|
||||
} from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { runWithHttpHook } from "./execution-context.js";
|
||||
import { ExecutionMetadataError, preparePostForDispatch } from "./idempotency.js";
|
||||
import type { PublishOutcome, PublishReceipt } from "./outcome.js";
|
||||
import { publishingPolicyFor, type PublishingPolicy } from "./policy.js";
|
||||
|
||||
export interface PublishAttempt {
|
||||
idempotencyKey: string;
|
||||
/** Defaults to the provider's registered policy. */
|
||||
policy?: PublishingPolicy;
|
||||
/** First dispatch time of this attempt, from the execution journal. */
|
||||
firstDispatchAt?: Date;
|
||||
/**
|
||||
* An earlier dispatch of this attempt has neither a receipt nor a definite
|
||||
* rejection. Only a verified token-dedupe provider inside its window may be
|
||||
* called again; otherwise the outcome is unknown with zero sends.
|
||||
*/
|
||||
unsettledPriorDispatch?: boolean;
|
||||
/**
|
||||
* The deployment verified this origin's token dedupe (e.g. listed in
|
||||
* MASTODON_PUBLISH_DEDUPE_ORIGINS). Never derived from post metadata.
|
||||
*/
|
||||
dedupeVerified?: boolean;
|
||||
/** Keep post.scheduledAt for native provider scheduling (default false). */
|
||||
allowProviderScheduling?: boolean;
|
||||
/**
|
||||
* Fence check run immediately before each mutating HTTP send, after
|
||||
* rate-limiter waits. Throwing blocks that send; the wrapper rethrows the
|
||||
* failure as DispatchFenceError.
|
||||
*/
|
||||
beforeMutation?: (event: HttpRequestEvent) => void | Promise<void>;
|
||||
aroundMutation?: (event: HttpRequestEvent, send: () => Promise<Response>) => Promise<Response>;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
/** The caller's fence refused a send; carries whether earlier sends happened. */
|
||||
export class DispatchFenceError extends Error {
|
||||
constructor(
|
||||
readonly dispatched: boolean,
|
||||
override readonly cause: unknown,
|
||||
) {
|
||||
super("publish dispatch blocked by execution fence");
|
||||
this.name = "DispatchFenceError";
|
||||
}
|
||||
}
|
||||
|
||||
function canReplayAmbiguous(policy: PublishingPolicy, attempt: PublishAttempt, firstDispatchAt: Date | undefined, now: Date): boolean {
|
||||
if (policy.idempotency !== "token" || attempt.dedupeVerified !== true) return false;
|
||||
if (firstDispatchAt === undefined) return false;
|
||||
return now.getTime() - firstDispatchAt.getTime() < (policy.dedupeWindowMs ?? 0);
|
||||
}
|
||||
|
||||
function receiptOf(connection: ConnectorConnection, idempotencyKey: string, publication: Publication): PublishReceipt {
|
||||
const receipt: PublishReceipt = {
|
||||
provider: connection.provider,
|
||||
connectionId: connection.id,
|
||||
idempotencyKey,
|
||||
remoteId: publication.remoteId,
|
||||
publishedAt: publication.publishedAt,
|
||||
providerMetadata: { ...publication.providerMetadata },
|
||||
};
|
||||
if (publication.remoteUrl !== undefined) receipt.remoteUrl = publication.remoteUrl;
|
||||
return receipt;
|
||||
}
|
||||
|
||||
function successOutcome(connection: ConnectorConnection, idempotencyKey: string, publication: Publication): PublishOutcome {
|
||||
const receipt = receiptOf(connection, idempotencyKey, publication);
|
||||
const meta = publication.providerMetadata;
|
||||
const scheduled = publication.remoteId.startsWith("scheduled:") || meta["outcome"] === "provider_scheduled";
|
||||
if (!scheduled) return { kind: "published", receipt };
|
||||
const scheduledAt = typeof meta["scheduledAt"] === "string" ? new Date(meta["scheduledAt"]) : publication.publishedAt;
|
||||
return { kind: "provider_scheduled", receipt, scheduledRemoteId: publication.remoteId, scheduledAt };
|
||||
}
|
||||
|
||||
function statusOf(error: unknown): number | undefined {
|
||||
if (error instanceof ProviderRejectedError || error instanceof ProviderServerError || error instanceof RateLimitError) {
|
||||
return error.status;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function retryAfterOf(error: unknown): number | undefined {
|
||||
if (error instanceof RateLimitError || error instanceof ProviderRejectedError || error instanceof ProviderServerError) {
|
||||
return error.retryAfterMs;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a publish failure. `dispatched`: a mutating request was sent in
|
||||
* this call; `replayAmbiguous`: a same-key resend is provider-deduped now;
|
||||
* `priorUnsettled`: an earlier send of this attempt is still ambiguous, so
|
||||
* even a definite refusal of this call cannot prove "not delivered".
|
||||
*/
|
||||
export function classifyPublishError(
|
||||
error: unknown,
|
||||
dispatched: boolean,
|
||||
replayAmbiguous: boolean,
|
||||
priorUnsettled = false,
|
||||
): PublishOutcome {
|
||||
const outcome = classifyCurrentCall(error, dispatched, replayAmbiguous);
|
||||
if (priorUnsettled && outcome.kind === "retryable" && outcome.resend === "not_delivered") {
|
||||
return { ...outcome, resend: "deduplicated" };
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function classifyCurrentCall(error: unknown, dispatched: boolean, replayAmbiguous: boolean): PublishOutcome {
|
||||
const base = {
|
||||
dispatched,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
...(statusOf(error) === undefined ? {} : { status: statusOf(error) }),
|
||||
};
|
||||
const retryAfterMs = retryAfterOf(error);
|
||||
const retryAfter = retryAfterMs === undefined ? {} : { retryAfterMs };
|
||||
const definite = deliveryCertaintyOf(error) === "rejected" || !dispatched;
|
||||
|
||||
if (!definite) {
|
||||
if (replayAmbiguous) {
|
||||
return { kind: "retryable", code: "PUBLISH_AMBIGUOUS_DEDUPED", resend: "deduplicated", ...base, ...retryAfter };
|
||||
}
|
||||
return { kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN", ...base, dispatched: true };
|
||||
}
|
||||
|
||||
if (error instanceof RateLimitError) {
|
||||
return { kind: "retryable", code: "RATE_LIMITED", resend: "not_delivered", ...base, ...retryAfter };
|
||||
}
|
||||
if (error instanceof AuthError) {
|
||||
return { kind: "rejected_permanent", code: "CREDENTIAL_REJECTED", needsReauthorization: true, ...base };
|
||||
}
|
||||
if (error instanceof ProviderRejectedError) {
|
||||
// 408/425: the provider refused for timing reasons, not content.
|
||||
if (error.status === 408 || error.status === 425) {
|
||||
return { kind: "retryable", code: "PROVIDER_UNAVAILABLE", resend: "not_delivered", ...base, ...retryAfter };
|
||||
}
|
||||
return { kind: "rejected_permanent", code: "PROVIDER_REJECTED", needsReauthorization: false, ...base };
|
||||
}
|
||||
if (error instanceof ProviderServerError) {
|
||||
return { kind: "retryable", code: "PROVIDER_UNAVAILABLE", resend: "not_delivered", ...base, ...retryAfter };
|
||||
}
|
||||
if (error instanceof CapabilityNotSupportedError) {
|
||||
return { kind: "rejected_permanent", code: "UNSUPPORTED", needsReauthorization: false, ...base };
|
||||
}
|
||||
if (error instanceof ExecutionMetadataError) {
|
||||
return { kind: "rejected_permanent", code: "INVALID_POST", needsReauthorization: false, ...base };
|
||||
}
|
||||
if (error instanceof ConnectorError) {
|
||||
// Connector-side validation (limits, missing loaders) before any send.
|
||||
return { kind: "rejected_permanent", code: "INVALID_POST", needsReauthorization: false, ...base };
|
||||
}
|
||||
// Raw transport/runtime failure before any mutating send: nothing delivered.
|
||||
return { kind: "retryable", code: "TRANSPORT_FAILURE", resend: "not_delivered", ...base };
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish one attempt through `connector.publish` (signature unchanged).
|
||||
* Rethrows only DispatchFenceError; every provider result is an outcome.
|
||||
*/
|
||||
export async function publishWithPolicy(
|
||||
connector: PlatformConnector,
|
||||
connection: ConnectorConnection,
|
||||
post: NormalizedPost,
|
||||
attempt: PublishAttempt,
|
||||
): Promise<PublishOutcome> {
|
||||
const now = attempt.now ?? (() => new Date());
|
||||
const policy = attempt.policy ?? publishingPolicyFor(connection.provider);
|
||||
|
||||
if (connector.publish === undefined) {
|
||||
return { kind: "rejected_permanent", code: "UNSUPPORTED", needsReauthorization: false, dispatched: false };
|
||||
}
|
||||
if (attempt.unsettledPriorDispatch === true && !canReplayAmbiguous(policy, attempt, attempt.firstDispatchAt, now())) {
|
||||
return { kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN", dispatched: true };
|
||||
}
|
||||
|
||||
let prepared: NormalizedPost;
|
||||
try {
|
||||
prepared = preparePostForDispatch(post, {
|
||||
idempotencyKey: attempt.idempotencyKey,
|
||||
allowProviderScheduling: attempt.allowProviderScheduling,
|
||||
});
|
||||
} catch (error) {
|
||||
return classifyPublishError(error, false, false);
|
||||
}
|
||||
|
||||
let mutations = 0;
|
||||
let firstMutationAt: Date | undefined;
|
||||
let fenceFailure: DispatchFenceError | undefined;
|
||||
const publish = connector.publish.bind(connector);
|
||||
try {
|
||||
const publication = await runWithHttpHook(
|
||||
{
|
||||
aroundMutation: async (event, send) => {
|
||||
const observedSend = async (): Promise<Response> => {
|
||||
mutations += 1;
|
||||
firstMutationAt ??= now();
|
||||
return send();
|
||||
};
|
||||
try {
|
||||
await attempt.beforeMutation?.(event);
|
||||
} catch (cause) {
|
||||
fenceFailure = new DispatchFenceError(mutations > 0 || attempt.unsettledPriorDispatch === true, cause);
|
||||
throw fenceFailure;
|
||||
}
|
||||
return attempt.aroundMutation ? attempt.aroundMutation(event, observedSend) : observedSend();
|
||||
},
|
||||
},
|
||||
() => publish(connection, prepared),
|
||||
);
|
||||
return successOutcome(connection, attempt.idempotencyKey, publication);
|
||||
} catch (error) {
|
||||
if (fenceFailure !== undefined) throw fenceFailure;
|
||||
if (error instanceof DispatchFenceError) throw error;
|
||||
const dispatched = mutations > 0;
|
||||
const firstDispatchAt = attempt.firstDispatchAt ?? firstMutationAt;
|
||||
return classifyPublishError(
|
||||
error,
|
||||
dispatched,
|
||||
canReplayAmbiguous(policy, attempt, firstDispatchAt, now()),
|
||||
attempt.unsettledPriorDispatch === true,
|
||||
);
|
||||
}
|
||||
}
|
||||
28
packages/connector-runtime/src/registry.test.ts
Normal file
28
packages/connector-runtime/src/registry.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { Pool } from "pg";
|
||||
import { createPublishingRegistry, verifiedDedupeOrigin } from "./registry.js";
|
||||
import { createPublishingCredentialAccessor } from "./credentials.js";
|
||||
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
||||
import { runWithPublishingContext } from "./execution-context.js";
|
||||
const connection: ConnectorConnection = { id: "connection", workspaceId: "workspace", provider: "mastodon", channelId: "channel", fields: {}, createdAt: new Date(), credentialId: { kind: "credential-ref", id: "connection" } };
|
||||
describe("production publishing registry", () => {
|
||||
it("registers real Mastodon and Telegram connectors and rejects duplicates", () => {
|
||||
const pool = {} as Pool;
|
||||
const registry = createPublishingRegistry(pool);
|
||||
expect(registry.list().map((c) => c.manifest().id).sort()).toEqual(["mastodon", "telegram"]);
|
||||
expect(registry.get("mastodon").publish).toBeTypeOf("function");
|
||||
expect(() => registry.register(registry.get("mastodon"))).toThrow(/already registered/);
|
||||
});
|
||||
it("trusts only explicitly configured HTTPS origins", () => {
|
||||
expect(verifiedDedupeOrigin("https://social.test", "")).toBe(false);
|
||||
expect(verifiedDedupeOrigin("https://social.test", "https://social.test")).toBe(true);
|
||||
expect(verifiedDedupeOrigin("https://social.test", "https://social.test/path")).toBe(false);
|
||||
expect(verifiedDedupeOrigin("http://social.test", "http://social.test")).toBe(false);
|
||||
});
|
||||
it("refuses absent or mismatched context and publishing credential mutation before SQL", async () => {
|
||||
const accessor = createPublishingCredentialAccessor({} as Pool);
|
||||
await expect(accessor.getAccessToken(connection)).rejects.toThrow("EXECUTION_CREDENTIAL_FENCE");
|
||||
await expect(accessor.put(connection, "access_token", "secret")).rejects.toThrow("MUTATION_REFUSED");
|
||||
await expect(runWithPublishingContext({ workspaceId: "other", connectionId: "connection", credentialId: "uuid", credentialGeneration: "1" }, () => accessor.getAccessToken(connection))).rejects.toThrow("EXECUTION_CREDENTIAL_FENCE");
|
||||
});
|
||||
});
|
||||
17
packages/connector-runtime/src/registry.ts
Normal file
17
packages/connector-runtime/src/registry.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Pool } from "pg";
|
||||
import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
|
||||
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
|
||||
import { createPublishingCredentialAccessor } from "./credentials.js";
|
||||
import { providerFetch } from "./provider-fetch.js";
|
||||
import { executionSignal } from "./execution-context.js";
|
||||
/** Production composition registers real connectors, once per worker process. */
|
||||
export function createPublishingRegistry(pool: Pool): ConnectorRegistry {
|
||||
const registry = new ConnectorRegistry();
|
||||
const fetchImpl: typeof fetch = (input, init) => providerFetch(input, { ...init, signal: executionSignal() ?? init?.signal });
|
||||
const options = { credentials: createPublishingCredentialAccessor(pool), fetchImpl, env: process.env };
|
||||
registry.register(createMastodonConnector(options));
|
||||
registry.register(createTelegramConnector(options));
|
||||
return registry;
|
||||
}
|
||||
export { verifiedDedupeOrigin } from "./policy.js";
|
||||
13
packages/connector-runtime/src/vault.test.ts
Normal file
13
packages/connector-runtime/src/vault.test.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Vault } from "./vault.js";
|
||||
describe("persistent APV1 compatibility", () => {
|
||||
it("survives new vault instances, binds tenant and connection, and rejects tampering", async () => {
|
||||
const sealed = await new Vault().seal("workspace", "connection", Buffer.from("canary-secret"));
|
||||
expect(sealed.ciphertext.subarray(0, 4).toString()).toBe("APV1");
|
||||
expect((await new Vault().open("workspace", "connection", sealed)).toString()).toBe("canary-secret");
|
||||
await expect(new Vault().open("other", "connection", sealed)).rejects.toThrow();
|
||||
await expect(new Vault().open("workspace", "other", sealed)).rejects.toThrow();
|
||||
const ciphertext = Buffer.from(sealed.ciphertext); ciphertext[ciphertext.length - 1] = ciphertext[ciphertext.length - 1]! ^ 1;
|
||||
await expect(new Vault().open("workspace", "connection", { ...sealed, ciphertext })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
121
packages/connector-runtime/src/vault.ts
Normal file
121
packages/connector-runtime/src/vault.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Envelope encryption vault.
|
||||
* - Master KEK from env (32 bytes, base64). Dev fallback: derive a stable key
|
||||
* from a machine-local file so ciphertexts survive restarts in development.
|
||||
* - Per-workspace DEK (AES-256-GCM) wrapped by the KEK.
|
||||
* - Rotation: new DEK per workspace, key_version increments; old ciphertexts
|
||||
* stay readable until re-wrapped, then the old DEK is dropped.
|
||||
*/
|
||||
|
||||
const ALGO = "aes-256-gcm";
|
||||
|
||||
export function loadKek(): Buffer {
|
||||
const env = process.env.SUPERPOSTER_KEK;
|
||||
if (env !== undefined && env !== "") {
|
||||
const kek = Buffer.from(env, "base64");
|
||||
if (kek.length !== 32) throw new Error("SUPERPOSTER_KEK must be 32 bytes base64");
|
||||
return kek;
|
||||
}
|
||||
// Dev fallback: deterministic from hostname — NEVER production.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("SUPERPOSTER_KEK is required in production");
|
||||
}
|
||||
return createHash("sha256").update(`dev-kek:${process.env.HOSTNAME ?? "local"}`).digest();
|
||||
}
|
||||
|
||||
const KEK = loadKek();
|
||||
|
||||
export interface SealedCredential {
|
||||
ciphertext: Buffer;
|
||||
keyVersion: number;
|
||||
}
|
||||
|
||||
interface WrappedDek {
|
||||
keyVersion: number;
|
||||
wrapped: Buffer;
|
||||
}
|
||||
|
||||
function aesEncrypt(key: Buffer, plaintext: Buffer): Buffer {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
return Buffer.concat([iv, cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
||||
}
|
||||
|
||||
function aesDecrypt(key: Buffer, sealed: Buffer): Buffer {
|
||||
const iv = sealed.subarray(0, 12);
|
||||
const tag = sealed.subarray(sealed.length - 16);
|
||||
const body = sealed.subarray(12, sealed.length - 16);
|
||||
const decipher = createDecipheriv(ALGO, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(body), decipher.final()]);
|
||||
}
|
||||
|
||||
/** In-memory DEK cache; the persistent wrapped-DEK store arrives with the credentials table wiring. */
|
||||
export class Vault {
|
||||
/** Self-contained envelope for persistent stores; AAD binds tenant and handle. */
|
||||
async seal(workspaceId: string, credentialId: string, plaintext: Buffer): Promise<SealedCredential> {
|
||||
const dek = randomBytes(32);
|
||||
const wrapped = aesEncrypt(KEK, dek);
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGO, dek, iv);
|
||||
cipher.setAAD(Buffer.from(JSON.stringify([workspaceId, credentialId])));
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
return { keyVersion: 1, ciphertext: Buffer.concat([Buffer.from("APV1"), wrapped, iv, cipher.getAuthTag(), encrypted]) };
|
||||
}
|
||||
|
||||
async open(workspaceId: string, credentialId: string, sealed: SealedCredential): Promise<Buffer> {
|
||||
const blob = sealed.ciphertext;
|
||||
if (blob.length < 92 || blob.subarray(0, 4).toString() !== "APV1") throw new Error("invalid vault envelope");
|
||||
const dek = aesDecrypt(KEK, blob.subarray(4, 64));
|
||||
const decipher = createDecipheriv(ALGO, dek, blob.subarray(64, 76));
|
||||
decipher.setAAD(Buffer.from(JSON.stringify([workspaceId, credentialId])));
|
||||
decipher.setAuthTag(blob.subarray(76, 92));
|
||||
return Buffer.concat([decipher.update(blob.subarray(92)), decipher.final()]);
|
||||
}
|
||||
private readonly deks = new Map<string, { dek: Buffer; wrapped: WrappedDek }>();
|
||||
private versions = new Map<string, number>();
|
||||
|
||||
async put(_workspaceId: string, credentialId: string, plaintext: Buffer): Promise<SealedCredential> {
|
||||
const { dek, wrapped } = await this.ensureDek(_workspaceId);
|
||||
this.deks.set(credentialId, { dek, wrapped });
|
||||
const version = this.versions.get(_workspaceId) ?? 1;
|
||||
return { ciphertext: aesEncrypt(dek, plaintext), keyVersion: version };
|
||||
}
|
||||
|
||||
async get(_workspaceId: string, credentialId: string, sealed: SealedCredential): Promise<Buffer> {
|
||||
const entry = this.deks.get(credentialId);
|
||||
if (entry === undefined) throw new Error("credential not loaded into vault session");
|
||||
return aesDecrypt(entry.dek, sealed.ciphertext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotation: fresh DEK for the workspace, re-wrap given credentials,
|
||||
* drop the old DEK so pre-rotation ciphertext becomes unreadable.
|
||||
*/
|
||||
async rotate(workspaceId: string, secrets: Array<{ credentialId: string; plaintext: Buffer }>): Promise<Map<string, SealedCredential>> {
|
||||
const nextVersion = (this.versions.get(workspaceId) ?? 1) + 1;
|
||||
const newDek = randomBytes(32);
|
||||
const wrapped: WrappedDek = { keyVersion: nextVersion, wrapped: aesEncrypt(KEK, newDek) };
|
||||
this.versions.set(workspaceId, nextVersion);
|
||||
const out = new Map<string, SealedCredential>();
|
||||
for (const { credentialId, plaintext } of secrets) {
|
||||
this.deks.set(credentialId, { dek: newDek, wrapped });
|
||||
out.set(credentialId, { ciphertext: aesEncrypt(newDek, plaintext), keyVersion: nextVersion });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private async ensureDek(workspaceId: string): Promise<{ dek: Buffer; wrapped: WrappedDek }> {
|
||||
const existing = [...this.deks.values()].find((e) => e.wrapped.keyVersion === (this.versions.get(workspaceId) ?? 1));
|
||||
if (existing !== undefined) return existing;
|
||||
const version = 1;
|
||||
const dek = randomBytes(32);
|
||||
const entry = { dek, wrapped: { keyVersion: version, wrapped: aesEncrypt(KEK, dek) } };
|
||||
this.versions.set(workspaceId, version);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
export const vault = new Vault();
|
||||
8
packages/connector-runtime/tsconfig.json
Normal file
8
packages/connector-runtime/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue