@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.
Files changed (69) hide show
  1. package/ARCHITECTURE.md +183 -0
  2. package/agenticmail-enterprise.db +0 -0
  3. package/dashboards/README.md +120 -0
  4. package/dashboards/dotnet/Program.cs +261 -0
  5. package/dashboards/express/app.js +146 -0
  6. package/dashboards/go/main.go +513 -0
  7. package/dashboards/html/index.html +535 -0
  8. package/dashboards/java/AgenticMailDashboard.java +376 -0
  9. package/dashboards/php/index.php +414 -0
  10. package/dashboards/python/app.py +273 -0
  11. package/dashboards/ruby/app.rb +195 -0
  12. package/dist/chunk-77IDQJL3.js +7 -0
  13. package/dist/chunk-7RGCCHIT.js +115 -0
  14. package/dist/chunk-DXNKR3TG.js +1355 -0
  15. package/dist/chunk-IQWA44WT.js +970 -0
  16. package/dist/chunk-LCUZGIDH.js +965 -0
  17. package/dist/chunk-N2JVTNNJ.js +2553 -0
  18. package/dist/chunk-O462UJBH.js +363 -0
  19. package/dist/chunk-PNKVD2UK.js +26 -0
  20. package/dist/cli.js +218 -0
  21. package/dist/dashboard/index.html +558 -0
  22. package/dist/db-adapter-DEWEFNIV.js +7 -0
  23. package/dist/dynamodb-CCGL2E77.js +426 -0
  24. package/dist/engine/index.js +1261 -0
  25. package/dist/index.js +522 -0
  26. package/dist/mongodb-ODTXIVPV.js +319 -0
  27. package/dist/mysql-RM3S2FV5.js +521 -0
  28. package/dist/postgres-LN7A6MGQ.js +518 -0
  29. package/dist/routes-2JEPIIKC.js +441 -0
  30. package/dist/routes-74ZLKJKP.js +399 -0
  31. package/dist/server.js +7 -0
  32. package/dist/sqlite-3K5YOZ4K.js +439 -0
  33. package/dist/turso-LDWODSDI.js +442 -0
  34. package/package.json +49 -0
  35. package/src/admin/routes.ts +331 -0
  36. package/src/auth/routes.ts +130 -0
  37. package/src/cli.ts +260 -0
  38. package/src/dashboard/index.html +558 -0
  39. package/src/db/adapter.ts +230 -0
  40. package/src/db/dynamodb.ts +456 -0
  41. package/src/db/factory.ts +51 -0
  42. package/src/db/mongodb.ts +360 -0
  43. package/src/db/mysql.ts +472 -0
  44. package/src/db/postgres.ts +479 -0
  45. package/src/db/sql-schema.ts +123 -0
  46. package/src/db/sqlite.ts +391 -0
  47. package/src/db/turso.ts +411 -0
  48. package/src/deploy/fly.ts +368 -0
  49. package/src/deploy/managed.ts +213 -0
  50. package/src/engine/activity.ts +474 -0
  51. package/src/engine/agent-config.ts +429 -0
  52. package/src/engine/agenticmail-bridge.ts +296 -0
  53. package/src/engine/approvals.ts +278 -0
  54. package/src/engine/db-adapter.ts +682 -0
  55. package/src/engine/db-schema.ts +335 -0
  56. package/src/engine/deployer.ts +595 -0
  57. package/src/engine/index.ts +134 -0
  58. package/src/engine/knowledge.ts +486 -0
  59. package/src/engine/lifecycle.ts +635 -0
  60. package/src/engine/openclaw-hook.ts +371 -0
  61. package/src/engine/routes.ts +528 -0
  62. package/src/engine/skills.ts +473 -0
  63. package/src/engine/tenant.ts +345 -0
  64. package/src/engine/tool-catalog.ts +189 -0
  65. package/src/index.ts +64 -0
  66. package/src/lib/resilience.ts +326 -0
  67. package/src/middleware/index.ts +286 -0
  68. package/src/server.ts +310 -0
  69. 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
+ }