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
25
packages/publishing-execution/README.md
Normal file
25
packages/publishing-execution/README.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Publishing execution
|
||||
|
||||
The API and scheduler share this built PostgreSQL repository. Callers own the
|
||||
`pg.Pool` and its lifecycle. `scheduleAttempt` and `cancelAttempt` run inside the
|
||||
API's existing tenant transaction; other methods establish their own RLS-bound
|
||||
transaction. Mutation order is workspace, draft, schedule, attempt, audit.
|
||||
|
||||
A business attempt gets a positive decimal attempt number and one provider key.
|
||||
Transport retries, Temporal replay and lease recovery retain that key.
|
||||
`retryLater` requires proof that the attempt has no unresolved delivery:
|
||||
429/refusal of the current request cannot disprove an earlier ambiguous send.
|
||||
Use `retryDeduplicated` for that case; it retains dispatch evidence and the
|
||||
original dedupe deadline. Both settlements are owner/epoch/expiry fenced.
|
||||
Unknown work cannot get a new key or send automatically.
|
||||
|
||||
The SDK around-send hook holds `withDispatchGate` across the bounded request,
|
||||
after rate-limiter waits. Provider evidence is appended separately before
|
||||
finalization, including evidence from stale owners. A current owner can finalize
|
||||
a receipt after authorization changes; the audit records those changes. Receipt
|
||||
recovery uses a separate workflow ID and never needs another provider send.
|
||||
|
||||
Runtime boot never executes owner DDL. Apply migrations through the API's offline
|
||||
`migrate` CLI with an explicit `SUPER_DATABASE_URL`; API writes and scheduler
|
||||
startup require completed 019, FORCE RLS, valid indexes and the `sp_app` role.
|
||||
018 is retained unchanged because local evidence records its application.
|
||||
27
packages/publishing-execution/package.json
Normal file
27
packages/publishing-execution/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@tactical-media-broadcast-hub/publishing-execution",
|
||||
"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/core": "workspace:*",
|
||||
"pg": "^8.23.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^2.0.5",
|
||||
"@types/pg": "^8.23.1"
|
||||
}
|
||||
}
|
||||
17
packages/publishing-execution/src/approval.test.ts
Normal file
17
packages/publishing-execution/src/approval.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { publishingIdempotencyKey } from "@tactical-media-broadcast-hub/core";
|
||||
import { canonicalPostHash } from "./approval.js";
|
||||
describe("approval and attempt identity", () => {
|
||||
it("sorts nested keys, excludes execution metadata, and preserves target and array order", () => {
|
||||
const post = { text: "hi", mediaAssetIds: ["a", "b"], providerMetadata: { connectionId: "target", nested: { z: 2, a: 1 } } };
|
||||
const reordered = { providerMetadata: { nested: { a: 1, z: 2 }, connectionId: "target", draftId: "injected", idempotencyKey: "key" }, mediaAssetIds: ["a", "b"], text: "hi" };
|
||||
expect(canonicalPostHash(post)).toBe(canonicalPostHash(reordered));
|
||||
expect(canonicalPostHash(post)).not.toBe(canonicalPostHash({ ...post, mediaAssetIds: ["b", "a"] }));
|
||||
expect(canonicalPostHash(post)).not.toBe(canonicalPostHash({ ...post, providerMetadata: { ...post.providerMetadata, connectionId: "other" } }));
|
||||
});
|
||||
it("uses decimal bigint attempts without precision loss", () => {
|
||||
expect(publishingIdempotencyKey({ workspaceId: "ws", draftId: "draft", attemptNo: "9007199254740993" })).toBe("wp3:ws:draft:a9007199254740993:publish");
|
||||
expect(() => publishingIdempotencyKey({ workspaceId: "ws", draftId: "draft", attemptNo: Number.MAX_SAFE_INTEGER + 1 })).toThrow();
|
||||
for (const attemptNo of ["0", "01", "-1", "1.1"]) expect(() => publishingIdempotencyKey({ workspaceId: "ws", draftId: "draft", attemptNo })).toThrow();
|
||||
});
|
||||
});
|
||||
13
packages/publishing-execution/src/approval.ts
Normal file
13
packages/publishing-execution/src/approval.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { createHash } from "node:crypto";
|
||||
function canonical(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, canonical(item)]));
|
||||
return value;
|
||||
}
|
||||
/** The target lives in providerMetadata.connectionId; execution keys are excluded. */
|
||||
export function canonicalPostHash<T extends { providerMetadata: Record<string, unknown> }>(post: T): string {
|
||||
const metadata = { ...post.providerMetadata };
|
||||
delete metadata.idempotencyKey;
|
||||
delete metadata.draftId;
|
||||
return createHash("sha256").update(JSON.stringify(canonical({ ...post, providerMetadata: metadata }))).digest("hex");
|
||||
}
|
||||
3
packages/publishing-execution/src/index.ts
Normal file
3
packages/publishing-execution/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./publishing.repository.js";
|
||||
export * from "./approval.js";
|
||||
export * from "./schema.js";
|
||||
1219
packages/publishing-execution/src/publishing.repository.ts
Normal file
1219
packages/publishing-execution/src/publishing.repository.ts
Normal file
File diff suppressed because it is too large
Load diff
12
packages/publishing-execution/src/schema.ts
Normal file
12
packages/publishing-execution/src/schema.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { Pool } from "pg";
|
||||
/** No runtime DDL: code refuses an incomplete or incorrectly privileged schema. */
|
||||
export async function assertPublishingSchema(pool: Pool): Promise<void> {
|
||||
const result = await pool.query<{ ready: boolean }>(`SELECT
|
||||
EXISTS (SELECT 1 FROM schema_migrations WHERE name = '019_publishing_integration.sql')
|
||||
AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'publishing_attempts' AND column_name = 'first_dispatch_started_at')
|
||||
AND (SELECT count(*) = 2 AND bool_and(relrowsecurity AND relforcerowsecurity) FROM pg_class WHERE oid IN ('publishing_attempts'::regclass, 'publishing_receipts'::regclass))
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_index WHERE indrelid IN ('publishing_attempts'::regclass, 'publishing_receipts'::regclass) AND NOT indisvalid)
|
||||
AND current_user = 'sp_app'
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = current_user AND (rolsuper OR rolbypassrls)) AS ready`);
|
||||
if (result.rows[0]?.ready !== true) throw new Error("PUBLISHING_SCHEMA_NOT_READY");
|
||||
}
|
||||
8
packages/publishing-execution/tsconfig.json
Normal file
8
packages/publishing-execution/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