Add browser platform governance records
This commit is contained in:
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}`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user