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:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View 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"
}
}

View 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"); } };
}

View 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; }

View 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;
}

View 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";

View 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"];

View 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) };
}

View 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 });
});
});

View 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();
});
};

View 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);
});
});

View 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,
);
}
}

View 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");
});
});

View 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";

View 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();
});
});

View 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();

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}