Add communication account plugin scaffolds

This commit is contained in:
Anthony Ettinger 2026-06-09 10:02:07 +00:00
parent 5cfeea6b57
commit c23ce42948
48 changed files with 1949 additions and 13 deletions

View file

@ -0,0 +1,43 @@
import type { LogicSrcAccountAuditEvent, LogicSrcAccountKind, LogicSrcPolicyDecision, LogicSrcPrincipal } from "./types.js";
const REDACTED_KEYS = new Set(["accessToken", "refreshToken", "token", "password", "secret", "clientSecret", "authorization"]);
export function redactedPreview(input: Record<string, unknown>) {
const output: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
output[key] = REDACTED_KEYS.has(key) ? "[redacted]" : value;
}
return output;
}
export function createAccountAuditEvent(input: {
id?: string;
accountId?: string;
provider: string;
kind: LogicSrcAccountKind;
principal: LogicSrcPrincipal;
action: string;
decision: LogicSrcPolicyDecision;
riskScore?: number;
requestPreview?: Record<string, unknown>;
resultPreview?: Record<string, unknown>;
correlationId?: string;
createdAt?: string;
}): LogicSrcAccountAuditEvent {
return {
id: input.id ?? `acct_audit_${Date.now()}`,
accountId: input.accountId,
provider: input.provider,
kind: input.kind,
principal: input.principal,
action: input.action,
decision: input.decision,
riskScore: input.riskScore ?? 0,
requestPreview: redactedPreview(input.requestPreview ?? {}),
resultPreview: redactedPreview(input.resultPreview ?? {}),
correlationId: input.correlationId,
createdAt: input.createdAt ?? new Date().toISOString()
};
}

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { createAccountAuditEvent, createProviderRegistry, evaluateAccountPolicy, isPolicyGatedPermission, riskBandForScore, scoreAccountActionRisk } from "./index.js";
describe("account-core", () => {
it("indexes account providers by kind and id", () => {
const registry = createProviderRegistry([
{ id: "gmail", name: "Gmail", kind: "email", authMethods: ["oauth2"], capabilities: ["email.search"] },
{ id: "mastodon", name: "Mastodon", kind: "social", authMethods: ["oauth2"], capabilities: ["social.post.publish"] }
]);
expect(registry.list("email")).toHaveLength(1);
expect(registry.require("mastodon").name).toBe("Mastodon");
});
it("keeps write and private actions policy-gated", () => {
expect(isPolicyGatedPermission("email:send")).toBe(true);
expect(isPolicyGatedPermission("social:profile:read")).toBe(false);
});
it("scores and bands risky account actions", () => {
const score = scoreAccountActionRisk({ action: "email:send", externalRecipientCount: 1, hasAttachment: true, sensitiveKeywordDetected: true });
expect(score).toBe(0.55);
expect(riskBandForScore(score)).toBe("high");
});
it("requires approval for gated actions with matching grants", () => {
const result = evaluateAccountPolicy({
action: "email:send",
principal: { type: "agent", id: "marketing-agent" },
grant: {
id: "grant_1",
accountId: "account_1",
principal: { type: "agent", id: "marketing-agent" },
permissions: ["email:send"],
policy: [],
createdAt: new Date(0).toISOString()
}
});
expect(result.decision).toBe("approval_required");
});
it("redacts secret-like audit previews", () => {
const event = createAccountAuditEvent({
provider: "gmail",
kind: "email",
principal: { type: "user", id: "user_1" },
action: "accounts:connect",
decision: "allow",
requestPreview: { accessToken: "raw-token", provider: "gmail" }
});
expect(event.requestPreview.accessToken).toBe("[redacted]");
expect(event.requestPreview.provider).toBe("gmail");
});
});

View file

@ -0,0 +1,13 @@
export * from "./types.js";
export {
ACCOUNT_PERMISSIONS,
EMAIL_PERMISSIONS,
POLICY_GATED_PERMISSIONS,
SOCIAL_PERMISSIONS,
SHARED_ACCOUNT_PERMISSIONS,
accountPermissionList,
isPolicyGatedPermission
} from "./permissions.js";
export { createProviderRegistry } from "./provider-registry.js";
export { createAccountAuditEvent, redactedPreview } from "./audit.js";
export { evaluateAccountPolicy, riskBandForScore, scoreAccountActionRisk } from "./policy.js";

View file

