diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index bc3d0e85..8c1db72c 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -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)); diff --git a/AgentCompany-New/src/main/database/migrations/009-social-governance.ts b/AgentCompany-New/src/main/database/migrations/009-social-governance.ts new file mode 100644 index 00000000..e500d793 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/009-social-governance.ts @@ -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')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 7f8fc62e..805982f5 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -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; diff --git a/AgentCompany-New/src/main/services/approval-service.ts b/AgentCompany-New/src/main/services/approval-service.ts index 7c1c082b..43b0166b 100644 --- a/AgentCompany-New/src/main/services/approval-service.ts +++ b/AgentCompany-New/src/main/services/approval-service.ts @@ -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); + } } diff --git a/AgentCompany-New/src/main/services/social-service.ts b/AgentCompany-New/src/main/services/social-service.ts index f1c09a27..ea0c0439 100644 --- a/AgentCompany-New/src/main/services/social-service.ts +++ b/AgentCompany-New/src/main/services/social-service.ts @@ -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 = { @@ -23,21 +25,75 @@ const ALLOWED_HOSTS: Record = { [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 = { + 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 = { + login: RISK_LEVEL.LOW, + draft: RISK_LEVEL.LOW, + approve: RISK_LEVEL.MEDIUM, +}; + +const RISK_WEIGHT: Record = { + 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 { + return typeof value === "object" && value !== null; +} + +function isRiskLevel(value: unknown): value is RiskLevel { + return Object.values(RISK_LEVEL).includes(value as RiskLevel); } diff --git a/AgentCompany-New/src/renderer/src/pages/SocialPage.tsx b/AgentCompany-New/src/renderer/src/pages/SocialPage.tsx index e060ed71..3d46eda9 100644 --- a/AgentCompany-New/src/renderer/src/pages/SocialPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/SocialPage.tsx @@ -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(); + 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 (
@@ -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 }))} /> ))}
@@ -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 &&
History
} + {history.map((post) => ( + updatePostState(post, "cancelled")} + onPublish={() => updatePostState(post, "published")} + /> + ))} @@ -156,12 +181,6 @@ export function SocialPage({ snapshot, actions }: PageProps) {