@agenticmail/enterprise 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +183 -0
- package/agenticmail-enterprise.db +0 -0
- package/dashboards/README.md +120 -0
- package/dashboards/dotnet/Program.cs +261 -0
- package/dashboards/express/app.js +146 -0
- package/dashboards/go/main.go +513 -0
- package/dashboards/html/index.html +535 -0
- package/dashboards/java/AgenticMailDashboard.java +376 -0
- package/dashboards/php/index.php +414 -0
- package/dashboards/python/app.py +273 -0
- package/dashboards/ruby/app.rb +195 -0
- package/dist/chunk-77IDQJL3.js +7 -0
- package/dist/chunk-7RGCCHIT.js +115 -0
- package/dist/chunk-DXNKR3TG.js +1355 -0
- package/dist/chunk-IQWA44WT.js +970 -0
- package/dist/chunk-LCUZGIDH.js +965 -0
- package/dist/chunk-N2JVTNNJ.js +2553 -0
- package/dist/chunk-O462UJBH.js +363 -0
- package/dist/chunk-PNKVD2UK.js +26 -0
- package/dist/cli.js +218 -0
- package/dist/dashboard/index.html +558 -0
- package/dist/db-adapter-DEWEFNIV.js +7 -0
- package/dist/dynamodb-CCGL2E77.js +426 -0
- package/dist/engine/index.js +1261 -0
- package/dist/index.js +522 -0
- package/dist/mongodb-ODTXIVPV.js +319 -0
- package/dist/mysql-RM3S2FV5.js +521 -0
- package/dist/postgres-LN7A6MGQ.js +518 -0
- package/dist/routes-2JEPIIKC.js +441 -0
- package/dist/routes-74ZLKJKP.js +399 -0
- package/dist/server.js +7 -0
- package/dist/sqlite-3K5YOZ4K.js +439 -0
- package/dist/turso-LDWODSDI.js +442 -0
- package/package.json +49 -0
- package/src/admin/routes.ts +331 -0
- package/src/auth/routes.ts +130 -0
- package/src/cli.ts +260 -0
- package/src/dashboard/index.html +558 -0
- package/src/db/adapter.ts +230 -0
- package/src/db/dynamodb.ts +456 -0
- package/src/db/factory.ts +51 -0
- package/src/db/mongodb.ts +360 -0
- package/src/db/mysql.ts +472 -0
- package/src/db/postgres.ts +479 -0
- package/src/db/sql-schema.ts +123 -0
- package/src/db/sqlite.ts +391 -0
- package/src/db/turso.ts +411 -0
- package/src/deploy/fly.ts +368 -0
- package/src/deploy/managed.ts +213 -0
- package/src/engine/activity.ts +474 -0
- package/src/engine/agent-config.ts +429 -0
- package/src/engine/agenticmail-bridge.ts +296 -0
- package/src/engine/approvals.ts +278 -0
- package/src/engine/db-adapter.ts +682 -0
- package/src/engine/db-schema.ts +335 -0
- package/src/engine/deployer.ts +595 -0
- package/src/engine/index.ts +134 -0
- package/src/engine/knowledge.ts +486 -0
- package/src/engine/lifecycle.ts +635 -0
- package/src/engine/openclaw-hook.ts +371 -0
- package/src/engine/routes.ts +528 -0
- package/src/engine/skills.ts +473 -0
- package/src/engine/tenant.ts +345 -0
- package/src/engine/tool-catalog.ts +189 -0
- package/src/index.ts +64 -0
- package/src/lib/resilience.ts +326 -0
- package/src/middleware/index.ts +286 -0
- package/src/server.ts +310 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Adapter Interface
|
|
3
|
+
*
|
|
4
|
+
* All enterprise storage goes through this interface.
|
|
5
|
+
* Implementations exist for Postgres, MySQL, MongoDB, SQLite,
|
|
6
|
+
* Turso, DynamoDB, CockroachDB, PlanetScale, Supabase, Neon.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ─── Types ───────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export type DatabaseType =
|
|
12
|
+
| 'postgres' | 'mysql' | 'mongodb' | 'sqlite'
|
|
13
|
+
| 'turso' | 'dynamodb' | 'cockroachdb'
|
|
14
|
+
| 'planetscale' | 'supabase' | 'neon';
|
|
15
|
+
|
|
16
|
+
export interface DatabaseConfig {
|
|
17
|
+
type: DatabaseType;
|
|
18
|
+
connectionString?: string;
|
|
19
|
+
host?: string;
|
|
20
|
+
port?: number;
|
|
21
|
+
database?: string;
|
|
22
|
+
username?: string;
|
|
23
|
+
password?: string;
|
|
24
|
+
ssl?: boolean;
|
|
25
|
+
/** DynamoDB-specific */
|
|
26
|
+
region?: string;
|
|
27
|
+
accessKeyId?: string;
|
|
28
|
+
secretAccessKey?: string;
|
|
29
|
+
/** Turso-specific */
|
|
30
|
+
authToken?: string;
|
|
31
|
+
/** Extra driver options */
|
|
32
|
+
options?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Agent {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
email: string;
|
|
39
|
+
role: string;
|
|
40
|
+
status: 'active' | 'archived' | 'suspended';
|
|
41
|
+
metadata: Record<string, unknown>;
|
|
42
|
+
createdAt: Date;
|
|
43
|
+
updatedAt: Date;
|
|
44
|
+
createdBy: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AgentInput {
|
|
48
|
+
name: string;
|
|
49
|
+
email?: string;
|
|
50
|
+
role?: string;
|
|
51
|
+
metadata?: Record<string, unknown>;
|
|
52
|
+
createdBy: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface User {
|
|
56
|
+
id: string;
|
|
57
|
+
email: string;
|
|
58
|
+
name: string;
|
|
59
|
+
role: 'owner' | 'admin' | 'member' | 'viewer';
|
|
60
|
+
passwordHash?: string;
|
|
61
|
+
ssoProvider?: string;
|
|
62
|
+
ssoSubject?: string;
|
|
63
|
+
createdAt: Date;
|
|
64
|
+
updatedAt: Date;
|
|
65
|
+
lastLoginAt?: Date;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface UserInput {
|
|
69
|
+
email: string;
|
|
70
|
+
name: string;
|
|
71
|
+
role: 'owner' | 'admin' | 'member' | 'viewer';
|
|
72
|
+
password?: string;
|
|
73
|
+
ssoProvider?: string;
|
|
74
|
+
ssoSubject?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AuditEvent {
|
|
78
|
+
id: string;
|
|
79
|
+
timestamp: Date;
|
|
80
|
+
actor: string; // user ID or 'system'
|
|
81
|
+
actorType: 'user' | 'agent' | 'system';
|
|
82
|
+
action: string; // e.g. 'agent.create', 'email.send', 'rule.update'
|
|
83
|
+
resource: string; // e.g. 'agent:abc123'
|
|
84
|
+
details?: Record<string, unknown>;
|
|
85
|
+
ip?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface AuditFilters {
|
|
89
|
+
actor?: string;
|
|
90
|
+
action?: string;
|
|
91
|
+
resource?: string;
|
|
92
|
+
from?: Date;
|
|
93
|
+
to?: Date;
|
|
94
|
+
limit?: number;
|
|
95
|
+
offset?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ApiKey {
|
|
99
|
+
id: string;
|
|
100
|
+
name: string;
|
|
101
|
+
keyHash: string;
|
|
102
|
+
keyPrefix: string; // First 8 chars for display
|
|
103
|
+
scopes: string[];
|
|
104
|
+
createdBy: string;
|
|
105
|
+
createdAt: Date;
|
|
106
|
+
lastUsedAt?: Date;
|
|
107
|
+
expiresAt?: Date;
|
|
108
|
+
revoked: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ApiKeyInput {
|
|
112
|
+
name: string;
|
|
113
|
+
scopes: string[];
|
|
114
|
+
createdBy: string;
|
|
115
|
+
expiresAt?: Date;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface EmailRule {
|
|
119
|
+
id: string;
|
|
120
|
+
name: string;
|
|
121
|
+
agentId?: string; // null = applies to all agents
|
|
122
|
+
conditions: {
|
|
123
|
+
fromContains?: string;
|
|
124
|
+
subjectContains?: string;
|
|
125
|
+
subjectRegex?: string;
|
|
126
|
+
toContains?: string;
|
|
127
|
+
hasAttachment?: boolean;
|
|
128
|
+
};
|
|
129
|
+
actions: {
|
|
130
|
+
moveTo?: string;
|
|
131
|
+
markRead?: boolean;
|
|
132
|
+
delete?: boolean;
|
|
133
|
+
addTags?: string[];
|
|
134
|
+
forwardTo?: string;
|
|
135
|
+
autoReply?: string;
|
|
136
|
+
};
|
|
137
|
+
priority: number;
|
|
138
|
+
enabled: boolean;
|
|
139
|
+
createdAt: Date;
|
|
140
|
+
updatedAt: Date;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface RetentionPolicy {
|
|
144
|
+
enabled: boolean;
|
|
145
|
+
retainDays: number; // Delete emails older than N days
|
|
146
|
+
excludeTags?: string[]; // Don't delete emails with these tags
|
|
147
|
+
archiveFirst: boolean; // Archive before delete
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface CompanySettings {
|
|
151
|
+
id: string;
|
|
152
|
+
name: string;
|
|
153
|
+
domain?: string;
|
|
154
|
+
subdomain: string; // <subdomain>.agenticmail.cloud
|
|
155
|
+
smtpHost?: string;
|
|
156
|
+
smtpPort?: number;
|
|
157
|
+
smtpUser?: string;
|
|
158
|
+
smtpPass?: string;
|
|
159
|
+
dkimPrivateKey?: string;
|
|
160
|
+
logoUrl?: string;
|
|
161
|
+
primaryColor?: string;
|
|
162
|
+
plan: 'free' | 'team' | 'enterprise' | 'self-hosted';
|
|
163
|
+
createdAt: Date;
|
|
164
|
+
updatedAt: Date;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Abstract Adapter ────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
export abstract class DatabaseAdapter {
|
|
170
|
+
abstract readonly type: DatabaseType;
|
|
171
|
+
|
|
172
|
+
// Connection lifecycle
|
|
173
|
+
abstract connect(config: DatabaseConfig): Promise<void>;
|
|
174
|
+
abstract disconnect(): Promise<void>;
|
|
175
|
+
abstract migrate(): Promise<void>;
|
|
176
|
+
abstract isConnected(): boolean;
|
|
177
|
+
|
|
178
|
+
// Company
|
|
179
|
+
abstract getSettings(): Promise<CompanySettings>;
|
|
180
|
+
abstract updateSettings(updates: Partial<CompanySettings>): Promise<CompanySettings>;
|
|
181
|
+
|
|
182
|
+
// Agents
|
|
183
|
+
abstract createAgent(input: AgentInput): Promise<Agent>;
|
|
184
|
+
abstract getAgent(id: string): Promise<Agent | null>;
|
|
185
|
+
abstract getAgentByName(name: string): Promise<Agent | null>;
|
|
186
|
+
abstract listAgents(options?: { status?: Agent['status']; limit?: number; offset?: number }): Promise<Agent[]>;
|
|
187
|
+
abstract updateAgent(id: string, updates: Partial<Agent>): Promise<Agent>;
|
|
188
|
+
abstract archiveAgent(id: string): Promise<void>;
|
|
189
|
+
abstract deleteAgent(id: string): Promise<void>;
|
|
190
|
+
abstract countAgents(status?: Agent['status']): Promise<number>;
|
|
191
|
+
|
|
192
|
+
// Users
|
|
193
|
+
abstract createUser(input: UserInput): Promise<User>;
|
|
194
|
+
abstract getUser(id: string): Promise<User | null>;
|
|
195
|
+
abstract getUserByEmail(email: string): Promise<User | null>;
|
|
196
|
+
abstract getUserBySso(provider: string, subject: string): Promise<User | null>;
|
|
197
|
+
abstract listUsers(options?: { limit?: number; offset?: number }): Promise<User[]>;
|
|
198
|
+
abstract updateUser(id: string, updates: Partial<User>): Promise<User>;
|
|
199
|
+
abstract deleteUser(id: string): Promise<void>;
|
|
200
|
+
|
|
201
|
+
// Audit
|
|
202
|
+
abstract logEvent(event: Omit<AuditEvent, 'id' | 'timestamp'>): Promise<void>;
|
|
203
|
+
abstract queryAudit(filters: AuditFilters): Promise<{ events: AuditEvent[]; total: number }>;
|
|
204
|
+
|
|
205
|
+
// API Keys
|
|
206
|
+
abstract createApiKey(input: ApiKeyInput): Promise<{ key: ApiKey; plaintext: string }>;
|
|
207
|
+
abstract getApiKey(id: string): Promise<ApiKey | null>;
|
|
208
|
+
abstract validateApiKey(plaintext: string): Promise<ApiKey | null>;
|
|
209
|
+
abstract listApiKeys(options?: { createdBy?: string }): Promise<ApiKey[]>;
|
|
210
|
+
abstract revokeApiKey(id: string): Promise<void>;
|
|
211
|
+
|
|
212
|
+
// Email Rules
|
|
213
|
+
abstract createRule(rule: Omit<EmailRule, 'id' | 'createdAt' | 'updatedAt'>): Promise<EmailRule>;
|
|
214
|
+
abstract getRules(agentId?: string): Promise<EmailRule[]>;
|
|
215
|
+
abstract updateRule(id: string, updates: Partial<EmailRule>): Promise<EmailRule>;
|
|
216
|
+
abstract deleteRule(id: string): Promise<void>;
|
|
217
|
+
|
|
218
|
+
// Retention
|
|
219
|
+
abstract getRetentionPolicy(): Promise<RetentionPolicy>;
|
|
220
|
+
abstract setRetentionPolicy(policy: RetentionPolicy): Promise<void>;
|
|
221
|
+
|
|
222
|
+
// Stats
|
|
223
|
+
abstract getStats(): Promise<{
|
|
224
|
+
totalAgents: number;
|
|
225
|
+
activeAgents: number;
|
|
226
|
+
totalUsers: number;
|
|
227
|
+
totalEmails: number;
|
|
228
|
+
totalAuditEvents: number;
|
|
229
|
+
}>;
|
|
230
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB Database Adapter
|
|
3
|
+
*
|
|
4
|
+
* For AWS-native organizations. Uses single-table design
|
|
5
|
+
* with GSIs for efficient access patterns.
|
|
6
|
+
* Requires @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID, createHash } from 'crypto';
|
|
10
|
+
import {
|
|
11
|
+
DatabaseAdapter, DatabaseConfig,
|
|
12
|
+
Agent, AgentInput, User, UserInput,
|
|
13
|
+
AuditEvent, AuditFilters, ApiKey, ApiKeyInput,
|
|
14
|
+
EmailRule, RetentionPolicy, CompanySettings,
|
|
15
|
+
} from './adapter.js';
|
|
16
|
+
|
|
17
|
+
let ddbLib: any;
|
|
18
|
+
let ddbDocLib: any;
|
|
19
|
+
|
|
20
|
+
async function getDdb() {
|
|
21
|
+
if (!ddbLib) {
|
|
22
|
+
try {
|
|
23
|
+
ddbLib = await import('@aws-sdk/client-dynamodb');
|
|
24
|
+
ddbDocLib = await import('@aws-sdk/lib-dynamodb');
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error('DynamoDB drivers not found. Install: npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { ddbLib, ddbDocLib };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TABLE = 'agenticmail_enterprise';
|
|
33
|
+
|
|
34
|
+
// Single-table design: PK = entity type, SK = entity ID
|
|
35
|
+
// GSI1: GSI1PK/GSI1SK for secondary lookups (email, name, etc.)
|
|
36
|
+
|
|
37
|
+
function pk(type: string) { return `${type}`; }
|
|
38
|
+
function sk(id: string) { return id; }
|
|
39
|
+
|
|
40
|
+
export class DynamoAdapter extends DatabaseAdapter {
|
|
41
|
+
readonly type = 'dynamodb' as const;
|
|
42
|
+
private client: any = null;
|
|
43
|
+
private docClient: any = null;
|
|
44
|
+
private tableName = TABLE;
|
|
45
|
+
|
|
46
|
+
async connect(config: DatabaseConfig): Promise<void> {
|
|
47
|
+
const { ddbLib, ddbDocLib } = await getDdb();
|
|
48
|
+
const opts: any = {};
|
|
49
|
+
if (config.region) opts.region = config.region;
|
|
50
|
+
if (config.accessKeyId && config.secretAccessKey) {
|
|
51
|
+
opts.credentials = { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey };
|
|
52
|
+
}
|
|
53
|
+
if (config.connectionString) {
|
|
54
|
+
// Local DynamoDB endpoint
|
|
55
|
+
opts.endpoint = config.connectionString;
|
|
56
|
+
}
|
|
57
|
+
if (config.options?.tableName) this.tableName = config.options.tableName as string;
|
|
58
|
+
|
|
59
|
+
this.client = new ddbLib.DynamoDBClient(opts);
|
|
60
|
+
this.docClient = ddbDocLib.DynamoDBDocumentClient.from(this.client);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async disconnect(): Promise<void> {
|
|
64
|
+
if (this.client) this.client.destroy();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
isConnected(): boolean { return this.client !== null; }
|
|
68
|
+
|
|
69
|
+
private async put(item: any): Promise<void> {
|
|
70
|
+
const { ddbDocLib } = await getDdb();
|
|
71
|
+
await this.docClient.send(new ddbDocLib.PutCommand({ TableName: this.tableName, Item: item }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async getItem(pkVal: string, skVal: string): Promise<any> {
|
|
75
|
+
const { ddbDocLib } = await getDdb();
|
|
76
|
+
const result = await this.docClient.send(new ddbDocLib.GetCommand({
|
|
77
|
+
TableName: this.tableName, Key: { PK: pkVal, SK: skVal },
|
|
78
|
+
}));
|
|
79
|
+
return result.Item || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async query(pkVal: string, opts?: { limit?: number; sk?: { begins?: string }; index?: string; pkField?: string }): Promise<any[]> {
|
|
83
|
+
const { ddbDocLib } = await getDdb();
|
|
84
|
+
const params: any = {
|
|
85
|
+
TableName: this.tableName,
|
|
86
|
+
KeyConditionExpression: '#pk = :pk',
|
|
87
|
+
ExpressionAttributeNames: { '#pk': opts?.pkField || 'PK' },
|
|
88
|
+
ExpressionAttributeValues: { ':pk': pkVal },
|
|
89
|
+
};
|
|
90
|
+
if (opts?.sk?.begins) {
|
|
91
|
+
params.KeyConditionExpression += ' AND begins_with(#sk, :skPrefix)';
|
|
92
|
+
params.ExpressionAttributeNames['#sk'] = 'SK';
|
|
93
|
+
params.ExpressionAttributeValues[':skPrefix'] = opts.sk.begins;
|
|
94
|
+
}
|
|
95
|
+
if (opts?.index) params.IndexName = opts.index;
|
|
96
|
+
if (opts?.limit) params.Limit = opts.limit;
|
|
97
|
+
params.ScanIndexForward = false;
|
|
98
|
+
const result = await this.docClient.send(new ddbDocLib.QueryCommand(params));
|
|
99
|
+
return result.Items || [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async deleteItem(pkVal: string, skVal: string): Promise<void> {
|
|
103
|
+
const { ddbDocLib } = await getDdb();
|
|
104
|
+
await this.docClient.send(new ddbDocLib.DeleteCommand({
|
|
105
|
+
TableName: this.tableName, Key: { PK: pkVal, SK: skVal },
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async migrate(): Promise<void> {
|
|
110
|
+
// Create table if needed (works for local DynamoDB; production tables should be pre-created via IaC)
|
|
111
|
+
const { ddbLib } = await getDdb();
|
|
112
|
+
try {
|
|
113
|
+
await this.client.send(new ddbLib.CreateTableCommand({
|
|
114
|
+
TableName: this.tableName,
|
|
115
|
+
KeySchema: [
|
|
116
|
+
{ AttributeName: 'PK', KeyType: 'HASH' },
|
|
117
|
+
{ AttributeName: 'SK', KeyType: 'RANGE' },
|
|
118
|
+
],
|
|
119
|
+
AttributeDefinitions: [
|
|
120
|
+
{ AttributeName: 'PK', AttributeType: 'S' },
|
|
121
|
+
{ AttributeName: 'SK', AttributeType: 'S' },
|
|
122
|
+
{ AttributeName: 'GSI1PK', AttributeType: 'S' },
|
|
123
|
+
{ AttributeName: 'GSI1SK', AttributeType: 'S' },
|
|
124
|
+
],
|
|
125
|
+
GlobalSecondaryIndexes: [
|
|
126
|
+
{
|
|
127
|
+
IndexName: 'GSI1',
|
|
128
|
+
KeySchema: [
|
|
129
|
+
{ AttributeName: 'GSI1PK', KeyType: 'HASH' },
|
|
130
|
+
{ AttributeName: 'GSI1SK', KeyType: 'RANGE' },
|
|
131
|
+
],
|
|
132
|
+
Projection: { ProjectionType: 'ALL' },
|
|
133
|
+
ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 },
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
137
|
+
}));
|
|
138
|
+
// Wait for table to be active
|
|
139
|
+
const waiter = new ddbLib.DescribeTableCommand({ TableName: this.tableName });
|
|
140
|
+
for (let i = 0; i < 30; i++) {
|
|
141
|
+
const desc = await this.client.send(waiter);
|
|
142
|
+
if (desc.Table?.TableStatus === 'ACTIVE') break;
|
|
143
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
144
|
+
}
|
|
145
|
+
} catch (err: any) {
|
|
146
|
+
if (!err.name?.includes('ResourceInUse') && !err.message?.includes('already exists')) throw err;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Seed defaults
|
|
150
|
+
const existing = await this.getItem(pk('SETTINGS'), 'default');
|
|
151
|
+
if (!existing) {
|
|
152
|
+
await this.put({ PK: pk('SETTINGS'), SK: 'default', name: '', subdomain: '', plan: 'free', primaryColor: '#6366f1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
|
|
153
|
+
}
|
|
154
|
+
const retPol = await this.getItem(pk('RETENTION'), 'default');
|
|
155
|
+
if (!retPol) {
|
|
156
|
+
await this.put({ PK: pk('RETENTION'), SK: 'default', enabled: false, retainDays: 365, excludeTags: [], archiveFirst: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Company ─────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async getSettings(): Promise<CompanySettings> {
|
|
163
|
+
const r = await this.getItem(pk('SETTINGS'), 'default');
|
|
164
|
+
if (!r) return null!;
|
|
165
|
+
return { id: 'default', name: r.name, domain: r.domain, subdomain: r.subdomain, smtpHost: r.smtpHost, smtpPort: r.smtpPort, smtpUser: r.smtpUser, smtpPass: r.smtpPass, dkimPrivateKey: r.dkimPrivateKey, logoUrl: r.logoUrl, primaryColor: r.primaryColor, plan: r.plan, createdAt: new Date(r.createdAt), updatedAt: new Date(r.updatedAt) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async updateSettings(updates: Partial<CompanySettings>): Promise<CompanySettings> {
|
|
169
|
+
const current = await this.getItem(pk('SETTINGS'), 'default') || {};
|
|
170
|
+
const { id, ...rest } = updates as any;
|
|
171
|
+
await this.put({ ...current, ...rest, PK: pk('SETTINGS'), SK: 'default', updatedAt: new Date().toISOString() });
|
|
172
|
+
return this.getSettings();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Agents ──────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
async createAgent(input: AgentInput): Promise<Agent> {
|
|
178
|
+
const id = randomUUID();
|
|
179
|
+
const now = new Date().toISOString();
|
|
180
|
+
const email = input.email || `${input.name.toLowerCase().replace(/\s+/g, '-')}@localhost`;
|
|
181
|
+
const item = {
|
|
182
|
+
PK: pk('AGENT'), SK: id,
|
|
183
|
+
GSI1PK: 'AGENT_NAME', GSI1SK: input.name,
|
|
184
|
+
name: input.name, email, role: input.role || 'assistant', status: 'active',
|
|
185
|
+
metadata: input.metadata || {}, createdBy: input.createdBy, createdAt: now, updatedAt: now,
|
|
186
|
+
};
|
|
187
|
+
await this.put(item);
|
|
188
|
+
return this.itemToAgent(item);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async getAgent(id: string): Promise<Agent | null> {
|
|
192
|
+
const r = await this.getItem(pk('AGENT'), id);
|
|
193
|
+
return r ? this.itemToAgent(r) : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async getAgentByName(name: string): Promise<Agent | null> {
|
|
197
|
+
const items = await this.query('AGENT_NAME', { index: 'GSI1', pkField: 'GSI1PK', sk: { begins: name }, limit: 1 });
|
|
198
|
+
return items.length > 0 ? this.itemToAgent(items[0]) : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async listAgents(opts?: { status?: string; limit?: number; offset?: number }): Promise<Agent[]> {
|
|
202
|
+
// DynamoDB doesn't support offset natively; scan AGENT partition
|
|
203
|
+
const items = await this.query(pk('AGENT'), { limit: (opts?.limit || 50) + (opts?.offset || 0) });
|
|
204
|
+
let result = items.map((r: any) => this.itemToAgent(r));
|
|
205
|
+
if (opts?.status) result = result.filter(a => a.status === opts.status);
|
|
206
|
+
if (opts?.offset) result = result.slice(opts.offset);
|
|
207
|
+
if (opts?.limit) result = result.slice(0, opts.limit);
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async updateAgent(id: string, updates: Partial<Agent>): Promise<Agent> {
|
|
212
|
+
const current = await this.getItem(pk('AGENT'), id);
|
|
213
|
+
if (!current) throw new Error('Agent not found');
|
|
214
|
+
const merged = { ...current, updatedAt: new Date().toISOString() };
|
|
215
|
+
for (const key of ['name', 'email', 'role', 'status', 'metadata']) {
|
|
216
|
+
if ((updates as any)[key] !== undefined) merged[key] = (updates as any)[key];
|
|
217
|
+
}
|
|
218
|
+
if (updates.name) { merged.GSI1SK = updates.name; }
|
|
219
|
+
await this.put(merged);
|
|
220
|
+
return this.itemToAgent(merged);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async archiveAgent(id: string): Promise<void> {
|
|
224
|
+
await this.updateAgent(id, { status: 'archived' } as any);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async deleteAgent(id: string): Promise<void> {
|
|
228
|
+
await this.deleteItem(pk('AGENT'), id);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async countAgents(status?: string): Promise<number> {
|
|
232
|
+
const items = await this.query(pk('AGENT'));
|
|
233
|
+
if (status) return items.filter((i: any) => i.status === status).length;
|
|
234
|
+
return items.length;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Users ───────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
async createUser(input: UserInput): Promise<User> {
|
|
240
|
+
const id = randomUUID();
|
|
241
|
+
const now = new Date().toISOString();
|
|
242
|
+
let passwordHash: string | null = null;
|
|
243
|
+
if (input.password) {
|
|
244
|
+
const { default: bcrypt } = await import('bcryptjs');
|
|
245
|
+
passwordHash = await bcrypt.hash(input.password, 12);
|
|
246
|
+
}
|
|
247
|
+
const item = {
|
|
248
|
+
PK: pk('USER'), SK: id,
|
|
249
|
+
GSI1PK: 'USER_EMAIL', GSI1SK: input.email,
|
|
250
|
+
email: input.email, name: input.name, role: input.role,
|
|
251
|
+
passwordHash, ssoProvider: input.ssoProvider || null, ssoSubject: input.ssoSubject || null,
|
|
252
|
+
createdAt: now, updatedAt: now, lastLoginAt: null,
|
|
253
|
+
};
|
|
254
|
+
await this.put(item);
|
|
255
|
+
return this.itemToUser(item);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async getUser(id: string): Promise<User | null> {
|
|
259
|
+
const r = await this.getItem(pk('USER'), id);
|
|
260
|
+
return r ? this.itemToUser(r) : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async getUserByEmail(email: string): Promise<User | null> {
|
|
264
|
+
const items = await this.query('USER_EMAIL', { index: 'GSI1', pkField: 'GSI1PK', sk: { begins: email }, limit: 1 });
|
|
265
|
+
return items.length > 0 ? this.itemToUser(items[0]) : null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async getUserBySso(provider: string, subject: string): Promise<User | null> {
|
|
269
|
+
// Full scan of USER partition — not ideal but SSO lookups are infrequent
|
|
270
|
+
const items = await this.query(pk('USER'));
|
|
271
|
+
const found = items.find((i: any) => i.ssoProvider === provider && i.ssoSubject === subject);
|
|
272
|
+
return found ? this.itemToUser(found) : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async listUsers(opts?: { limit?: number; offset?: number }): Promise<User[]> {
|
|
276
|
+
const items = await this.query(pk('USER'), { limit: (opts?.limit || 50) + (opts?.offset || 0) });
|
|
277
|
+
let result = items.map((r: any) => this.itemToUser(r));
|
|
278
|
+
if (opts?.offset) result = result.slice(opts.offset);
|
|
279
|
+
if (opts?.limit) result = result.slice(0, opts.limit);
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async updateUser(id: string, updates: Partial<User>): Promise<User> {
|
|
284
|
+
const current = await this.getItem(pk('USER'), id);
|
|
285
|
+
if (!current) throw new Error('User not found');
|
|
286
|
+
const merged = { ...current, updatedAt: new Date().toISOString() };
|
|
287
|
+
for (const key of ['email', 'name', 'role', 'lastLoginAt']) {
|
|
288
|
+
if ((updates as any)[key] !== undefined) merged[key] = (updates as any)[key];
|
|
289
|
+
}
|
|
290
|
+
if (updates.email) { merged.GSI1SK = updates.email; }
|
|
291
|
+
await this.put(merged);
|
|
292
|
+
return this.itemToUser(merged);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async deleteUser(id: string): Promise<void> {
|
|
296
|
+
await this.deleteItem(pk('USER'), id);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── Audit ───────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
async logEvent(event: Omit<AuditEvent, 'id' | 'timestamp'>): Promise<void> {
|
|
302
|
+
const id = randomUUID();
|
|
303
|
+
const now = new Date().toISOString();
|
|
304
|
+
await this.put({
|
|
305
|
+
PK: pk('AUDIT'), SK: `${now}#${id}`,
|
|
306
|
+
GSI1PK: `AUDIT_ACTOR#${event.actor}`, GSI1SK: now,
|
|
307
|
+
id, timestamp: now, actor: event.actor, actorType: event.actorType,
|
|
308
|
+
action: event.action, resource: event.resource, details: event.details || {}, ip: event.ip || null,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async queryAudit(filters: AuditFilters): Promise<{ events: AuditEvent[]; total: number }> {
|
|
313
|
+
let items: any[];
|
|
314
|
+
if (filters.actor) {
|
|
315
|
+
items = await this.query(`AUDIT_ACTOR#${filters.actor}`, { index: 'GSI1', pkField: 'GSI1PK' });
|
|
316
|
+
} else {
|
|
317
|
+
items = await this.query(pk('AUDIT'));
|
|
318
|
+
}
|
|
319
|
+
// Apply filters client-side (DynamoDB limitations)
|
|
320
|
+
if (filters.action) items = items.filter(i => i.action === filters.action);
|
|
321
|
+
if (filters.resource) items = items.filter(i => i.resource?.includes(filters.resource));
|
|
322
|
+
if (filters.from) items = items.filter(i => new Date(i.timestamp) >= filters.from!);
|
|
323
|
+
if (filters.to) items = items.filter(i => new Date(i.timestamp) <= filters.to!);
|
|
324
|
+
const total = items.length;
|
|
325
|
+
if (filters.offset) items = items.slice(filters.offset);
|
|
326
|
+
if (filters.limit) items = items.slice(0, filters.limit);
|
|
327
|
+
return {
|
|
328
|
+
events: items.map(r => ({ id: r.id || r.SK, timestamp: new Date(r.timestamp), actor: r.actor, actorType: r.actorType, action: r.action, resource: r.resource, details: r.details, ip: r.ip })),
|
|
329
|
+
total,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── API Keys ────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
async createApiKey(input: ApiKeyInput): Promise<{ key: ApiKey; plaintext: string }> {
|
|
336
|
+
const id = randomUUID();
|
|
337
|
+
const plaintext = `ek_${randomUUID().replace(/-/g, '')}`;
|
|
338
|
+
const keyHash = createHash('sha256').update(plaintext).digest('hex');
|
|
339
|
+
const keyPrefix = plaintext.substring(0, 11);
|
|
340
|
+
const now = new Date().toISOString();
|
|
341
|
+
const item = {
|
|
342
|
+
PK: pk('APIKEY'), SK: id,
|
|
343
|
+
GSI1PK: 'APIKEY_HASH', GSI1SK: keyHash,
|
|
344
|
+
name: input.name, keyHash, keyPrefix, scopes: input.scopes,
|
|
345
|
+
createdBy: input.createdBy, createdAt: now, lastUsedAt: null,
|
|
346
|
+
expiresAt: input.expiresAt?.toISOString() || null, revoked: false,
|
|
347
|
+
};
|
|
348
|
+
await this.put(item);
|
|
349
|
+
return { key: this.itemToApiKey(item), plaintext };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async getApiKey(id: string): Promise<ApiKey | null> {
|
|
353
|
+
const r = await this.getItem(pk('APIKEY'), id);
|
|
354
|
+
return r ? this.itemToApiKey(r) : null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async validateApiKey(plaintext: string): Promise<ApiKey | null> {
|
|
358
|
+
const keyHash = createHash('sha256').update(plaintext).digest('hex');
|
|
359
|
+
const items = await this.query('APIKEY_HASH', { index: 'GSI1', pkField: 'GSI1PK', sk: { begins: keyHash }, limit: 1 });
|
|
360
|
+
if (items.length === 0 || items[0].revoked) return null;
|
|
361
|
+
const key = this.itemToApiKey(items[0]);
|
|
362
|
+
if (key.expiresAt && new Date() > key.expiresAt) return null;
|
|
363
|
+
items[0].lastUsedAt = new Date().toISOString();
|
|
364
|
+
await this.put(items[0]);
|
|
365
|
+
return key;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async listApiKeys(opts?: { createdBy?: string }): Promise<ApiKey[]> {
|
|
369
|
+
const items = await this.query(pk('APIKEY'));
|
|
370
|
+
let result = items;
|
|
371
|
+
if (opts?.createdBy) result = result.filter((i: any) => i.createdBy === opts.createdBy);
|
|
372
|
+
return result.map((r: any) => this.itemToApiKey(r));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async revokeApiKey(id: string): Promise<void> {
|
|
376
|
+
const current = await this.getItem(pk('APIKEY'), id);
|
|
377
|
+
if (current) { current.revoked = true; await this.put(current); }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── Rules ───────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
async createRule(rule: Omit<EmailRule, 'id' | 'createdAt' | 'updatedAt'>): Promise<EmailRule> {
|
|
383
|
+
const id = randomUUID();
|
|
384
|
+
const now = new Date().toISOString();
|
|
385
|
+
const item = { PK: pk('RULE'), SK: id, ...rule, createdAt: now, updatedAt: now };
|
|
386
|
+
await this.put(item);
|
|
387
|
+
return this.itemToRule(item);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async getRules(agentId?: string): Promise<EmailRule[]> {
|
|
391
|
+
const items = await this.query(pk('RULE'));
|
|
392
|
+
let result = items;
|
|
393
|
+
if (agentId) result = result.filter((i: any) => !i.agentId || i.agentId === agentId);
|
|
394
|
+
return result.map((r: any) => this.itemToRule(r)).sort((a, b) => b.priority - a.priority);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async updateRule(id: string, updates: Partial<EmailRule>): Promise<EmailRule> {
|
|
398
|
+
const current = await this.getItem(pk('RULE'), id);
|
|
399
|
+
if (!current) throw new Error('Rule not found');
|
|
400
|
+
const { id: _id, createdAt, ...rest } = updates as any;
|
|
401
|
+
const merged = { ...current, ...rest, updatedAt: new Date().toISOString() };
|
|
402
|
+
await this.put(merged);
|
|
403
|
+
return this.itemToRule(merged);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async deleteRule(id: string): Promise<void> {
|
|
407
|
+
await this.deleteItem(pk('RULE'), id);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ─── Retention ───────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
async getRetentionPolicy(): Promise<RetentionPolicy> {
|
|
413
|
+
const r = await this.getItem(pk('RETENTION'), 'default');
|
|
414
|
+
if (!r) return { enabled: false, retainDays: 365, archiveFirst: true };
|
|
415
|
+
return { enabled: r.enabled, retainDays: r.retainDays, excludeTags: r.excludeTags || [], archiveFirst: r.archiveFirst };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async setRetentionPolicy(policy: RetentionPolicy): Promise<void> {
|
|
419
|
+
await this.put({ PK: pk('RETENTION'), SK: 'default', ...policy });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ─── Stats ───────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
async getStats() {
|
|
425
|
+
const [agents, users, audit] = await Promise.all([
|
|
426
|
+
this.query(pk('AGENT')),
|
|
427
|
+
this.query(pk('USER')),
|
|
428
|
+
this.query(pk('AUDIT')),
|
|
429
|
+
]);
|
|
430
|
+
return {
|
|
431
|
+
totalAgents: agents.length,
|
|
432
|
+
activeAgents: agents.filter((a: any) => a.status === 'active').length,
|
|
433
|
+
totalUsers: users.length,
|
|
434
|
+
totalEmails: 0,
|
|
435
|
+
totalAuditEvents: audit.length,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── Mappers ─────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
private itemToAgent(r: any): Agent {
|
|
442
|
+
return { id: r.SK || r.id, name: r.name, email: r.email, role: r.role, status: r.status, metadata: r.metadata || {}, createdBy: r.createdBy, createdAt: new Date(r.createdAt), updatedAt: new Date(r.updatedAt) };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private itemToUser(r: any): User {
|
|
446
|
+
return { id: r.SK || r.id, email: r.email, name: r.name, role: r.role, passwordHash: r.passwordHash, ssoProvider: r.ssoProvider, ssoSubject: r.ssoSubject, createdAt: new Date(r.createdAt), updatedAt: new Date(r.updatedAt), lastLoginAt: r.lastLoginAt ? new Date(r.lastLoginAt) : undefined };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private itemToApiKey(r: any): ApiKey {
|
|
450
|
+
return { id: r.SK || r.id, name: r.name, keyHash: r.keyHash, keyPrefix: r.keyPrefix, scopes: r.scopes || [], createdBy: r.createdBy, createdAt: new Date(r.createdAt), lastUsedAt: r.lastUsedAt ? new Date(r.lastUsedAt) : undefined, expiresAt: r.expiresAt ? new Date(r.expiresAt) : undefined, revoked: r.revoked };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private itemToRule(r: any): EmailRule {
|
|
454
|
+
return { id: r.SK || r.id, name: r.name, agentId: r.agentId, conditions: r.conditions || {}, actions: r.actions || {}, priority: r.priority || 0, enabled: r.enabled ?? true, createdAt: new Date(r.createdAt), updatedAt: new Date(r.updatedAt) };
|
|
455
|
+
}
|
|
456
|
+
}
|