@ -0,0 +1,59 @@
export const SHARED_ACCOUNT_PERMISSIONS = [
"accounts:connect",
"accounts:list",
"accounts:read_metadata",
"accounts:test",
"accounts:revoke",
"accounts:sync",
"accounts:audit:read"
] as const;
export const SOCIAL_PERMISSIONS = [
"social:profile:read",
"social:post:draft",
"social:post:publish",
"social:post:delete",
"social:media:upload",
"social:mentions:read",
"social:comments:read",
"social:dm:read",
"social:dm:send",
"social:analytics:read"
] as const;
export const EMAIL_PERMISSIONS = [
"email:headers:read",
"email:body:read",
"email:attachments:read",
"email:search",
"email:draft",
"email:send",
"email:reply",
"email:forward",
"email:archive",
"email:labels:modify",
"email:delete",
"email:sync"
] as const;
export const ACCOUNT_PERMISSIONS = [...SHARED_ACCOUNT_PERMISSIONS, ...SOCIAL_PERMISSIONS, ...EMAIL_PERMISSIONS] as const;
export const POLICY_GATED_PERMISSIONS = [
"social:post:publish",
"social:post:delete",
"social:dm:read",
"social:dm:send",
"email:attachments:read",
"email:send",
"email:delete"
] as const;
export type LogicSrcAccountPermission = (typeof ACCOUNT_PERMISSIONS)[number];
export function accountPermissionList() {
return [...ACCOUNT_PERMISSIONS];
}
export function isPolicyGatedPermission(permission: string) {
return (POLICY_GATED_PERMISSIONS as readonly string[]).includes(permission);
}

View file

@ -0,0 +1,92 @@
import { isPolicyGatedPermission } from "./permissions.js";
import type { LogicSrcPolicyEvaluationInput, LogicSrcPolicyEvaluationResult, LogicSrcRiskBand } from "./types.js";
const WRITE_ACTIONS = new Set([
"social:post:publish",
"social:post:delete",
"social:dm:send",
"email:send",
"email:reply",
"email:forward",
"email:delete",
"email:labels:modify",
"email:archive"
]);
export function riskBandForScore(score: number): LogicSrcRiskBand {
if (score >= 0.75) {
return "critical";
}
if (score >= 0.5) {
return "high";
}
if (score >= 0.25) {
return "medium";
}
return "low";
}
export function scoreAccountActionRisk(input: {
action: string;
externalRecipientCount?: number;
newRecipientOrDomain?: boolean;
hasAttachment?: boolean;
highReachAccount?: boolean;
sensitiveKeywordDetected?: boolean;
rawCredentialAccessAttempted?: boolean;
}) {
let score = 0;
if (input.externalRecipientCount && input.externalRecipientCount > 0) score += 0.1;
if (input.newRecipientOrDomain) score += 0.15;
if (input.hasAttachment) score += 0.15;
if (input.action === "social:post:publish") score += 0.2;
if (input.action.includes("delete")) score += 0.2;
if (input.action.includes("dm:read") || input.action.includes("attachments:read")) score += 0.25;
if (input.highReachAccount) score += 0.25;
if (input.sensitiveKeywordDetected) score += 0.3;
if (input.rawCredentialAccessAttempted) score += 0.4;
return Math.min(1, Number(score.toFixed(2)));
}
export function evaluateAccountPolicy(input: LogicSrcPolicyEvaluationInput): LogicSrcPolicyEvaluationResult {
const riskScore = Math.min(1, Math.max(0, input.riskScore ?? scoreAccountActionRisk({ action: input.action })));
const grantActive = input.grant && !input.grant.revokedAt && (!input.grant.expiresAt || Date.parse(input.grant.expiresAt) > Date.now());
const hasPermission = Boolean(grantActive && input.grant?.permissions.includes(input.action));
if (!hasPermission) {
return { decision: "deny", riskScore, reason: `missing grant for ${input.action}` };
}
if (input.dryRun) {
return { decision: "allow", riskScore, reason: "dry run with matching grant" };
}
const policy = input.grant?.policy.find((entry) => entry.action === input.action);
if (policy?.default === "deny") {
return { decision: "deny", riskScore, reason: `policy ${policy.id} denies ${input.action}` };
}
if (policy?.default === "approval_required") {
return { decision: "approval_required", riskScore, reason: `policy ${policy.id} requires approval` };
}
if (policy?.default === "allow_if_trusted_agent" && input.principal?.trusted) {
return { decision: "allow", riskScore, reason: `policy ${policy.id} allows trusted principal` };
}
if (policy?.default === "allow_if_below_risk_score") {
const maxRiskScore = typeof policy.conditions?.maxRiskScore === "number" ? policy.conditions.maxRiskScore : 0.25;
return riskScore <= maxRiskScore
? { decision: "allow", riskScore, reason: `risk score is within policy ${policy.id}` }
: { decision: "approval_required", riskScore, reason: `risk score exceeds policy ${policy.id}` };
}
const band = riskBandForScore(riskScore);
if (band === "critical") {
return { decision: "deny", riskScore, reason: "critical risk requires admin override" };
}
if (isPolicyGatedPermission(input.action) || (WRITE_ACTIONS.has(input.action) && band !== "low")) {
return { decision: "approval_required", riskScore, reason: "default gate for risky account action" };
}
return { decision: "allow", riskScore, reason: "grant allows account action" };
}

