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
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue