The legacy application - its src, tests, landing site, promo assets, docs, release tooling, CI workflows, and root configs - leaves the tree; it stays reachable in history. Everything under AgentCompany-New moves up unchanged: same package name, so the production userData/database path is untouched, and e2e/build scripts already resolved paths relative to the project. The developer cred.txt stays gitignored at its new location. Full verify:ci passes from the new root.
483 lines
19 KiB
TypeScript
483 lines
19 KiB
TypeScript
import type { DatabaseSync } from "node:sqlite";
|
|
import type {
|
|
CreateScheduledPostInput,
|
|
CreateSocialAccountInput,
|
|
LaunchSocialLoginInput,
|
|
SetScheduledPostStateInput,
|
|
} from "../../shared/commands";
|
|
import { APPROVAL_STATE, RISK_LEVEL, ROUTE_TO_TYPE, SOCIAL_PLATFORM, type RiskLevel, type SocialPlatform } from "../../shared/domain";
|
|
import { createId } from "../../shared/ids";
|
|
import { ApprovalService } from "./approval-service";
|
|
import { AuditService } from "./audit-service";
|
|
import { SocialAuditService } from "./social-audit-service";
|
|
import { openSocialLoginWindow, type SocialLoginWindowLauncher } from "./social-login-window";
|
|
import {
|
|
SOCIAL_BROWSER_ACTION,
|
|
SocialBrowserPolicyService,
|
|
type BrowserActionPolicyRow,
|
|
type SocialApprovalRouteRow,
|
|
type SocialBrowserAction,
|
|
} from "./social-browser-policy-service";
|
|
|
|
const PLATFORM_LOGIN_URL: Record<SocialPlatform, string> = {
|
|
[SOCIAL_PLATFORM.X]: "https://x.com/i/flow/login",
|
|
[SOCIAL_PLATFORM.LINKEDIN]: "https://www.linkedin.com/login",
|
|
[SOCIAL_PLATFORM.YOUTUBE]: "https://accounts.google.com/",
|
|
[SOCIAL_PLATFORM.INSTAGRAM]: "https://www.instagram.com/accounts/login/",
|
|
};
|
|
|
|
const STATE_GRANT_ACTION: Record<SetScheduledPostStateInput["state"], SocialBrowserAction> = {
|
|
draft: SOCIAL_BROWSER_ACTION.DRAFT,
|
|
scheduled: SOCIAL_BROWSER_ACTION.APPROVE,
|
|
published: SOCIAL_BROWSER_ACTION.APPROVE,
|
|
cancelled: SOCIAL_BROWSER_ACTION.APPROVE,
|
|
};
|
|
|
|
const GRANT_SCOPE = {
|
|
actions: [SOCIAL_BROWSER_ACTION.LOGIN, SOCIAL_BROWSER_ACTION.DRAFT, SOCIAL_BROWSER_ACTION.APPROVE],
|
|
riskCeiling: RISK_LEVEL.MEDIUM,
|
|
} as const;
|
|
|
|
const ACTION_REQUIRED_RISK: Record<SocialBrowserAction, 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 type { SocialLoginWindowInput, SocialLoginWindowLauncher } from "./social-login-window";
|
|
|
|
export class SocialService {
|
|
private readonly audit: AuditService;
|
|
private readonly approvals: ApprovalService;
|
|
private readonly socialAudit: SocialAuditService;
|
|
private readonly browserPolicy: SocialBrowserPolicyService;
|
|
|
|
constructor(
|
|
private readonly db: DatabaseSync,
|
|
private readonly launchSocialLoginWindow: SocialLoginWindowLauncher = openSocialLoginWindow,
|
|
) {
|
|
this.audit = new AuditService(db);
|
|
this.approvals = new ApprovalService(db);
|
|
this.socialAudit = new SocialAuditService(db);
|
|
this.browserPolicy = new SocialBrowserPolicyService(db);
|
|
}
|
|
|
|
createScheduledPost(input: CreateScheduledPostInput): string {
|
|
this.requireAccount(input.orgId, input.socialAccountId);
|
|
const policy = this.browserPolicy.requireActionPolicy(input.orgId, input.socialAccountId, SOCIAL_BROWSER_ACTION.DRAFT);
|
|
this.requireGrant(input.orgId, input.socialAccountId, input.createdByEmployeeId, SOCIAL_BROWSER_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(`
|
|
insert into scheduled_posts(
|
|
id, org_id, social_account_id, body_md, scheduled_for,
|
|
state, approval_id, created_by_employee_id, created_at, updated_at
|
|
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
id,
|
|
input.orgId,
|
|
input.socialAccountId,
|
|
body,
|
|
scheduledFor,
|
|
"draft",
|
|
null,
|
|
input.createdByEmployeeId,
|
|
now,
|
|
now,
|
|
);
|
|
this.audit.record({
|
|
orgId: input.orgId,
|
|
entityType: "scheduled_post",
|
|
entityId: id,
|
|
actorType: "employee",
|
|
actorId: input.createdByEmployeeId,
|
|
action: "drafted",
|
|
payload: { socialAccountId: input.socialAccountId, scheduledFor, state: "draft" },
|
|
});
|
|
this.socialAudit.record({
|
|
orgId: input.orgId,
|
|
socialAccountId: input.socialAccountId,
|
|
sourceType: "scheduled_post",
|
|
sourceId: id,
|
|
eventType: "draft_created",
|
|
actorType: "employee",
|
|
actorId: input.createdByEmployeeId,
|
|
policyDecision: "allowed_by_policy",
|
|
metadata: { scheduledFor, actionType: policy.action_type, riskLevel: policy.risk_level },
|
|
createdAt: now,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
setScheduledPostState(input: SetScheduledPostStateInput): void {
|
|
const post = this.requireScheduledPost(input.orgId, input.scheduledPostId);
|
|
const action = STATE_GRANT_ACTION[input.state];
|
|
const policy = this.browserPolicy.requireActionPolicy(input.orgId, post.social_account_id, action);
|
|
this.requireGrant(input.orgId, post.social_account_id, input.employeeId, action);
|
|
this.assertStateTransition(post, input.state);
|
|
const approvalId = this.resolveApprovalForState(input, post, policy);
|
|
const now = new Date().toISOString();
|
|
const result = this.db.prepare(`
|
|
update scheduled_posts set state = ?, approval_id = ?, updated_at = ?
|
|
where id = ? and org_id = ?
|
|
`).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: input.employeeId,
|
|
action: `state_${input.state}`,
|
|
payload: { state: input.state, approvalId },
|
|
});
|
|
this.socialAudit.record({
|
|
orgId: input.orgId,
|
|
socialAccountId: post.social_account_id,
|
|
sourceType: "scheduled_post",
|
|
sourceId: input.scheduledPostId,
|
|
eventType: `state_${input.state}`,
|
|
actorType: "employee",
|
|
actorId: input.employeeId,
|
|
policyDecision: policyDecisionForState(input.state, policy.approval_required === 1),
|
|
metadata: { state: input.state, approvalId, actionType: policy.action_type, riskLevel: policy.risk_level },
|
|
createdAt: now,
|
|
});
|
|
}
|
|
|
|
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) {
|
|
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)
|
|
values (?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
id,
|
|
input.orgId,
|
|
input.platform,
|
|
handle,
|
|
displayName,
|
|
input.authorizedEmployeeId,
|
|
now,
|
|
);
|
|
this.browserPolicy.seedAccountControls({
|
|
orgId: input.orgId,
|
|
socialAccountId: id,
|
|
platform: input.platform,
|
|
authorizedEmployeeId: input.authorizedEmployeeId ?? null,
|
|
createdAt: now,
|
|
});
|
|
if (input.authorizedEmployeeId) {
|
|
this.db.prepare(`
|
|
insert into social_account_grants(id, org_id, social_account_id, employee_id, role, scope_json, created_at)
|
|
values (?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
createId("sgr"),
|
|
input.orgId,
|
|
id,
|
|
input.authorizedEmployeeId,
|
|
"operator",
|
|
JSON.stringify(GRANT_SCOPE),
|
|
now,
|
|
);
|
|
}
|
|
this.audit.record({
|
|
orgId: input.orgId,
|
|
entityType: "social_account",
|
|
entityId: id,
|
|
actorType: "employee",
|
|
actorId: input.createdByEmployeeId,
|
|
action: "created",
|
|
payload: { platform: input.platform, handle },
|
|
});
|
|
this.socialAudit.record({
|
|
orgId: input.orgId,
|
|
socialAccountId: id,
|
|
sourceType: "social_account",
|
|
sourceId: id,
|
|
eventType: "account_created",
|
|
actorType: "employee",
|
|
actorId: input.createdByEmployeeId,
|
|
policyDecision: "allowed_employee_authorization",
|
|
metadata: { platform: input.platform, handle, authorizedEmployeeId: input.authorizedEmployeeId ?? null },
|
|
createdAt: now,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
launchLoginWindow(input: LaunchSocialLoginInput): void {
|
|
const account = this.requireAccount(input.orgId, input.socialAccountId);
|
|
const container = this.browserPolicy.requireSessionContainer(input.orgId, input.socialAccountId);
|
|
this.browserPolicy.requireActionPolicy(input.orgId, input.socialAccountId, SOCIAL_BROWSER_ACTION.LOGIN);
|
|
this.requireGrant(input.orgId, input.socialAccountId, input.employeeId, SOCIAL_BROWSER_ACTION.LOGIN);
|
|
const allowedHosts = this.browserPolicy.allowedHosts(container);
|
|
const loginUrl = PLATFORM_LOGIN_URL[account.platform];
|
|
this.launchSocialLoginWindow({
|
|
title: account.handle,
|
|
loginUrl,
|
|
partitionKey: container.partition_key,
|
|
allowedHosts,
|
|
sandboxEnabled: container.sandbox_enabled === 1,
|
|
});
|
|
const now = new Date().toISOString();
|
|
this.browserPolicy.markSessionUsed(container.id, now);
|
|
const browserActionId = createId("bact");
|
|
const metadata = loginEvidenceMetadata({
|
|
platform: account.platform,
|
|
loginUrl,
|
|
containerId: container.id,
|
|
partitionKey: container.partition_key,
|
|
hostAllowlist: allowedHosts,
|
|
permissionPolicy: container.permission_policy,
|
|
sandboxEnabled: container.sandbox_enabled === 1,
|
|
actorId: input.employeeId,
|
|
});
|
|
this.db.prepare(`
|
|
insert into browser_actions(
|
|
id, org_id, social_account_id, action_type, policy_decision,
|
|
evidence_file_id, metadata_json, created_at
|
|
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(browserActionId, account.org_id, account.id, "login_window_opened", "allowed_human_login", null, JSON.stringify(metadata), now);
|
|
this.audit.record({
|
|
orgId: account.org_id,
|
|
entityType: "social_account",
|
|
entityId: account.id,
|
|
actorType: "employee",
|
|
actorId: input.employeeId,
|
|
action: "login_window_opened",
|
|
payload: { platform: account.platform },
|
|
});
|
|
this.socialAudit.record({
|
|
orgId: account.org_id,
|
|
socialAccountId: account.id,
|
|
sourceType: "browser_action",
|
|
sourceId: browserActionId,
|
|
eventType: "login_window_opened",
|
|
actorType: "employee",
|
|
actorId: input.employeeId,
|
|
policyDecision: "allowed_human_login",
|
|
metadata,
|
|
createdAt: now,
|
|
});
|
|
}
|
|
|
|
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: SocialBrowserAction): 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, policy: BrowserActionPolicyRow): string | null {
|
|
if (input.state === "scheduled") {
|
|
return policy.approval_required === 1
|
|
? post.approval_id ?? this.createPublishApproval(input.orgId, input.employeeId, post, policy)
|
|
: post.approval_id;
|
|
}
|
|
if (input.state === "published") {
|
|
if (policy.approval_required === 1) this.assertApprovalApproved(input.orgId, post.approval_id);
|
|
return post.approval_id;
|
|
}
|
|
return post.approval_id;
|
|
}
|
|
|
|
private createPublishApproval(orgId: string, employeeId: string, post: ScheduledPostRow, policy: BrowserActionPolicyRow): string {
|
|
const route = this.browserPolicy.requireApprovalRoute(orgId, post.social_account_id, policy.action_type, policy.risk_level);
|
|
const approvalId = this.approvals.create({
|
|
orgId,
|
|
kind: route.approval_kind,
|
|
title: "Approve social post",
|
|
details: post.body_md,
|
|
risk: policy.risk_level,
|
|
requestedByType: "employee",
|
|
requestedById: employeeId,
|
|
}, { sourceEntityType: "scheduled_post", sourceEntityId: post.id });
|
|
this.assignSocialApprovalRoute(orgId, approvalId, route);
|
|
return approvalId;
|
|
}
|
|
|
|
private assignSocialApprovalRoute(orgId: string, approvalId: string, route: SocialApprovalRouteRow): void {
|
|
if (route.route_to_type !== "authorized_employee") throw new Error("Social approval route target type is invalid.");
|
|
if (!route.route_to_id) throw new Error("Social approval route target is required.");
|
|
this.assertEmployee(orgId, route.route_to_id, "Social approval route employee was not found.");
|
|
this.db.prepare(`
|
|
insert into approval_assignments(id, org_id, approval_id, assignee_type, assignee_id, routing_rule_id, created_at)
|
|
values (?, ?, ?, ?, ?, null, ?)
|
|
on conflict(approval_id, assignee_type, assignee_id) do nothing
|
|
`).run(createId("aas"), orgId, approvalId, ROUTE_TO_TYPE.EMPLOYEE, route.route_to_id, new Date().toISOString());
|
|
this.audit.record({
|
|
orgId,
|
|
entityType: "approval",
|
|
entityId: approvalId,
|
|
actorType: "system",
|
|
actorId: null,
|
|
action: "social_route_assigned",
|
|
payload: { routeId: route.id, routeToType: route.route_to_type, routeToId: route.route_to_id },
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function policyDecisionForState(state: SetScheduledPostStateInput["state"], approvalRequired: boolean): string {
|
|
if (state === "scheduled" && approvalRequired) return "approval_requested";
|
|
if (state === "published" && approvalRequired) return "allowed_after_approval";
|
|
return "allowed_by_policy";
|
|
}
|
|
|
|
function loginEvidenceMetadata(input: {
|
|
platform: SocialPlatform;
|
|
loginUrl: string;
|
|
containerId: string;
|
|
partitionKey: string;
|
|
hostAllowlist: string[];
|
|
permissionPolicy: string;
|
|
sandboxEnabled: boolean;
|
|
actorId: string;
|
|
}): Record<string, unknown> {
|
|
return {
|
|
platform: input.platform,
|
|
loginUrl: input.loginUrl,
|
|
containerId: input.containerId,
|
|
partitionKey: input.partitionKey,
|
|
hostAllowlist: input.hostAllowlist,
|
|
permissionPolicy: input.permissionPolicy,
|
|
sandboxEnabled: input.sandboxEnabled,
|
|
actorType: "employee",
|
|
actorId: input.actorId,
|
|
};
|
|
} |