View file

@ -0,0 +1,28 @@
import type { LogicSrcAccountKind, LogicSrcAccountProviderManifest } from "./types.js";
export function createProviderRegistry(providers: LogicSrcAccountProviderManifest[]) {
const byId = new Map<string, LogicSrcAccountProviderManifest>();
for (const provider of providers) {
if (byId.has(provider.id)) {
throw new Error(`Duplicate account provider: ${provider.id}`);
}
byId.set(provider.id, provider);
}
return {
list(kind?: LogicSrcAccountKind) {
return providers.filter((provider) => !kind || provider.kind === kind);
},
get(id: string) {
return byId.get(id);
},
require(id: string) {
const provider = byId.get(id);
if (!provider) {
throw new Error(`Unknown account provider: ${id}`);
}
return provider;
}
};
}

View file

@ -0,0 +1,447 @@
export type LogicSrcAccountKind = "social" | "email";
export type LogicSrcAccountStatus = "connected" | "expired" | "revoked" | "error" | "disabled" | "pending";
export type LogicSrcAccountAuthMethod = "oauth2" | "api_key" | "imap_smtp" | "local_bridge";
export type LogicSrcPrincipalType = "user" | "agent" | "workflow" | "plugin";
export type LogicSrcPolicyMode =
| "allow"
| "approval_required"
| "deny"
| "allow_if_dry_run"
| "allow_if_trusted_agent"
| "allow_if_below_risk_score";
export type LogicSrcPolicyDecision = "allow" | "approval_required" | "deny";
export type LogicSrcRiskBand = "low" | "medium" | "high" | "critical";
export interface LogicSrcConnectedAccount {
id: string;
orgId?: string;
projectId?: string;
boardId?: string;
ownerUserId: string;
kind: LogicSrcAccountKind;
provider: string;
providerAccountId?: string;
displayName: string;
handle?: string;
email?: string;
avatarUrl?: string;
homepageUrl?: string;
status: LogicSrcAccountStatus;
scopes: string[];
capabilities: string[];
credentialRef: string;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
lastSyncedAt?: string;
}
export interface LogicSrcAccountProviderManifest {
id: string;
name: string;
kind: LogicSrcAccountKind;
authMethods: LogicSrcAccountAuthMethod[];
capabilities: string[];
defaultScopes?: string[];
status?: "available" | "planned" | "disabled";
docsUrl?: string;
}
export interface LogicSrcAccountProvider extends LogicSrcAccountProviderManifest {
getAuthUrl?(input: AuthUrlInput): Promise<AuthUrlResult>;
completeAuth?(input: CompleteAuthInput): Promise<ConnectedAccountResult>;
refreshCredential?(input: RefreshCredentialInput): Promise<CredentialRefreshResult>;
testConnection(input: TestConnectionInput): Promise<TestConnectionResult>;
revoke?(input: RevokeAccountInput): Promise<RevokeAccountResult>;
}
export interface SocialAccountProvider extends LogicSrcAccountProvider {
kind: "social";
getProfile(input: SocialAccountInput): Promise<SocialProfile>;
draftPost(input: DraftSocialPostInput): Promise<SocialDraft>;
publishPost(input: PublishSocialPostInput): Promise<PublishedSocialPost>;
uploadMedia?(input: UploadSocialMediaInput): Promise<UploadedMedia>;
searchMentions?(input: SearchMentionsInput): Promise<SocialMention[]>;
listComments?(input: ListCommentsInput): Promise<SocialComment[]>;
getAnalytics?(input: SocialAnalyticsInput): Promise<SocialAnalyticsResult>;
}
export interface EmailAccountProvider extends LogicSrcAccountProvider {
kind: "email";
searchMessages(input: EmailSearchInput): Promise<EmailSearchResult>;
readMessage(input: ReadEmailMessageInput): Promise<EmailMessage>;
draftMessage(input: DraftEmailInput): Promise<EmailDraft>;
sendMessage(input: SendEmailInput): Promise<SentEmailResult>;
replyToMessage?(input: ReplyEmailInput): Promise<SentEmailResult>;
forwardMessage?(input: ForwardEmailInput): Promise<SentEmailResult>;
archiveMessage?(input: EmailMessageMutationInput): Promise<EmailMutationResult>;
applyLabels?(input: EmailLabelInput): Promise<EmailMutationResult>;
deleteMessage?(input: EmailMessageMutationInput): Promise<EmailMutationResult>;
}
export interface AuthUrlInput {
redirectUri: string;
state: string;
scopes: string[];
metadata?: Record<string, unknown>;
}
export interface AuthUrlResult {
url: string;
state: string;
expiresAt?: string;
}
export interface CompleteAuthInput {
code: string;
redirectUri: string;
state: string;
principal: LogicSrcPrincipal;
}
export interface ConnectedAccountResult {
account: LogicSrcConnectedAccount;
auditEvent: LogicSrcAccountAuditEvent;
}
export interface RefreshCredentialInput {
account: LogicSrcConnectedAccount;
broker: LogicSrcCredentialBroker;
}
export interface CredentialRefreshResult {
credentialRef: string;
scopes: string[];
expiresAt?: string;
}
export interface TestConnectionInput {
account: LogicSrcConnectedAccount;
broker: LogicSrcCredentialBroker;
}
export interface TestConnectionResult {
ok: boolean;
provider: string;
checkedAt: string;
message?: string;
}
export interface RevokeAccountInput {
account: LogicSrcConnectedAccount;
broker: LogicSrcCredentialBroker;
principal: LogicSrcPrincipal;
}
export interface RevokeAccountResult {
ok: boolean;
revokedAt: string;
}
export interface LogicSrcPrincipal {
type: LogicSrcPrincipalType;
id: string;
trusted?: boolean;
}
export interface LogicSrcAccountPermissionGrant {
id: string;
accountId: string;
principal: LogicSrcPrincipal;
permissions: string[];
policy: LogicSrcAccountPolicy[];
expiresAt?: string;
createdBy?: string;
createdAt: string;
revokedAt?: string;
}
export interface LogicSrcAccountPolicy {
id: string;
resource: string;
action: string;
default: LogicSrcPolicyMode;
conditions?: Record<string, unknown>;
}
export interface LogicSrcPolicyEvaluationInput {
action: string;
grant?: LogicSrcAccountPermissionGrant;
riskScore?: number;
dryRun?: boolean;
principal?: LogicSrcPrincipal;
}
export interface LogicSrcPolicyEvaluationResult {
decision: LogicSrcPolicyDecision;
riskScore: number;
reason: string;
}
export interface LogicSrcCredentialBroker {
getCredential(input: CredentialBrokerGetInput): Promise<CredentialBrokerGetResult>;
storeCredential(input: CredentialBrokerStoreInput): Promise<CredentialBrokerStoreResult>;
revokeCredential(input: CredentialBrokerRevokeInput): Promise<CredentialBrokerRevokeResult>;
}
export interface CredentialBrokerGetInput {
credentialRef: string;
accountId: string;
provider: string;
purpose: string;
principal: LogicSrcPrincipal;
}
export interface CredentialBrokerGetResult {
credentialRef: string;
token: string;
expiresAt?: string;
}
export interface CredentialBrokerStoreInput {
provider: string;
kind: LogicSrcAccountKind;
scopes: string[];
secret: string;
metadata?: Record<string, unknown>;
}
export interface CredentialBrokerStoreResult {
credentialRef: string;
}
export interface CredentialBrokerRevokeInput {
credentialRef: string;
accountId: string;
provider: string;
principal: LogicSrcPrincipal;
}
export interface CredentialBrokerRevokeResult {
ok: boolean;
revokedAt: string;
}
export interface LogicSrcAccountAuditEvent {
id: string;
accountId?: string;
provider: string;
kind: LogicSrcAccountKind;
principal: LogicSrcPrincipal;
action: string;
decision: LogicSrcPolicyDecision;
riskScore: number;
requestPreview: Record<string, unknown>;
resultPreview: Record<string, unknown>;
correlationId?: string;
createdAt: string;
}
export interface SocialAccountInput {
account: LogicSrcConnectedAccount;
broker: LogicSrcCredentialBroker;
}
export interface SocialProfile {
providerAccountId: string;
displayName: string;
handle?: string;
avatarUrl?: string;
homepageUrl?: string;
metadata?: Record<string, unknown>;
}
export interface DraftSocialPostInput extends SocialAccountInput {
text: string;
media?: SocialMediaInput[];
principal: LogicSrcPrincipal;
}
export interface PublishSocialPostInput extends DraftSocialPostInput {
dryRun?: boolean;
approvalId?: string;
}
export interface SocialDraft {
id: string;
accountId: string;
text: string;
media: SocialMediaInput[];
createdAt: string;
}
export interface PublishedSocialPost {
providerPostId?: string;
url?: string;
publishedAt: string;
dryRun?: boolean;
}
export interface SocialMediaInput {
url?: string;
fileRef?: string;
altText?: string;
mimeType?: string;
}
export interface UploadSocialMediaInput extends SocialAccountInput {
media: SocialMediaInput;
}
export interface UploadedMedia {
providerMediaId: string;
url?: string;
}
export interface SearchMentionsInput extends SocialAccountInput {
query?: string;
limit?: number;
}
export interface SocialMention {
id: string;
text: string;
authorHandle?: string;
url?: string;
createdAt?: string;
}
export interface ListCommentsInput extends SocialAccountInput {
postId: string;
limit?: number;
}
export interface SocialComment extends SocialMention {
postId: string;
}
export interface SocialAnalyticsInput extends SocialAccountInput {
postId?: string;
since?: string;
until?: string;
}
export interface SocialAnalyticsResult {
metrics: Record<string, number>;
metadata?: Record<string, unknown>;
}
export interface EmailSearchInput {
account: LogicSrcConnectedAccount;
broker: LogicSrcCredentialBroker;
query: string;
limit?: number;
headersOnly?: boolean;
principal: LogicSrcPrincipal;
}
export interface EmailSearchResult {
messages: EmailMessageMetadata[];
nextPageToken?: string;
}
export interface EmailMessageMetadata {
id: string;
providerMessageId: string;
threadId?: string;
subject?: string;
fromAddress?: string;
toAddresses: string[];
ccAddresses: string[];
snippet?: string;
labels: string[];
hasAttachments: boolean;
receivedAt?: string;
sentAt?: string;
}
export interface ReadEmailMessageInput {
account: LogicSrcConnectedAccount;
broker: LogicSrcCredentialBroker;
messageId: string;
includeBody?: boolean;
includeAttachments?: boolean;
principal: LogicSrcPrincipal;
}
export interface EmailMessage extends EmailMessageMetadata {
bodyText?: string;
bodyHtml?: string;
attachments?: EmailAttachment[];
}
export interface EmailAttachment {
id: string;
filename: string;
mimeType: string;
sizeBytes?: number;
contentRef?: string;
}
export interface DraftEmailInput {
account: LogicSrcConnectedAccount;
to: string[];
cc?: string[];
bcc?: string[];
subject: string;
bodyText?: string;
bodyHtml?: string;
attachmentRefs?: string[];
principal: LogicSrcPrincipal;
}
export interface EmailDraft {
id: string;
accountId: string;
to: string[];
cc: string[];
bcc: string[];
subject: string;
bodyPreview: string;
attachmentRefs: string[];
createdAt: string;
}
export interface SendEmailInput extends DraftEmailInput {
draftId?: string;
dryRun?: boolean;
approvalId?: string;
}
export interface SentEmailResult {
providerMessageId?: string;
sentAt: string;
dryRun?: boolean;
}
export interface ReplyEmailInput extends SendEmailInput {
messageId: string;
}
export interface ForwardEmailInput extends SendEmailInput {
messageId: string;
}
export interface EmailMessageMutationInput {
account: LogicSrcConnectedAccount;
broker: LogicSrcCredentialBroker;
messageId: string;
principal: LogicSrcPrincipal;
}
export interface EmailLabelInput extends EmailMessageMutationInput {
add?: string[];
remove?: string[];
}
export interface EmailMutationResult {
ok: boolean;
messageId: string;
changedAt: string;
}