feat(new): enforce social publishing governance

Amp-Thread-ID: https://ampcode.com/threads/T-019e55df-9bcd-73e6-9935-c1e6238cd273
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
ZacharyZhang-NYandAmp committed 2026-05-23 14:18:34 -04:00
1 parent 8a1389bcbc
commit 6d99f515c7
10 files changed
+643 -141

No files matched your search

@@ -117,7 +117,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.launchSocialLogin, (_event, input) => {
try {
social.launchLoginWindow(input.socialAccountId);
social.launchLoginWindow(input);
return ok(null);
} catch (error) {
return fail("social_login_failed", error instanceof Error ? error.message : String(error));
@@ -0,0 +1,49 @@
export const socialGovernanceSchema = `
update social_account_grants
set scope_json = '{"actions":["login","draft","approve"],"riskCeiling":"medium"}'
where scope_json = '{"actions":["login","draft","approve"]}';
create temp table if not exists social_post_approval_backfill(
post_id text primary key,
approval_id text not null
);
delete from social_post_approval_backfill;
insert into social_post_approval_backfill(post_id, approval_id)
select id, 'app_' || lower(hex(randomblob(16)))
from scheduled_posts
where state = 'scheduled' and approval_id is null;
insert into approvals(
id, org_id, kind, title, details, risk, requested_by_type, requested_by_id,
state, decided_by_employee_id, created_at, decided_at
)
select
backfill.approval_id,
posts.org_id,
'social_publish',
'Approve social post',
posts.body_md,
'medium',
case when posts.created_by_employee_id is null then 'system' else 'employee' end,
posts.created_by_employee_id,
'pending',
null,
posts.created_at,
null
from scheduled_posts posts
join social_post_approval_backfill backfill on backfill.post_id = posts.id;
update scheduled_posts
set approval_id = (
select approval_id from social_post_approval_backfill
where social_post_approval_backfill.post_id = scheduled_posts.id
)
where id in (select post_id from social_post_approval_backfill);
drop table social_post_approval_backfill;
insert into schema_migrations(version, name, applied_at)
values (9, 'social_governance', datetime('now'));
`;
@@ -7,6 +7,7 @@ import { chatReadState } from "./migrations/005-chat-read-state";
import { appStateSchema } from "./migrations/006-app-state";
import { skillHardeningSchema } from "./migrations/007-skill-hardening";
import { providerHealthSchema } from "./migrations/008-provider-health";
import { socialGovernanceSchema } from "./migrations/009-social-governance";
const MIGRATIONS: Array<{ version: number; sql: string }> = [
{ version: 1, sql: initialEnterpriseSchema },
@@ -17,6 +18,7 @@ const MIGRATIONS: Array<{ version: number; sql: string }> = [
{ version: 6, sql: appStateSchema },
{ version: 7, sql: skillHardeningSchema },
{ version: 8, sql: providerHealthSchema },
{ version: 9, sql: socialGovernanceSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -12,6 +12,7 @@ export class ApprovalService {
}
create(input: CreateApprovalInput): string {
this.assertRequesterBelongsToOrg(input);
const id = createId("app");
const now = new Date().toISOString();
this.db.prepare(`
@@ -46,6 +47,7 @@ export class ApprovalService {
}
decide(input: DecideApprovalInput): void {
this.assertEmployee(input.orgId, input.decidedByEmployeeId, "Deciding employee was not found.");
const now = new Date().toISOString();
const result = this.db.prepare(`
update approvals
@@ -63,4 +65,26 @@ export class ApprovalService {
payload: { decidedAt: now },
});
}
private assertRequesterBelongsToOrg(input: CreateApprovalInput): void {
if (input.requestedByType === "employee") {
if (!input.requestedById) throw new Error("Requesting employee is required.");
this.assertEmployee(input.orgId, input.requestedById, "Requesting employee was not found.");
return;
}
if (input.requestedByType === "agent") {
if (!input.requestedById) throw new Error("Requesting agent is required.");
const agent = this.db.prepare("select id from agents where id = ? and org_id = ?").get(input.requestedById, input.orgId);
if (!agent) throw new Error("Requesting agent was not found.");
return;
}
if (input.requestedByType === "system" && input.requestedById) {
throw new Error("System approvals cannot include a requester id.");
}
}
private assertEmployee(orgId: string, employeeId: string, message: string): void {
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
if (!employee) throw new Error(message);
}
}
@@ -3,10 +3,12 @@ import { BrowserWindow, session } from "electron";
import type {
CreateScheduledPostInput,
CreateSocialAccountInput,
LaunchSocialLoginInput,
SetScheduledPostStateInput,
} from "../../shared/commands";
import { SOCIAL_PLATFORM, type SocialPlatform } from "../../shared/domain";
import { APPROVAL_STATE, RISK_LEVEL, SOCIAL_PLATFORM, type RiskLevel, type SocialPlatform } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { ApprovalService } from "./approval-service";
import { AuditService } from "./audit-service";
const PLATFORM_LOGIN_URL: Record<SocialPlatform, string> = {
@@ -23,21 +25,75 @@ const ALLOWED_HOSTS: Record<SocialPlatform, string[]> = {
[SOCIAL_PLATFORM.INSTAGRAM]: ["instagram.com", "www.instagram.com"],
};
const SOCIAL_GRANT_ACTION = {
LOGIN: "login",
DRAFT: "draft",
APPROVE: "approve",
} as const;
type SocialGrantAction = typeof SOCIAL_GRANT_ACTION[keyof typeof SOCIAL_GRANT_ACTION];
const STATE_GRANT_ACTION: Record<SetScheduledPostStateInput["state"], SocialGrantAction> = {
draft: SOCIAL_GRANT_ACTION.DRAFT,
scheduled: SOCIAL_GRANT_ACTION.APPROVE,
published: SOCIAL_GRANT_ACTION.APPROVE,
cancelled: SOCIAL_GRANT_ACTION.APPROVE,
};
const GRANT_SCOPE = {
actions: [SOCIAL_GRANT_ACTION.LOGIN, SOCIAL_GRANT_ACTION.DRAFT, SOCIAL_GRANT_ACTION.APPROVE],
riskCeiling: RISK_LEVEL.MEDIUM,
} as const;
const ACTION_REQUIRED_RISK: Record<SocialGrantAction, RiskLevel> = {
login: RISK_LEVEL.LOW,
draft: RISK_LEVEL.LOW,
approve: RISK_LEVEL.MEDIUM,
};
const RISK_WEIGHT: Record<RiskLevel, number> = {
low: 1,
medium: 2,
high: 3,
};
interface SocialAccountRow {
id: string;
org_id: string;
platform: SocialPlatform;
handle: string;
authorized_employee_id: string | null;
}
interface ScheduledPostRow {
id: string;
org_id: string;
social_account_id: string;
body_md: string;
state: SetScheduledPostStateInput["state"];
approval_id: string | null;
created_by_employee_id: string | null;
}
interface GrantScope {
actions: string[];
riskCeiling: RiskLevel;
}
export class SocialService {
private readonly audit: AuditService;
private readonly approvals: ApprovalService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
this.approvals = new ApprovalService(db);
}
createScheduledPost(input: CreateScheduledPostInput): string {
const account = this.db.prepare("select id from social_accounts where id = ? and org_id = ?")
.get(input.socialAccountId, input.orgId) as { id: string } | undefined;
if (!account) throw new Error("Social account was not found.");
const body = input.bodyMd.trim();
if (!body) throw new Error("Post body is required.");
const scheduledFor = input.scheduledFor.trim();
if (!scheduledFor) throw new Error("Scheduled date is required.");
this.requireAccount(input.orgId, input.socialAccountId);
this.requireGrant(input.orgId, input.socialAccountId, input.createdByEmployeeId, SOCIAL_GRANT_ACTION.DRAFT);
const body = this.requireText(input.bodyMd, "Post body is required.");
const scheduledFor = this.requireSchedule(input.scheduledFor);
const id = createId("spost");
const now = new Date().toISOString();
this.db.prepare(`
@@ -51,7 +107,7 @@ export class SocialService {
input.socialAccountId,
body,
scheduledFor,
input.state,
"draft",
null,
input.createdByEmployeeId,
now,
@@ -61,39 +117,47 @@ export class SocialService {
orgId: input.orgId,
entityType: "scheduled_post",
entityId: id,
actorType: input.createdByEmployeeId ? "employee" : "system",
actorType: "employee",
actorId: input.createdByEmployeeId,
action: "scheduled",
payload: { socialAccountId: input.socialAccountId, scheduledFor, state: input.state },
action: "drafted",
payload: { socialAccountId: input.socialAccountId, scheduledFor, state: "draft" },
});
return id;
}
setScheduledPostState(input: SetScheduledPostStateInput): void {
const post = this.requireScheduledPost(input.orgId, input.scheduledPostId);
this.requireGrant(input.orgId, post.social_account_id, input.employeeId, STATE_GRANT_ACTION[input.state]);
this.assertStateTransition(post, input.state);
const approvalId = this.resolveApprovalForState(input, post);
const now = new Date().toISOString();
const result = this.db.prepare(`
update scheduled_posts set state = ?, updated_at = ?
update scheduled_posts set state = ?, approval_id = ?, updated_at = ?
where id = ? and org_id = ?
`).run(input.state, now, input.scheduledPostId, input.orgId);
`).run(input.state, approvalId, now, input.scheduledPostId, input.orgId);
if (result.changes === 0) throw new Error("Scheduled post was not found.");
this.audit.record({
orgId: input.orgId,
entityType: "scheduled_post",
entityId: input.scheduledPostId,
actorType: "employee",
actorId: null,
actorId: input.employeeId,
action: `state_${input.state}`,
payload: { state: input.state },
payload: { state: input.state, approvalId },
});
}
createAccount(input: CreateSocialAccountInput): string {
this.assertOrg(input.orgId);
this.assertPlatform(input.platform);
this.assertEmployee(input.orgId, input.createdByEmployeeId, "Creating employee was not found.");
const handle = this.requireText(input.handle, "Social handle is required.");
const displayName = this.requireText(input.displayName, "Social display name is required.");
this.assertUniqueAccount(input.orgId, input.platform, handle);
const id = createId("soc");
const now = new Date().toISOString();
if (input.authorizedEmployeeId) {
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?")
.get(input.authorizedEmployeeId, input.orgId);
if (!employee) throw new Error("Authorized employee was not found.");
this.assertEmployee(input.orgId, input.authorizedEmployeeId, "Authorized employee was not found.");
}
this.db.prepare(`
insert into social_accounts(id, org_id, platform, handle, display_name, authorized_employee_id, created_at)
@@ -102,8 +166,8 @@ export class SocialService {
id,
input.orgId,
input.platform,
input.handle.trim(),
input.displayName.trim(),
handle,
displayName,
input.authorizedEmployeeId,
now,
);
@@ -117,7 +181,7 @@ export class SocialService {
id,
input.authorizedEmployeeId,
"operator",
JSON.stringify({ actions: ["login", "draft", "approve"] }),
JSON.stringify(GRANT_SCOPE),
now,
);
}
@@ -125,18 +189,17 @@ export class SocialService {
orgId: input.orgId,
entityType: "social_account",
entityId: id,
actorType: "system",
actorId: null,
actorType: "employee",
actorId: input.createdByEmployeeId,
action: "created",
payload: { platform: input.platform, handle: input.handle.trim() },
payload: { platform: input.platform, handle },
});
return id;
}
launchLoginWindow(accountId: string): void {
const account = this.db.prepare("select * from social_accounts where id = ?")
.get(accountId) as { id: string; org_id: string; platform: SocialPlatform; handle: string } | undefined;
if (!account) throw new Error("Social account was not found.");
launchLoginWindow(input: LaunchSocialLoginInput): void {
const account = this.requireAccount(input.orgId, input.socialAccountId);
this.requireGrant(input.orgId, input.socialAccountId, input.employeeId, SOCIAL_GRANT_ACTION.LOGIN);
const partition = `persist:agentcompany-social-${account.id}`;
const socialSession = session.fromPartition(partition);
socialSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
@@ -163,21 +226,151 @@ export class SocialService {
orgId: account.org_id,
entityType: "social_account",
entityId: account.id,
actorType: "system",
actorId: null,
actorType: "employee",
actorId: input.employeeId,
action: "login_window_opened",
payload: { platform: account.platform },
});
}
private attachNavigationPolicy(win: BrowserWindow, platform: SocialPlatform): void {
const allowed = ALLOWED_HOSTS[platform];
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
win.webContents.on("will-navigate", (event, url) => {
const host = new URL(url).hostname.toLowerCase();
if (!allowed.some((entry) => host === entry || host.endsWith(`.${entry}`))) {
event.preventDefault();
}
const guard = (event: Electron.Event, url: string) => {
if (!this.isAllowedNavigation(platform, url)) event.preventDefault();
};
win.webContents.on("will-navigate", guard);
win.webContents.on("will-redirect", guard);
}
private requireAccount(orgId: string, accountId: string): SocialAccountRow {
const account = this.db.prepare("select * from social_accounts where id = ? and org_id = ?")
.get(accountId, orgId) as SocialAccountRow | undefined;
if (!account) throw new Error("Social account was not found.");
return account;
}
private requireScheduledPost(orgId: string, scheduledPostId: string): ScheduledPostRow {
const post = this.db.prepare("select * from scheduled_posts where id = ? and org_id = ?")
.get(scheduledPostId, orgId) as ScheduledPostRow | undefined;
if (!post) throw new Error("Scheduled post was not found.");
return post;
}
private requireGrant(orgId: string, socialAccountId: string, employeeId: string, action: SocialGrantAction): void {
this.assertEmployee(orgId, employeeId, "Social account grant is required.");
const row = this.db.prepare(`
select scope_json from social_account_grants
where org_id = ? and social_account_id = ? and employee_id = ?
`).get(orgId, socialAccountId, employeeId) as { scope_json: string } | undefined;
if (!row) throw new Error("Social account grant is required.");
const scope = parseGrantScope(row.scope_json);
if (!scope.actions.includes(action)) throw new Error("Social account grant is required.");
if (RISK_WEIGHT[scope.riskCeiling] < RISK_WEIGHT[ACTION_REQUIRED_RISK[action]]) {
throw new Error("Social account grant risk ceiling is insufficient.");
}
}
private resolveApprovalForState(input: SetScheduledPostStateInput, post: ScheduledPostRow): string | null {
if (input.state === "scheduled") {
return post.approval_id ?? this.createPublishApproval(input.orgId, input.employeeId, post.body_md);
}
if (input.state === "published") {
this.assertApprovalApproved(input.orgId, post.approval_id);
return post.approval_id;
}
return post.approval_id;
}
private createPublishApproval(orgId: string, employeeId: string, body: string): string {
return this.approvals.create({
orgId,
kind: "social_publish",
title: "Approve social post",
details: body,
risk: RISK_LEVEL.MEDIUM,
requestedByType: "employee",
requestedById: employeeId,
});
}
private assertApprovalApproved(orgId: string, approvalId: string | null): void {
if (!approvalId) throw new Error("Approved social approval is required.");
const approval = this.db.prepare("select state from approvals where id = ? and org_id = ? and kind = ?")
.get(approvalId, orgId, "social_publish") as { state: string } | undefined;
if (!approval || approval.state !== APPROVAL_STATE.APPROVED) {
throw new Error("Approved social approval is required.");
}
}
private assertStateTransition(post: ScheduledPostRow, nextState: SetScheduledPostStateInput["state"]): void {
if (post.state === nextState) throw new Error("Scheduled post already has this state.");
const allowed = post.state === "draft"
? ["scheduled", "cancelled"]
: post.state === "scheduled"
? ["published", "cancelled"]
: [];
if (!allowed.includes(nextState)) throw new Error("Scheduled post state transition is invalid.");
}
private assertOrg(orgId: string): void {
const org = this.db.prepare("select id from organizations where id = ?").get(orgId);
if (!org) throw new Error("Organization was not found.");
}
private assertEmployee(orgId: string, employeeId: string, message: string): void {
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
if (!employee) throw new Error(message);
}
private assertPlatform(platform: SocialPlatform): void {
if (!Object.values(SOCIAL_PLATFORM).includes(platform)) throw new Error("Social platform is unsupported.");
}
private assertUniqueAccount(orgId: string, platform: SocialPlatform, handle: string): void {
const account = this.db.prepare("select id from social_accounts where org_id = ? and platform = ? and lower(handle) = lower(?)")
.get(orgId, platform, handle);
if (account) throw new Error("Social account already exists.");
}
private requireText(value: string, message: string): string {
const trimmed = value.trim();
if (!trimmed) throw new Error(message);
return trimmed;
}
private requireSchedule(value: string): string {
const scheduledFor = this.requireText(value, "Scheduled date is required.");
const date = new Date(scheduledFor);
if (Number.isNaN(date.getTime())) throw new Error("Scheduled date is invalid.");
return scheduledFor;
}
private isAllowedNavigation(platform: SocialPlatform, url: string): boolean {
const parsed = new URL(url);
if (parsed.protocol !== "https:") return false;
const host = parsed.hostname.toLowerCase();
return ALLOWED_HOSTS[platform].some((entry) => host === entry || host.endsWith(`.${entry}`));
}
}
function parseGrantScope(raw: string): GrantScope {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Social account grant scope is invalid.");
}
if (!isRecord(parsed) || !Array.isArray(parsed.actions) || !parsed.actions.every((action) => typeof action === "string")) {
throw new Error("Social account grant scope is invalid.");
}
if (!isRiskLevel(parsed.riskCeiling)) throw new Error("Social account grant scope is invalid.");
return { actions: parsed.actions, riskCeiling: parsed.riskCeiling };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isRiskLevel(value: unknown): value is RiskLevel {
return Object.values(RISK_LEVEL).includes(value as RiskLevel);
}
@@ -4,10 +4,12 @@ import { Dialog } from "../shared/Dialog";
import {
SOCIAL_PLATFORM,
type EmployeeRecord,
type ApprovalState,
type ScheduledPostRecord,
type SocialAccountRecord,
type SocialPlatform,
} from "../../../shared/domain";
import { APPROVAL_STATE } from "../../../shared/domain";
import { SocialCalendar } from "./social/SocialCalendar";
import { PlatformIcon } from "./social/PlatformIcon";
import type { PageProps } from "./types";
@@ -26,6 +28,13 @@ export function SocialPage({ snapshot, actions }: PageProps) {
const employee = requireActiveEmployee(snapshot);
const accounts = useMemo(() => snapshot.socialAccounts.filter((account) => account.orgId === orgId), [snapshot.socialAccounts, orgId]);
const scheduledPosts = useMemo(() => snapshot.scheduledPosts.filter((post) => post.orgId === orgId), [snapshot.scheduledPosts, orgId]);
const approvalStateById = useMemo(() => {
const states = new Map<string, ApprovalState>();
for (const approval of snapshot.approvals) {
if (approval.orgId === orgId) states.set(approval.id, approval.state);
}
return states;
}, [snapshot.approvals, orgId]);
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
const [connectOpen, setConnectOpen] = useState(false);
@@ -33,6 +42,7 @@ export function SocialPage({ snapshot, actions }: PageProps) {
const scheduled = scheduledPosts.filter((post) => post.state === "scheduled");
const drafts = scheduledPosts.filter((post) => post.state === "draft");
const history = scheduledPosts.filter((post) => post.state === "published" || post.state === "cancelled");
const operator = accounts.find((account) => account.authorizedEmployeeId)?.authorizedEmployeeId ?? null;
const operatorName = operator ? employees.find((item) => item.id === operator)?.displayName ?? null : null;
@@ -44,6 +54,7 @@ export function SocialPage({ snapshot, actions }: PageProps) {
platform: value(form, "platform") as SocialPlatform,
handle: value(form, "handle"),
displayName: value(form, "displayName"),
createdByEmployeeId: employee.id,
authorizedEmployeeId: optional(form, "authorizedEmployeeId"),
})).then((result) => { if (result) setConnectOpen(false); });
};
@@ -56,13 +67,12 @@ export function SocialPage({ snapshot, actions }: PageProps) {
socialAccountId: value(form, "socialAccountId"),
bodyMd: value(form, "bodyMd"),
scheduledFor: value(form, "scheduledFor"),
state: value(form, "state") === "scheduled" ? "scheduled" : "draft",
createdByEmployeeId: employee.id,
})).then((result) => { if (result) setDraftOpen(false); });
};
const updatePostState = (post: ScheduledPostRecord, state: "draft" | "scheduled" | "published" | "cancelled") =>
void actions.run(() => window.agentCompany.setScheduledPostState({ orgId, scheduledPostId: post.id, state }));
void actions.run(() => window.agentCompany.setScheduledPostState({ orgId, scheduledPostId: post.id, state, employeeId: employee.id }));
return (
<div className="page">
@@ -91,7 +101,8 @@ export function SocialPage({ snapshot, actions }: PageProps) {
key={account.id}
account={account}
authorizedName={account.authorizedEmployeeId ? employees.find((item) => item.id === account.authorizedEmployeeId)?.displayName ?? null : null}
onSignIn={() => void actions.run(() => window.agentCompany.launchSocialLogin({ socialAccountId: account.id }))}
canSignIn={account.authorizedEmployeeId === employee.id}
onSignIn={() => void actions.run(() => window.agentCompany.launchSocialLogin({ orgId, socialAccountId: account.id, employeeId: employee.id }))}
/>
))}
</div>
@@ -107,6 +118,7 @@ export function SocialPage({ snapshot, actions }: PageProps) {
post={post}
account={requireAccount(accounts, post.socialAccountId)}
authorName={authorName(post, employees)}
approvalState={approvalState(post, approvalStateById)}
onCancel={() => updatePostState(post, "cancelled")}
onPublish={() => updatePostState(post, "published")}
/>
@@ -118,10 +130,23 @@ export function SocialPage({ snapshot, actions }: PageProps) {
post={post}
account={requireAccount(accounts, post.socialAccountId)}
authorName={authorName(post, employees)}
approvalState={approvalState(post, approvalStateById)}
onCancel={() => updatePostState(post, "cancelled")}
onPublish={() => updatePostState(post, "scheduled")}
/>
))}
{history.length > 0 && <div className="section-label" style={{ margin: "24px 0 10px" }}>History</div>}
{history.map((post) => (
<PostRow
key={post.id}
post={post}
account={requireAccount(accounts, post.socialAccountId)}
authorName={authorName(post, employees)}
approvalState={approvalState(post, approvalStateById)}
onCancel={() => updatePostState(post, "cancelled")}
onPublish={() => updatePostState(post, "published")}
/>
))}
</div>
<SocialCalendar posts={scheduledPosts} accounts={accounts} />
@@ -156,12 +181,6 @@ export function SocialPage({ snapshot, actions }: PageProps) {
</Field>
<Field label="Body"><textarea className="input" name="bodyMd" required /></Field>
<Field label="Scheduled for"><input className="input" name="scheduledFor" type="datetime-local" required /></Field>
<Field label="State">
<select className="input" name="state" defaultValue="scheduled">
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
</select>
</Field>
</Dialog>
</div>
);
@@ -170,10 +189,12 @@ export function SocialPage({ snapshot, actions }: PageProps) {
function AccountCard({
account,
authorizedName,
canSignIn,
onSignIn,
}: {
account: SocialAccountRecord;
authorizedName: string | null;
canSignIn: boolean;
onSignIn: () => void;
}) {
return (
@@ -196,11 +217,12 @@ function AccountCard({
<div style={{ fontSize: 11, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{account.displayName}</div>
</div>
</div>
{authorizedName ? (
{authorizedName && (
<div className="row" style={{ fontSize: 11, color: "var(--text-3)", gap: 6 }}>
<span style={{ width: 6, height: 6, borderRadius: 3, background: "var(--green)" }} /> Authorized to {authorizedName}
</div>
) : (
)}
{canSignIn && (
<Button size="small" onClick={onSignIn}>Sign in</Button>
)}
</div>
@@ -211,16 +233,19 @@ function PostRow({
post,
account,
authorName,
approvalState,
onCancel,
onPublish,
}: {
post: ScheduledPostRecord;
account: SocialAccountRecord;
authorName: string;
approvalState: ApprovalState | null;
onCancel: () => void;
onPublish: () => void;
}) {
const isDraft = post.state === "draft";
const canPublish = post.state === "scheduled" && approvalState === APPROVAL_STATE.APPROVED;
return (
<div
style={{
@@ -244,7 +269,9 @@ function PostRow({
{formatSchedule(post.scheduledFor)}
</span>
)}
{post.approvalId && <Pill tone="amber" dot>Awaiting approval</Pill>}
{approvalState === APPROVAL_STATE.PENDING && <Pill tone="amber" dot>Awaiting approval</Pill>}
{approvalState === APPROVAL_STATE.APPROVED && <Pill tone="green" dot>Approved</Pill>}
{approvalState === APPROVAL_STATE.DECLINED && <Pill tone="red">Declined</Pill>}
{post.state === "published" && <Pill tone="green" dot>Published</Pill>}
{post.state === "cancelled" && <Pill tone="red">Cancelled</Pill>}
</div>
@@ -255,7 +282,7 @@ function PostRow({
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{post.state === "scheduled" && <Button size="small" tone="primary" onClick={onPublish}>Publish</Button>}
{post.state === "scheduled" && <Button size="small" tone="primary" disabled={!canPublish} onClick={onPublish}>Publish</Button>}
{post.state === "draft" && <Button size="small" tone="primary" onClick={onPublish}>Schedule</Button>}
{post.state !== "cancelled" && post.state !== "published" && (
<Button size="small" tone="ghost" onClick={onCancel}>Cancel</Button>
@@ -266,6 +293,13 @@ function PostRow({
);
}
function approvalState(post: ScheduledPostRecord, states: Map<string, ApprovalState>): ApprovalState | null {
if (!post.approvalId) return null;
const state = states.get(post.approvalId);
if (!state) throw new Error("Scheduled post references missing approval.");
return state;
}
function requireAccount(accounts: SocialAccountRecord[], id: string): SocialAccountRecord {
const account = accounts.find((item) => item.id === id);
if (!account) throw new Error("Scheduled post references missing account.");
+5 -2
View File
@@ -148,11 +148,14 @@ export interface CreateSocialAccountInput {
platform: SocialPlatform;
handle: string;
displayName: string;
createdByEmployeeId: string;
authorizedEmployeeId: string | null;
}
export interface LaunchSocialLoginInput {
orgId: string;
socialAccountId: string;
employeeId: string;
}
export interface ImportFileInput {
@@ -169,14 +172,14 @@ export interface CreateScheduledPostInput {
socialAccountId: string;
bodyMd: string;
scheduledFor: string;
state: "draft" | "scheduled";
createdByEmployeeId: string | null;
createdByEmployeeId: string;
}
export interface SetScheduledPostStateInput {
orgId: string;
scheduledPostId: string;
state: "draft" | "scheduled" | "published" | "cancelled";
employeeId: string;
}
export interface SetFilePinnedInput {
@@ -61,6 +61,30 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("Release channel opened.")).toBeVisible();
await page.getByRole("button", { name: "Social" }).click();
await page.getByRole("button", { name: "Connect account" }).click();
await page.getByLabel("Handle").fill("@acme");
await page.getByLabel("Display name").fill("Acme Social");
await page.locator(".modal").getByRole("button", { name: "Connect" }).click();
await expect(page.getByText("@acme").first()).toBeVisible();
await page.getByRole("button", { name: "Draft post" }).click();
await page.getByLabel("Body").fill("Pilot starts Friday.");
await page.getByLabel("Scheduled for").fill("2027-01-15T09:30");
await page.locator(".modal").getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Draft").first()).toBeVisible();
await page.getByRole("button", { name: "Schedule" }).click();
await expect(page.getByText("Awaiting approval").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Publish" })).toBeDisabled();
await page.getByRole("button", { name: "Approvals" }).click();
await expect(page.getByText("Approve social post").first()).toBeVisible();
await page.getByLabel("Filter risk").selectOption("medium");
await page.getByRole("button", { name: /^Approve$/ }).first().click();
await page.getByRole("button", { name: "Social" }).click();
await expect(page.getByText("Approved").first()).toBeVisible();
await page.getByRole("button", { name: "Publish" }).click();
await expect(page.getByText("Published").first()).toBeVisible();
await verifyPagesDoNotOverflow(page);
} finally {
await app.close();
@@ -5,15 +5,13 @@ import { randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { FileService } from "../../src/main/services/file-service";
import { OrganizationService } from "../../src/main/services/organization-service";
import { SnapshotService } from "../../src/main/services/snapshot-service";
import { SkillService } from "../../src/main/services/skill-service";
import { ChatService } from "../../src/main/services/chat-service";
import { MigrationService } from "../../src/main/services/migration-service";
import { OnboardingService } from "../../src/main/services/onboarding-service";
import { SocialService } from "../../src/main/services/social-service";
import { RISK_LEVEL, SOCIAL_PLATFORM, TASK_STATUS } from "../../src/shared/domain";
import { RISK_LEVEL, TASK_STATUS } from "../../src/shared/domain";
import { createId } from "../../src/shared/ids";
function createConnection(): DatabaseConnection {
@@ -398,91 +396,6 @@ describe("domain services", () => {
connection.close();
});
test("creates social accounts with employee grants", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const social = new SocialService(connection.db);
const snapshot = new SnapshotService(connection.db);
let orgId = "";
let employeeId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
employeeId = (connection.db.prepare("select id from employees where org_id = ?")
.get(orgId) as { id: string }).id;
social.createAccount({
orgId,
platform: SOCIAL_PLATFORM.X,
handle: "@acme",
displayName: "Acme",
authorizedEmployeeId: employeeId,
});
});
const state = snapshot.load();
expect(state.socialAccounts).toHaveLength(1);
expect(state.socialAccountGrants[0]?.employeeId).toBe(employeeId);
connection.close();
});
test("pins files and schedules social posts", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const social = new SocialService(connection.db);
const files = new FileService(connection.db, join(tmpdir(), `artifacts-${randomUUID()}`));
const snapshot = new SnapshotService(connection.db);
let orgId = "";
let socialAccountId = "";
let fileId = "";
let employeeId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
socialAccountId = social.createAccount({
orgId,
platform: SOCIAL_PLATFORM.X,
handle: "@acme",
displayName: "Acme",
authorizedEmployeeId: employeeId,
});
fileId = createId("fil");
connection.db.prepare(`
insert into file_objects(
id, org_id, name, kind, size_bytes, sha256, storage_path,
owner_type, owner_id, space_id, pinned, created_at, updated_at
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(fileId, orgId, "launch-thread.md", "md", 256, "abc", "artifacts/launch-thread.md", "employee", employeeId, null, 0, new Date().toISOString(), new Date().toISOString());
files.setPinned(orgId, fileId, true);
social.createScheduledPost({
orgId,
socialAccountId,
bodyMd: "Pilot starts Friday.",
scheduledFor: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
state: "scheduled",
createdByEmployeeId: employeeId,
});
});
const state = snapshot.load();
expect(state.files[0]?.pinned).toBe(true);
expect(state.scheduledPosts).toHaveLength(1);
expect(state.scheduledPosts[0]?.state).toBe("scheduled");
expect(state.scheduledPosts[0]?.socialAccountId).toBe(socialAccountId);
connection.close();
});
test("bootstraps the original onboarding chain with company, CEO, goal, project, task, and run", () => {
const connection = createConnection();
const onboarding = new OnboardingService({
@@ -0,0 +1,260 @@
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { ApprovalService } from "../../src/main/services/approval-service";
import { FileService } from "../../src/main/services/file-service";
import { OrganizationService } from "../../src/main/services/organization-service";
import { SnapshotService } from "../../src/main/services/snapshot-service";
import { SocialService } from "../../src/main/services/social-service";
import { APPROVAL_STATE, SOCIAL_PLATFORM } from "../../src/shared/domain";
import { createId } from "../../src/shared/ids";
function createConnection(): DatabaseConnection {
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-social-${randomUUID()}.db`));
}
function createOrganization(organization: OrganizationService): string {
return organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
}
function firstEmployeeId(connection: DatabaseConnection, orgId: string): string {
return (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
}
function createEmployee(connection: DatabaseConnection, orgId: string, displayName: string): string {
const id = createId("emp");
connection.db.prepare(`
insert into employees(id, org_id, display_name, role, created_at)
values (?, ?, ?, ?, ?)
`).run(id, orgId, displayName, "operator", new Date().toISOString());
return id;
}
describe("social service", () => {
test("creates social accounts with scoped employee grants", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const social = new SocialService(connection.db);
const snapshot = new SnapshotService(connection.db);
let employeeId = "";
connection.transaction(() => {
const orgId = createOrganization(organization);
employeeId = firstEmployeeId(connection, orgId);
social.createAccount({
orgId,
platform: SOCIAL_PLATFORM.X,
handle: "@acme",
displayName: "Acme",
createdByEmployeeId: employeeId,
authorizedEmployeeId: employeeId,
});
});
const state = snapshot.load();
const grantScope = JSON.parse(state.socialAccountGrants[0]!.scopeJson) as { actions: string[]; riskCeiling: string };
expect(state.socialAccounts).toHaveLength(1);
expect(state.socialAccountGrants[0]?.employeeId).toBe(employeeId);
expect(state.auditEvents.find((event) => event.entityType === "social_account")?.actorId).toBe(employeeId);
expect(grantScope.actions).toEqual(["login", "draft", "approve"]);
expect(grantScope.riskCeiling).toBe("medium");
connection.close();
});
test("pins files and schedules social posts behind approval", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const social = new SocialService(connection.db);
const approvals = new ApprovalService(connection.db);
const files = new FileService(connection.db, join(tmpdir(), `artifacts-${randomUUID()}`));
const snapshot = new SnapshotService(connection.db);
let orgId = "";
let socialAccountId = "";
let postId = "";
let employeeId = "";
connection.transaction(() => {
orgId = createOrganization(organization);
employeeId = firstEmployeeId(connection, orgId);
socialAccountId = social.createAccount({
orgId,
platform: SOCIAL_PLATFORM.X,
handle: "@acme",
displayName: "Acme",
createdByEmployeeId: employeeId,
authorizedEmployeeId: employeeId,
});
const fileId = createId("fil");
connection.db.prepare(`
insert into file_objects(
id, org_id, name, kind, size_bytes, sha256, storage_path,
owner_type, owner_id, space_id, pinned, created_at, updated_at
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(fileId, orgId, "launch-thread.md", "md", 256, "abc", "artifacts/launch-thread.md", "employee", employeeId, null, 0, new Date().toISOString(), new Date().toISOString());
files.setPinned(orgId, fileId, true);
postId = social.createScheduledPost({
orgId,
socialAccountId,
bodyMd: "Pilot starts Friday.",
scheduledFor: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
createdByEmployeeId: employeeId,
});
});
connection.transaction(() => {
social.setScheduledPostState({ orgId, scheduledPostId: postId, state: "scheduled", employeeId });
});
expect(() => connection.transaction(() => social.setScheduledPostState({
orgId,
scheduledPostId: postId,
state: "published",
employeeId,
}))).toThrow("Approved social approval is required.");
connection.transaction(() => {
const approvalId = (connection.db.prepare("select approval_id from scheduled_posts where id = ?")
.get(postId) as { approval_id: string }).approval_id;
approvals.decide({ orgId, approvalId, state: APPROVAL_STATE.APPROVED, decidedByEmployeeId: employeeId });
social.setScheduledPostState({ orgId, scheduledPostId: postId, state: "published", employeeId });
});
const state = snapshot.load();
expect(state.files[0]?.pinned).toBe(true);
expect(state.approvals[0]?.kind).toBe("social_publish");
expect(state.approvals[0]?.state).toBe(APPROVAL_STATE.APPROVED);
expect(state.scheduledPosts[0]?.state).toBe("published");
expect(state.scheduledPosts[0]?.socialAccountId).toBe(socialAccountId);
expect(state.scheduledPosts[0]?.approvalId).toBe(state.approvals[0]?.id);
connection.close();
});
test("rejects social actions without account grants", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const social = new SocialService(connection.db);
let orgId = "";
let authorizedEmployeeId = "";
let outsiderId = "";
let socialAccountId = "";
let postId = "";
connection.transaction(() => {
orgId = createOrganization(organization);
authorizedEmployeeId = firstEmployeeId(connection, orgId);
outsiderId = createEmployee(connection, orgId, "Riley Chen");
socialAccountId = social.createAccount({
orgId,
platform: SOCIAL_PLATFORM.LINKEDIN,
handle: "acme",
displayName: "Acme",
createdByEmployeeId: authorizedEmployeeId,
authorizedEmployeeId,
});
postId = social.createScheduledPost({
orgId,
socialAccountId,
bodyMd: "Launch update.",
scheduledFor: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
createdByEmployeeId: authorizedEmployeeId,
});
});
expect(() => connection.transaction(() => social.createScheduledPost({
orgId,
socialAccountId,
bodyMd: "Unauthorized update.",
scheduledFor: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
createdByEmployeeId: outsiderId,
}))).toThrow("Social account grant is required.");
expect(() => connection.transaction(() => social.setScheduledPostState({
orgId,
scheduledPostId: postId,
state: "cancelled",
employeeId: outsiderId,
}))).toThrow("Social account grant is required.");
expect(() => social.launchLoginWindow({ orgId, socialAccountId, employeeId: outsiderId })).toThrow("Social account grant is required.");
connection.close();
});
test("rejects publish governance when grant risk ceiling is too low", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const social = new SocialService(connection.db);
let orgId = "";
let employeeId = "";
let socialAccountId = "";
let postId = "";
connection.transaction(() => {
orgId = createOrganization(organization);
employeeId = firstEmployeeId(connection, orgId);
socialAccountId = social.createAccount({
orgId,
platform: SOCIAL_PLATFORM.YOUTUBE,
handle: "acme",
displayName: "Acme",
createdByEmployeeId: employeeId,
authorizedEmployeeId: employeeId,
});
connection.db.prepare("update social_account_grants set scope_json = ? where social_account_id = ?")
.run(JSON.stringify({ actions: ["login", "draft", "approve"], riskCeiling: "low" }), socialAccountId);
postId = social.createScheduledPost({
orgId,
socialAccountId,
bodyMd: "Launch update.",
scheduledFor: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
createdByEmployeeId: employeeId,
});
});
expect(() => connection.transaction(() => social.setScheduledPostState({
orgId,
scheduledPostId: postId,
state: "scheduled",
employeeId,
}))).toThrow("Social account grant risk ceiling is insufficient.");
connection.close();
});
test("rejects approval decisions from employees outside the approval organization", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const approvals = new ApprovalService(connection.db);
let firstOrgId = "";
let secondOrgId = "";
let approvalId = "";
let secondEmployeeId = "";
connection.transaction(() => {
firstOrgId = createOrganization(organization);
secondOrgId = organization.createOrganization({ organizationName: "Beta Ops", employeeName: "Riley Chen", mission: "B", goal: "B" });
secondEmployeeId = firstEmployeeId(connection, secondOrgId);
approvalId = approvals.create({
orgId: firstOrgId,
kind: "social_publish",
title: "Approve social post",
details: "Pilot starts Friday.",
risk: "medium",
requestedByType: "employee",
requestedById: firstEmployeeId(connection, firstOrgId),
});
});
expect(() => connection.transaction(() => approvals.decide({
orgId: firstOrgId,
approvalId,
state: APPROVAL_STATE.APPROVED,
decidedByEmployeeId: secondEmployeeId,
}))).toThrow("Deciding employee was not found.");
connection.close();
});
});