Add browser platform governance records

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 21:17:03 -04:00
1 parent 18e0c6338e
commit eab282d8b1
12 files changed
+536 -78

No files matched your search

@@ -0,0 +1,127 @@
export const browserPlatformGovernanceSchema = `
create table if not exists browser_session_containers (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
social_account_id text not null references social_accounts(id) on delete cascade,
platform text not null,
partition_key text not null,
host_allowlist_json text not null,
permission_policy text not null,
sandbox_enabled integer not null,
created_at text not null,
last_used_at text,
unique(social_account_id)
);
create table if not exists browser_action_policies (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
social_account_id text not null references social_accounts(id) on delete cascade,
action_type text not null,
content_category text not null,
risk_level text not null,
approval_required integer not null,
approval_kind text,
created_at text not null,
unique(social_account_id, action_type, content_category)
);
create table if not exists social_approval_routes (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
social_account_id text not null references social_accounts(id) on delete cascade,
action_type text not null,
risk_level text not null,
approval_kind text not null,
route_to_type text not null,
route_to_id text,
created_at text not null,
unique(social_account_id, action_type, risk_level)
);
insert into browser_session_containers(
id, org_id, social_account_id, platform, partition_key, host_allowlist_json,
permission_policy, sandbox_enabled, created_at, last_used_at
)
select
'bsc_' || lower(hex(randomblob(16))),
org_id,
id,
platform,
'persist:agentcompany-social-' || id,
case platform
when 'x' then '["x.com","twitter.com"]'
when 'linkedin' then '["linkedin.com","www.linkedin.com"]'
when 'youtube' then '["accounts.google.com","youtube.com","www.youtube.com"]'
when 'instagram' then '["instagram.com","www.instagram.com"]'
else '[]'
end,
'deny_all',
1,
datetime('now'),
null
from social_accounts
where not exists (
select 1 from browser_session_containers
where browser_session_containers.social_account_id = social_accounts.id
);
insert into browser_action_policies(
id, org_id, social_account_id, action_type, content_category, risk_level,
approval_required, approval_kind, created_at
)
select
'bap_' || lower(hex(randomblob(16))),
social_accounts.org_id,
social_accounts.id,
policy.action_type,
policy.content_category,
policy.risk_level,
policy.approval_required,
policy.approval_kind,
datetime('now')
from social_accounts
join (
select 'login' action_type, 'auth' content_category, 'low' risk_level, 0 approval_required, null approval_kind
union all select 'draft', 'content', 'low', 0, null
union all select 'approve', 'content', 'medium', 1, 'social_publish'
) policy
where not exists (
select 1 from browser_action_policies
where browser_action_policies.social_account_id = social_accounts.id
and browser_action_policies.action_type = policy.action_type
and browser_action_policies.content_category = policy.content_category
);
insert into social_approval_routes(
id, org_id, social_account_id, action_type, risk_level, approval_kind,
route_to_type, route_to_id, created_at
)
select
'sar_' || lower(hex(randomblob(16))),
org_id,
id,
'approve',
'medium',
'social_publish',
'authorized_employee',
authorized_employee_id,
datetime('now')
from social_accounts
where not exists (
select 1 from social_approval_routes
where social_approval_routes.social_account_id = social_accounts.id
and social_approval_routes.action_type = 'approve'
and social_approval_routes.risk_level = 'medium'
);
create index if not exists idx_browser_session_containers_org
on browser_session_containers(org_id, social_account_id);
create index if not exists idx_browser_action_policies_org_account
on browser_action_policies(org_id, social_account_id, action_type);
create index if not exists idx_social_approval_routes_org_account
on social_approval_routes(org_id, social_account_id, action_type);
insert into schema_migrations(version, name, applied_at)
values (26, 'browser_platform_governance', datetime('now'));
`;
@@ -24,6 +24,7 @@ import { providerRateLimitObservationSchema } from "./migrations/022-provider-ra
import { providerRequestRuntimeSchema } from "./migrations/023-provider-request-runtime";
import { taskRunProviderRequestSchema } from "./migrations/024-task-run-provider-request";
import { chatMessageDeliverySchema } from "./migrations/025-chat-message-delivery";
import { browserPlatformGovernanceSchema } from "./migrations/026-browser-platform-governance";
interface Migration {
version: number;
@@ -57,6 +58,7 @@ const MIGRATIONS: Migration[] = [
{ version: 23, name: "provider_request_runtime", sql: providerRequestRuntimeSchema },
{ version: 24, name: "task_run_provider_request", sql: taskRunProviderRequestSchema },
{ version: 25, name: "chat_message_delivery", sql: chatMessageDeliverySchema },
{ version: 26, name: "browser_platform_governance", sql: browserPlatformGovernanceSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -64,6 +64,9 @@ const LEGACY_TABLES = [
"file_access_grants",
"social_accounts",
"social_account_grants",
"browser_session_containers",
"browser_action_policies",
"social_approval_routes",
"browser_actions",
"scheduled_posts",
"policy_rules",
@@ -214,6 +217,13 @@ const RELATION_CHECKS: RelationCheck[] = [
{ table: "social_account_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "social_account_grants", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
{ table: "social_account_grants", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" },
{ table: "browser_session_containers", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "browser_session_containers", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
{ table: "browser_action_policies", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "browser_action_policies", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
{ table: "social_approval_routes", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "social_approval_routes", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
{ table: "social_approval_routes", column: "route_to_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_route_to_id" },
{ table: "browser_actions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "browser_actions", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" },
{ table: "browser_actions", column: "evidence_file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_evidence_file_id" },
@@ -262,7 +262,7 @@ export class SdlcService {
{ key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` },
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
{ key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady },
{ key: "schema_version", passed: schemaVersion >= 25, value: schemaVersion },
{ key: "schema_version", passed: schemaVersion >= 26, value: schemaVersion },
];
}
@@ -116,6 +116,11 @@ export class SnapshotService {
skillRuntimeSnapshots: list(this.db, "skill_runtime_snapshots", "created_at desc"),
socialAccounts: list(this.db, "social_accounts", "created_at asc"),
socialAccountGrants: list(this.db, "social_account_grants", "created_at asc"),
browserSessionContainers: list<AppSnapshot["browserSessionContainers"][number]>(this.db, "browser_session_containers", "created_at asc")
.map((container) => ({ ...container, sandboxEnabled: toBool(container.sandboxEnabled) })),
browserActionPolicies: list<AppSnapshot["browserActionPolicies"][number]>(this.db, "browser_action_policies", "created_at asc")
.map((policy) => ({ ...policy, approvalRequired: toBool(policy.approvalRequired) })),
socialApprovalRoutes: list(this.db, "social_approval_routes", "created_at asc"),
browserActions: list(this.db, "browser_actions", "created_at desc"),
policyRules: list(this.db, "policy_rules", "created_at asc"),
migrationReports: list(this.db, "migration_reports", "created_at desc"),
@@ -0,0 +1,165 @@
import type { DatabaseSync } from "node:sqlite";
import { SOCIAL_PLATFORM, type RiskLevel, type SocialPlatform } from "../../shared/domain";
import { createId } from "../../shared/ids";
export const SOCIAL_BROWSER_ACTION = {
LOGIN: "login",
DRAFT: "draft",
APPROVE: "approve",
} as const;
export type SocialBrowserAction = typeof SOCIAL_BROWSER_ACTION[keyof typeof SOCIAL_BROWSER_ACTION];
const PLATFORM_HOSTS: Record<SocialPlatform, string[]> = {
[SOCIAL_PLATFORM.X]: ["x.com", "twitter.com"],
[SOCIAL_PLATFORM.LINKEDIN]: ["linkedin.com", "www.linkedin.com"],
[SOCIAL_PLATFORM.YOUTUBE]: ["accounts.google.com", "youtube.com", "www.youtube.com"],
[SOCIAL_PLATFORM.INSTAGRAM]: ["instagram.com", "www.instagram.com"],
};
const ACTION_POLICIES: ReadonlyArray<{
actionType: SocialBrowserAction;
contentCategory: string;
riskLevel: RiskLevel;
approvalRequired: boolean;
approvalKind: string | null;
}> = [
{ actionType: SOCIAL_BROWSER_ACTION.LOGIN, contentCategory: "auth", riskLevel: "low", approvalRequired: false, approvalKind: null },
{ actionType: SOCIAL_BROWSER_ACTION.DRAFT, contentCategory: "content", riskLevel: "low", approvalRequired: false, approvalKind: null },
{ actionType: SOCIAL_BROWSER_ACTION.APPROVE, contentCategory: "content", riskLevel: "medium", approvalRequired: true, approvalKind: "social_publish" },
];
export interface BrowserSessionContainerRow {
id: string;
partition_key: string;
host_allowlist_json: string;
permission_policy: string;
sandbox_enabled: number;
}
export interface BrowserActionPolicyRow {
id: string;
action_type: SocialBrowserAction;
risk_level: RiskLevel;
approval_required: number;
approval_kind: string | null;
}
export interface SocialApprovalRouteRow {
id: string;
approval_kind: string;
route_to_type: string;
route_to_id: string | null;
}
export class SocialBrowserPolicyService {
constructor(private readonly db: DatabaseSync) {}
seedAccountControls(input: {
orgId: string;
socialAccountId: string;
platform: SocialPlatform;
authorizedEmployeeId: string | null;
createdAt: string;
}): void {
this.db.prepare(`
insert or ignore into browser_session_containers(
id, org_id, social_account_id, platform, partition_key, host_allowlist_json,
permission_policy, sandbox_enabled, created_at, last_used_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, null)
`).run(
createId("bsc"),
input.orgId,
input.socialAccountId,
input.platform,
`persist:agentcompany-social-${input.socialAccountId}`,
JSON.stringify(PLATFORM_HOSTS[input.platform]),
"deny_all",
1,
input.createdAt,
);
for (const policy of ACTION_POLICIES) {
this.db.prepare(`
insert or ignore into browser_action_policies(
id, org_id, social_account_id, action_type, content_category, risk_level,
approval_required, approval_kind, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
createId("bap"),
input.orgId,
input.socialAccountId,
policy.actionType,
policy.contentCategory,
policy.riskLevel,
policy.approvalRequired ? 1 : 0,
policy.approvalKind,
input.createdAt,
);
}
this.db.prepare(`
insert or ignore into social_approval_routes(
id, org_id, social_account_id, action_type, risk_level, approval_kind,
route_to_type, route_to_id, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
createId("sar"),
input.orgId,
input.socialAccountId,
SOCIAL_BROWSER_ACTION.APPROVE,
"medium",
"social_publish",
"authorized_employee",
input.authorizedEmployeeId,
input.createdAt,
);
}
requireSessionContainer(orgId: string, socialAccountId: string): BrowserSessionContainerRow {
const row = this.db.prepare(`
select id, partition_key, host_allowlist_json, permission_policy, sandbox_enabled
from browser_session_containers
where org_id = ? and social_account_id = ?
`).get(orgId, socialAccountId) as BrowserSessionContainerRow | undefined;
if (!row) throw new Error("Browser session container is required.");
this.parseHostAllowlist(row.host_allowlist_json);
if (row.permission_policy !== "deny_all") throw new Error("Browser permission policy is invalid.");
if (row.sandbox_enabled !== 1) throw new Error("Browser sandbox policy is required.");
return row;
}
allowedHosts(container: BrowserSessionContainerRow): string[] {
return this.parseHostAllowlist(container.host_allowlist_json);
}
markSessionUsed(containerId: string, usedAt: string): void {
this.db.prepare("update browser_session_containers set last_used_at = ? where id = ?").run(usedAt, containerId);
}
requireActionPolicy(orgId: string, socialAccountId: string, actionType: SocialBrowserAction): BrowserActionPolicyRow {
const row = this.db.prepare(`
select id, action_type, risk_level, approval_required, approval_kind
from browser_action_policies
where org_id = ? and social_account_id = ? and action_type = ?
`).get(orgId, socialAccountId, actionType) as BrowserActionPolicyRow | undefined;
if (!row) throw new Error("Browser action policy is required.");
return row;
}
requireApprovalRoute(orgId: string, socialAccountId: string, actionType: SocialBrowserAction, riskLevel: RiskLevel): SocialApprovalRouteRow {
const row = this.db.prepare(`
select id, approval_kind, route_to_type, route_to_id
from social_approval_routes
where org_id = ? and social_account_id = ? and action_type = ? and risk_level = ?
`).get(orgId, socialAccountId, actionType, riskLevel) as SocialApprovalRouteRow | undefined;
if (!row) throw new Error("Social approval route is required.");
return row;
}
private parseHostAllowlist(raw: string): string[] {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === "string" && item.trim())) {
throw new Error("Browser host allowlist is invalid.");
}
return parsed.map((item) => item.toLowerCase());
}
}
@@ -6,10 +6,17 @@ import type {
LaunchSocialLoginInput,
SetScheduledPostStateInput,
} from "../../shared/commands";
import { APPROVAL_STATE, RISK_LEVEL, SOCIAL_PLATFORM, type RiskLevel, type SocialPlatform } from "../../shared/domain";
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 {
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",
@@ -18,34 +25,19 @@ const PLATFORM_LOGIN_URL: Record<SocialPlatform, string> = {
[SOCIAL_PLATFORM.INSTAGRAM]: "https://www.instagram.com/accounts/login/",
};
const ALLOWED_HOSTS: Record<SocialPlatform, string[]> = {
[SOCIAL_PLATFORM.X]: ["x.com", "twitter.com"],
[SOCIAL_PLATFORM.LINKEDIN]: ["linkedin.com", "www.linkedin.com"],
[SOCIAL_PLATFORM.YOUTUBE]: ["accounts.google.com", "youtube.com", "www.youtube.com"],
[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 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_GRANT_ACTION.LOGIN, SOCIAL_GRANT_ACTION.DRAFT, SOCIAL_GRANT_ACTION.APPROVE],
actions: [SOCIAL_BROWSER_ACTION.LOGIN, SOCIAL_BROWSER_ACTION.DRAFT, SOCIAL_BROWSER_ACTION.APPROVE],
riskCeiling: RISK_LEVEL.MEDIUM,
} as const;
const ACTION_REQUIRED_RISK: Record<SocialGrantAction, RiskLevel> = {
const ACTION_REQUIRED_RISK: Record<SocialBrowserAction, RiskLevel> = {
login: RISK_LEVEL.LOW,
draft: RISK_LEVEL.LOW,
approve: RISK_LEVEL.MEDIUM,
@@ -83,15 +75,18 @@ interface GrantScope {
export class SocialService {
private readonly audit: AuditService;
private readonly approvals: ApprovalService;
private readonly browserPolicy: SocialBrowserPolicyService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
this.approvals = new ApprovalService(db);
this.browserPolicy = new SocialBrowserPolicyService(db);
}
createScheduledPost(input: CreateScheduledPostInput): string {
this.requireAccount(input.orgId, input.socialAccountId);
this.requireGrant(input.orgId, input.socialAccountId, input.createdByEmployeeId, SOCIAL_GRANT_ACTION.DRAFT);
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");
@@ -127,9 +122,11 @@ export class SocialService {
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]);
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);
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 = ?
@@ -171,6 +168,13 @@ export class SocialService {
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)
@@ -199,9 +203,10 @@ export class SocialService {
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);
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 socialSession = session.fromPartition(container.partition_key);
socialSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
socialSession.setPermissionCheckHandler(() => false);
@@ -213,15 +218,17 @@ export class SocialService {
session: socialSession,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
sandbox: container.sandbox_enabled === 1,
},
});
this.attachNavigationPolicy(win, account.platform);
this.attachNavigationPolicy(win, this.browserPolicy.allowedHosts(container));
win.loadURL(PLATFORM_LOGIN_URL[account.platform]);
const now = new Date().toISOString();
this.browserPolicy.markSessionUsed(container.id, now);
this.db.prepare(`
insert into browser_actions(id, org_id, social_account_id, action_type, policy_decision, evidence_file_id, created_at)
values (?, ?, ?, ?, ?, ?, ?)
`).run(createId("bact"), account.org_id, account.id, "login_window_opened", "allowed_human_login", null, new Date().toISOString());
`).run(createId("bact"), account.org_id, account.id, "login_window_opened", "allowed_human_login", null, now);
this.audit.record({
orgId: account.org_id,
entityType: "social_account",
@@ -233,10 +240,10 @@ export class SocialService {
});
}
private attachNavigationPolicy(win: BrowserWindow, platform: SocialPlatform): void {
private attachNavigationPolicy(win: BrowserWindow, allowedHosts: string[]): void {
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
const guard = (event: Electron.Event, url: string) => {
if (!this.isAllowedNavigation(platform, url)) event.preventDefault();
if (!this.isAllowedNavigation(allowedHosts, url)) event.preventDefault();
};
win.webContents.on("will-navigate", guard);
win.webContents.on("will-redirect", guard);
@@ -256,7 +263,7 @@ export class SocialService {
return post;
}
private requireGrant(orgId: string, socialAccountId: string, employeeId: string, action: SocialGrantAction): void {
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
@@ -270,27 +277,52 @@ export class SocialService {
}
}
private resolveApprovalForState(input: SetScheduledPostStateInput, post: ScheduledPostRow): string | null {
private resolveApprovalForState(input: SetScheduledPostStateInput, post: ScheduledPostRow, policy: BrowserActionPolicyRow): string | null {
if (input.state === "scheduled") {
return post.approval_id ?? this.createPublishApproval(input.orgId, input.employeeId, post.body_md);
return policy.approval_required === 1
? post.approval_id ?? this.createPublishApproval(input.orgId, input.employeeId, post, policy)
: post.approval_id;
}
if (input.state === "published") {
this.assertApprovalApproved(input.orgId, post.approval_id);
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, body: string): string {
return this.approvals.create({
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: "social_publish",
kind: route.approval_kind,
title: "Approve social post",
details: body,
risk: RISK_LEVEL.MEDIUM,
details: post.body_md,
risk: policy.risk_level,
requestedByType: "employee",
requestedById: employeeId,
});
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 {
@@ -345,11 +377,11 @@ export class SocialService {
return scheduledFor;
}
private isAllowedNavigation(platform: SocialPlatform, url: string): boolean {
private isAllowedNavigation(allowedHosts: string[], 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}`));
return allowedHosts.some((entry) => host === entry || host.endsWith(`.${entry}`));
}
}
+5 -30
View File
@@ -10,6 +10,7 @@ import type { ProviderRequestRecord } from "./provider-request-domain";
import type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain";
import type { RollbackSnapshotRecord } from "./rollback-domain";
import type { EvalCaseRecord, EvalRunRecord, EvalSuiteRecord, PrBotGateRecord, SdlcReviewAgentRecord, SdlcReviewRunRecord } from "./sdlc-domain";
import type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord } from "./social-domain";
export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain";
export type { AuditReplayRunRecord } from "./audit-domain";
export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain";
@@ -23,6 +24,7 @@ export type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord }
export type { RollbackSnapshotRecord } from "./rollback-domain";
export { EVAL_SUITE_KEY, PR_BOT_GATE_KEY, SDLC_REVIEW_AGENT_KEY, SDLC_REVIEW_TARGET_TYPE, SDLC_RUN_STATE, type EvalCaseRecord, type EvalRunRecord, type EvalSuiteKey, type EvalSuiteRecord, type PrBotGateKey, type PrBotGateRecord, type SdlcReviewAgentKey, type SdlcReviewAgentRecord, type SdlcReviewRunRecord, type SdlcReviewTargetType, type SdlcRunState } from "./sdlc-domain";
export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain";
export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord } from "./social-domain";
export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const;
export const APPROVAL_STATE = { PENDING: "pending", APPROVED: "approved", DECLINED: "declined" } as const;
export const RISK_LEVEL = { LOW: "low", MEDIUM: "medium", HIGH: "high" } as const;
@@ -376,36 +378,6 @@ export interface SkillRuntimeSnapshotRecord {
createdAt: string;
}
export interface SocialAccountRecord {
id: string;
orgId: string;
platform: SocialPlatform;
handle: string;
displayName: string;
authorizedEmployeeId: string | null;
createdAt: string;
}
export interface SocialAccountGrantRecord {
id: string;
orgId: string;
socialAccountId: string;
employeeId: string;
role: string;
scopeJson: string;
createdAt: string;
}
export interface BrowserActionRecord {
id: string;
orgId: string;
socialAccountId: string;
actionType: string;
policyDecision: string;
evidenceFileId: string | null;
createdAt: string;
}
export interface AuditEventRecord {
id: string;
orgId: string;
@@ -473,6 +445,9 @@ export interface AppSnapshot {
skillRuntimeSnapshots: SkillRuntimeSnapshotRecord[];
socialAccounts: SocialAccountRecord[];
socialAccountGrants: SocialAccountGrantRecord[];
browserSessionContainers: BrowserSessionContainerRecord[];
browserActionPolicies: BrowserActionPolicyRecord[];
socialApprovalRoutes: SocialApprovalRouteRecord[];
browserActions: BrowserActionRecord[];
policyRules: PolicyRuleRecord[];
migrationReports: MigrationReportRecord[];
@@ -0,0 +1,68 @@
import type { SocialPlatform } from "./domain";
export interface SocialAccountRecord {
id: string;
orgId: string;
platform: SocialPlatform;
handle: string;
displayName: string;
authorizedEmployeeId: string | null;
createdAt: string;
}
export interface SocialAccountGrantRecord {
id: string;
orgId: string;
socialAccountId: string;
employeeId: string;
role: string;
scopeJson: string;
createdAt: string;
}
export interface BrowserSessionContainerRecord {
id: string;
orgId: string;
socialAccountId: string;
platform: SocialPlatform;
partitionKey: string;
hostAllowlistJson: string;
permissionPolicy: string;
sandboxEnabled: boolean;
createdAt: string;
lastUsedAt: string | null;
}
export interface BrowserActionPolicyRecord {
id: string;
orgId: string;
socialAccountId: string;
actionType: string;
contentCategory: string;
riskLevel: string;
approvalRequired: boolean;
approvalKind: string | null;
createdAt: string;
}
export interface SocialApprovalRouteRecord {
id: string;
orgId: string;
socialAccountId: string;
actionType: string;
riskLevel: string;
approvalKind: string;
routeToType: string;
routeToId: string | null;
createdAt: string;
}
export interface BrowserActionRecord {
id: string;
orgId: string;
socialAccountId: string;
actionType: string;
policyDecision: string;
evidenceFileId: string | null;
createdAt: string;
}
@@ -23,7 +23,7 @@ describe("domain services", () => {
const connection = createConnection();
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]);
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]);
connection.close();
});
@@ -146,6 +146,9 @@ describe("migration service", () => {
create table file_access_grants(id text primary key, org_id text, file_id text, source_message_id text, source_thread_id text);
create table social_accounts(id text primary key, org_id text, authorized_employee_id text);
create table social_account_grants(id text primary key, org_id text, social_account_id text, employee_id text);
create table browser_session_containers(id text primary key, org_id text, social_account_id text);
create table browser_action_policies(id text primary key, org_id text, social_account_id text);
create table social_approval_routes(id text primary key, org_id text, social_account_id text, route_to_id text);
create table browser_actions(id text primary key, org_id text, social_account_id text, evidence_file_id text);
create table scheduled_posts(id text primary key, org_id text, social_account_id text, approval_id text, created_by_employee_id text);
create table policy_rules(id text primary key, org_id text);
@@ -211,6 +214,9 @@ describe("migration service", () => {
insert into skill_catalog_tags(skill_id, tag, tag_type) values ('skill-1', 'engineering', 'department');
insert into social_accounts(id, org_id, authorized_employee_id) values ('social-1', 'org-1', 'emp-1');
insert into social_account_grants(id, org_id, social_account_id, employee_id) values ('grant-1', 'org-1', 'social-1', 'emp-1');
insert into browser_session_containers(id, org_id, social_account_id) values ('container-1', 'org-1', 'social-1');
insert into browser_action_policies(id, org_id, social_account_id) values ('browser-policy-1', 'org-1', 'social-1');
insert into social_approval_routes(id, org_id, social_account_id, route_to_id) values ('social-route-1', 'org-1', 'social-1', 'emp-1');
insert into browser_actions(id, org_id, social_account_id, evidence_file_id) values ('browser-1', 'org-1', 'social-1', 'file-1');
insert into scheduled_posts(id, org_id, social_account_id, approval_id, created_by_employee_id) values ('post-1', 'org-1', 'social-1', 'approval-1', 'emp-1');
insert into policy_rules(id, org_id) values ('policy-1', 'org-1');
@@ -247,6 +253,9 @@ describe("migration service", () => {
expect(body.tableCounts.provider_requests).toBe(1);
expect(body.tableCounts.provider_usage_windows).toBe(1);
expect(body.tableCounts.provider_rate_limit_observations).toBe(1);
expect(body.tableCounts.browser_session_containers).toBe(1);
expect(body.tableCounts.browser_action_policies).toBe(1);
expect(body.tableCounts.social_approval_routes).toBe(1);
expect(body.tableCounts.compliance_exports).toBe(1);
expect(body.tableCounts.audit_replay_runs).toBe(1);
expect(body.tableCounts.rollback_snapshots).toBe(1);
@@ -266,6 +275,9 @@ describe("migration service", () => {
expect(body.idMaps.rollback_snapshots).toEqual(["rollback-1"]);
expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]);
expect(body.idMaps.provider_rate_limit_observations).toEqual(["quota-1"]);
expect(body.idMaps.browser_session_containers).toEqual(["container-1"]);
expect(body.idMaps.browser_action_policies).toEqual(["browser-policy-1"]);
expect(body.idMaps.social_approval_routes).toEqual(["social-route-1"]);
connection.close();
});
@@ -8,7 +8,7 @@ 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 { APPROVAL_STATE, ROUTE_TO_TYPE, SOCIAL_PLATFORM } from "../../src/shared/domain";
import { createId } from "../../src/shared/ids";
function createConnection(): DatabaseConnection {
@@ -62,6 +62,25 @@ describe("social service", () => {
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.browserSessionContainers[0]).toMatchObject({
socialAccountId: state.socialAccounts[0]?.id,
platform: SOCIAL_PLATFORM.X,
permissionPolicy: "deny_all",
sandboxEnabled: true,
});
expect(JSON.parse(state.browserSessionContainers[0]!.hostAllowlistJson)).toEqual(["x.com", "twitter.com"]);
expect(state.browserActionPolicies.map((policy) => policy.actionType).sort()).toEqual(["approve", "draft", "login"]);
expect(state.browserActionPolicies.find((policy) => policy.actionType === "approve")).toMatchObject({
approvalRequired: true,
approvalKind: "social_publish",
riskLevel: "medium",
});
expect(state.socialApprovalRoutes[0]).toMatchObject({
socialAccountId: state.socialAccounts[0]?.id,
actionType: "approve",
approvalKind: "social_publish",
routeToId: 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");
@@ -137,6 +156,49 @@ describe("social service", () => {
connection.close();
});
test("assigns social publish approvals to the account authorizer", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const social = new SocialService(connection.db);
const snapshot = new SnapshotService(connection.db);
let orgId = "";
let authorizerId = "";
let approvalId = "";
connection.transaction(() => {
orgId = createOrganization(organization);
const creatorId = firstEmployeeId(connection, orgId);
authorizerId = createEmployee(connection, orgId, "Riley Chen");
const socialAccountId = social.createAccount({
orgId,
platform: SOCIAL_PLATFORM.INSTAGRAM,
handle: "acme",
displayName: "Acme",
createdByEmployeeId: creatorId,
authorizedEmployeeId: authorizerId,
});
const postId = social.createScheduledPost({
orgId,
socialAccountId,
bodyMd: "Launch update.",
scheduledFor: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
createdByEmployeeId: authorizerId,
});
social.setScheduledPostState({ orgId, scheduledPostId: postId, state: "scheduled", employeeId: authorizerId });
approvalId = (connection.db.prepare("select approval_id from scheduled_posts where id = ?")
.get(postId) as { approval_id: string }).approval_id;
});
const state = snapshot.load();
expect(state.approvalAssignments).toContainEqual(expect.objectContaining({
approvalId,
assigneeType: ROUTE_TO_TYPE.EMPLOYEE,
assigneeId: authorizerId,
routingRuleId: null,
}));
connection.close();
});
test("rejects social actions without account grants", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);