feat: per-connection publish governance — auto, 1-admin, majority, consensus

Per the owner's spec: each social connection gets publishGovernance in its
fields — unset = manual publish (today), auto = dispatch on approval,
admin1/admin_majority/admin_consensus = a publish-phase chatgov poll
(subject draft_publish, its own open-poll slot, no collision with review
gates). Dispatch holds until the publish poll passes (E3 pre-dispatch
check); auto dispatches on approval. Includes migration 033, the PATCH
/connections/:id/publish-governance route (chatgov.manage), and 19+31 new
tests. Implementation by the branding-panel process (claude/opus), lint
and integration fixes here.
This commit is contained in:
glass beadian 2026-10-14 08:04:56 +00:00
parent f2eb14956e
commit b89dea5c93
26 changed files with 1637 additions and 31 deletions

View file

@ -6,6 +6,7 @@ import { withTenant } from "../db/client.js";
import type { SealedCredential } from "../vault/vault.js";
import type { ConnectionSummary } from "./connections.service.js";
import { scrub } from "../audit/audit.service.js";
import { readPublishGovernance, type PublishGovernance } from "../publishing/publish-governance.js";
export interface StoredConnection { connection: ConnectorConnection; credential: SealedCredential | null }
export interface ConnectionStore {
@ -15,6 +16,8 @@ export interface ConnectionStore {
save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void>;
rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void>;
remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void>;
/** Set (or clear with null) connection.fields.publishGovernance; chatgov.manage holders only. */
setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void>;
}
export class PgConnectionStore implements ConnectionStore {
@ -65,7 +68,9 @@ export class PgConnectionStore implements ConnectionStore {
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
const fields = { ...connection.fields, handle: identity.handle, displayName: identity.displayName ?? identity.handle, remoteId: identity.remoteId };
if (reconnect) {
const updated = await client.query("UPDATE connections SET fields = $2, disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id", [connection.id, fields, connection.provider]);
// Governance settings are not provider identity: a reconnect keeps them.
const updated = await client.query(`UPDATE connections SET fields = $2::jsonb || jsonb_strip_nulls(jsonb_build_object('publishGovernance', fields->'publishGovernance')),
disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id`, [connection.id, fields, connection.provider]);
if (!updated.rowCount) throw new NotFoundException("Connection removed during authorization");
} else {
await client.query("INSERT INTO connections (id, workspace_id, provider, channel_id, fields) VALUES ($1,$2,$3,$4,$5)", [connection.id, session.workspaceId, connection.provider, connection.channelId, fields]);
@ -106,6 +111,22 @@ export class PgConnectionStore implements ConnectionStore {
catch { await this.transaction(session.workspaceId, (client) => client.query("SELECT audit_append($1,$2,$3,'connection.revoke_failed','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id])); }
}
}
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void> {
await this.transaction(session.workspaceId, async (client) => {
// Serialized with membership revocation like every governance write.
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [session.workspaceId]);
const allowed = await client.query(`SELECT 1 FROM memberships m JOIN workspaces w ON w.id = m.workspace_id JOIN role_permissions rp ON rp.role = m.role WHERE m.user_id = $1 AND m.status = 'active' AND w.authz_epoch = $2 AND rp.permission = 'chatgov.manage'`, [session.userId, session.authzEpoch]);
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
const current = await client.query("SELECT fields FROM connections WHERE id = $1 AND disconnected_at IS NULL FOR UPDATE", [id]);
if (!current.rowCount) throw new NotFoundException("Connection not found");
const previous = readPublishGovernance(current.rows[0]?.fields);
await client.query(
"UPDATE connections SET fields = CASE WHEN $2::text IS NULL THEN fields - 'publishGovernance' ELSE fields || jsonb_build_object('publishGovernance', $2::text) END WHERE id = $1",
[id, mode],
);
await client.query("SELECT audit_append($1,$2,$3,'connection.publish_governance','connection',$4,$5)", [session.workspaceId, session.userId, session.role, id, { from: previous, to: mode }]);
});
}
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
await this.transaction(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);

View file

@ -1,9 +1,11 @@
import { Body, Controller, Delete, Get, Inject, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
import type { Request, Response } from "express";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js";
import { ConnectionsService, type ConnectionSummary } from "./connections.service.js";
import { AUTH_TTL_MS, stateCookieName, validState } from "./pending.js";
import { CHATGOV_PERMISSIONS } from "../chatgov/policy.js";
import { PUBLISH_GOVERNANCE_MODES, type PublishGovernance } from "../publishing/publish-governance.js";
@Controller("connections")
export class ConnectionsController {
@ -74,6 +76,19 @@ export class ConnectionsController {
return this.redirectResponse(response, await this.connections.reconnect(requireSession(request), parseId(id)));
}
/**
* Publish governance for one connection: auto | admin1 | admin_majority |
* admin_consensus, or null for manual publishing. Deciding who must sign
* off a publish is a governance rule, so it needs chatgov.manage.
*/
@Patch(":connectionId/publish-governance")
@UseGuards(RbacGuard)
@RequirePermission(CHATGOV_PERMISSIONS.manage)
async publishGovernance(@Req() request: Request & AuthenticatedRequest, @Param("connectionId") id: string, @Body() body: unknown) {
const input = requireObject(body, "publish governance");
return this.connections.setPublishGovernance(requireSession(request), parseId(id), parsePublishGovernance(input.publishGovernance));
}
@Delete(":connectionId")
@UseGuards(RbacGuard)
@RequirePermission("connection.manage")
@ -82,3 +97,10 @@ export class ConnectionsController {
return { removed: true, token };
}
}
function parsePublishGovernance(value: unknown): PublishGovernance | null {
if (value === null) return null;
const mode = PUBLISH_GOVERNANCE_MODES.find((candidate) => candidate === value);
if (mode === undefined) throw new BadRequestException(`publishGovernance must be null or one of: ${PUBLISH_GOVERNANCE_MODES.join(", ")}`);
return mode;
}

View file

@ -15,6 +15,7 @@ import { RbacGuard } from "../rbac/rbac.guard.js";
import { logger } from "../telemetry/logger.js";
import { ConnectionsController } from "./connections.controller.js";
import { CONNECTION_DEPENDENCIES, ConnectionsService, type ConnectionDependencies, type ConnectionSummary } from "./connections.service.js";
import type { PublishGovernance } from "../publishing/publish-governance.js";
import type { ConnectionStore, StoredConnection } from "./connection-store.js";
import { RedisPendingStore } from "./pending.js";
import { MemoryPendingRedis } from "./testing.js";
@ -50,6 +51,14 @@ class MemoryConnections implements ConnectionStore {
this.rows.delete(id);
this.audit.push({ action: "connection.remove" });
}
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void> {
await this.assertAuthorized(session);
const row = await this.get(session.workspaceId, id);
const fields = { ...row.connection.fields };
delete fields.publishGovernance;
row.connection.fields = mode === null ? fields : { ...fields, publishGovernance: mode };
this.audit.push({ action: "connection.publish_governance" });
}
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
const row = await this.get(workspaceId, id);
if (row.credential?.ciphertext.equals(previous.ciphertext)) {
@ -333,6 +342,21 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
bridgeConfigured = false;
});
it("sets, validates and clears per-connection publish governance", async () => {
await finish(await begin());
const id = (await listed())[0]?.id ?? "";
const set = await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "admin_majority" });
expect(set.status).toBe(200);
expect(await set.json()).toEqual({ connectionId: id, publishGovernance: "admin_majority" });
expect((await listed())[0]?.fields.publishGovernance).toBe("admin_majority");
expect(store.audit.at(-1)?.action).toBe("connection.publish_governance");
expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "everyone" })).status).toBe(400);
expect((await api(`/${id}/publish-governance`, "PATCH", {})).status).toBe(400);
expect((await api("/not-a-uuid/publish-governance", "PATCH", { publishGovernance: "auto" })).status).toBe(400);
expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: null })).status).toBe(200);
expect((await listed())[0]?.fields).not.toHaveProperty("publishGovernance");
});
it("does not resurrect a connection removed while reconnect consent is pending", async () => {
await finish(await begin());
const row = (await listed())[0];

View file

@ -11,6 +11,7 @@ import { connectionRegistry } from "./connector-registry.js";
import { PgConnectionStore, type ConnectionStore, type StoredConnection } from "./connection-store.js";
import { AUTH_TTL_MS, browserHash, randomState, RedisPendingStore, type PendingStore } from "./pending.js";
import { oauthFetch } from "./oauth-fetch.js";
import type { PublishGovernance } from "../publishing/publish-governance.js";
export type ConnectionHealth = "healthy" | "needs_credentials" | "unavailable";
export interface ConnectionSummary {
@ -214,6 +215,11 @@ export class ConnectionsService {
}
return rows;
}
/** Per-connection publish governance (publish-governance.ts); null = manual. */
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<{ connectionId: string; publishGovernance: PublishGovernance | null }> {
await this.deps.store.setPublishGovernance(session, id, mode);
return { connectionId: id, publishGovernance: mode };
}
async remove(session: SessionPayload, id: string): Promise<string> {
await this.deps.store.assertAuthorized(session);
await this.deps.store.remove(session, id, async (stored) => {