@taodigital/app-brain 0.0.0-stage → 1.2.0

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 (47) hide show
  1. package/README.md +13 -2
  2. package/dist/archetype-classification.d.ts +54 -0
  3. package/dist/archetype-classification.js +95 -0
  4. package/dist/cloud-storage.d.ts +106 -0
  5. package/dist/cloud-storage.js +539 -0
  6. package/dist/email.d.ts +21 -0
  7. package/dist/email.js +234 -0
  8. package/dist/grounding.d.ts +30 -0
  9. package/dist/grounding.js +138 -0
  10. package/dist/index.d.ts +13 -0
  11. package/dist/index.js +37 -0
  12. package/dist/nav-graph.d.ts +42 -0
  13. package/dist/nav-graph.js +248 -0
  14. package/dist/notifications.d.ts +26 -0
  15. package/dist/notifications.js +105 -0
  16. package/dist/orphan-classification.d.ts +32 -0
  17. package/dist/orphan-classification.js +40 -0
  18. package/dist/repository.d.ts +3185 -0
  19. package/dist/repository.js +5927 -0
  20. package/dist/run-notifications.d.ts +16 -0
  21. package/dist/run-notifications.js +244 -0
  22. package/dist/skill-file-kinds.d.ts +5 -0
  23. package/dist/skill-file-kinds.js +50 -0
  24. package/dist/token-ledger.d.ts +23 -0
  25. package/dist/token-ledger.js +178 -0
  26. package/dist/traceability.d.ts +213 -0
  27. package/dist/traceability.js +339 -0
  28. package/package.json +24 -4
  29. package/src/archetype-classification.test.ts +91 -0
  30. package/src/archetype-classification.ts +132 -0
  31. package/src/cloud-storage.test.ts +83 -0
  32. package/src/cloud-storage.ts +627 -0
  33. package/src/email.ts +268 -0
  34. package/src/grounding.ts +172 -0
  35. package/src/index.ts +18 -0
  36. package/src/nav-graph.ts +295 -0
  37. package/src/notifications.ts +156 -0
  38. package/src/orphan-classification.test.ts +61 -0
  39. package/src/orphan-classification.ts +65 -0
  40. package/src/orphan-screens.test.ts +88 -0
  41. package/src/repository.ts +7397 -0
  42. package/src/run-notifications.ts +281 -0
  43. package/src/skill-file-kinds.ts +51 -0
  44. package/src/token-ledger.ts +209 -0
  45. package/src/traceability.test.ts +75 -0
  46. package/src/traceability.ts +477 -0
  47. package/tsconfig.json +9 -0
@@ -0,0 +1,539 @@
1
+ "use strict";
2
+ /**
3
+ * Cloud Storage integration for ZeTa-AI artifacts.
4
+ *
5
+ * Folder convention:
6
+ * {prefix}/{tenantId}/{projectId}/screenshots/{filename}
7
+ * {prefix}/{tenantId}/{projectId}/test-plans/{filename}
8
+ * {prefix}/{tenantId}/{projectId}/test-data/{filename}
9
+ * {prefix}/{tenantId}/{projectId}/reports/{runId}/{filename}
10
+ *
11
+ * Industry reference: Playwright Cloud, BrowserStack, Cypress Cloud all use
12
+ * tenant→project→type→artifact path hierarchies for multi-tenant isolation.
13
+ */
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.getTenantStorageConfig = getTenantStorageConfig;
19
+ exports.uploadScreenshotToTenantStorage = uploadScreenshotToTenantStorage;
20
+ exports.uploadFileToTenantStorage = uploadFileToTenantStorage;
21
+ exports.resolveCloudUrl = resolveCloudUrl;
22
+ exports.getProjectStorageConfig = getProjectStorageConfig;
23
+ exports.buildArtifactKey = buildArtifactKey;
24
+ exports.uploadArtifact = uploadArtifact;
25
+ exports.uploadArtifactBuffer = uploadArtifactBuffer;
26
+ exports.generatePresignedGetUrl = generatePresignedGetUrl;
27
+ exports.downloadBufferFromTenantStorage = downloadBufferFromTenantStorage;
28
+ exports.uploadBufferToTenantStorage = uploadBufferToTenantStorage;
29
+ exports.buildKnowledgeKey = buildKnowledgeKey;
30
+ exports.listProjectStorageObjects = listProjectStorageObjects;
31
+ exports.deleteProjectStorageObjects = deleteProjectStorageObjects;
32
+ exports.deleteTenantStorageObjects = deleteTenantStorageObjects;
33
+ exports.buildContentKey = buildContentKey;
34
+ exports.persistContent = persistContent;
35
+ exports.resolveContent = resolveContent;
36
+ const database_1 = require("@taodigital/database");
37
+ const core_1 = require("@taodigital/core");
38
+ const node_fs_1 = require("node:fs");
39
+ const node_path_1 = __importDefault(require("node:path"));
40
+ function decryptConfig(raw) {
41
+ const out = {};
42
+ for (const [key, value] of Object.entries(raw ?? {})) {
43
+ if (key.endsWith('_enc') && typeof value === 'string') {
44
+ try {
45
+ out[key.replace('_enc', '')] = (0, core_1.decrypt)(value);
46
+ }
47
+ catch {
48
+ out[key.replace('_enc', '')] = '';
49
+ }
50
+ }
51
+ else {
52
+ out[key] = value;
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+ /** Resolve the platform-managed default bucket from Platform Admin config first,
58
+ * then env vars (AWS_S3_BUCKET / PLATFORM_S3_BUCKET). Used as a fallback for
59
+ * tenants who haven't set up their own storage connector. accessKeyId/
60
+ * secretAccessKey are optional: when unset, buildS3Client() falls back to the
61
+ * AWS SDK's default credential provider chain (e.g. an ECS task IAM role). */
62
+ async function getPlatformStorageConfig() {
63
+ const adminCfg = await (0, core_1.getSystemConfig)('storage').catch(() => ({}));
64
+ const p = core_1.config.platformStorage;
65
+ const bucket = adminCfg.bucket || p.bucket;
66
+ const accountId = adminCfg.accountId || p.accountId;
67
+ const providerRaw = (adminCfg.provider || p.provider || (accountId ? 'R2' : bucket ? 'S3' : '')).toUpperCase();
68
+ const provider = providerRaw === 'R2' ? 'R2' : providerRaw === 'S3' ? 'S3' : null;
69
+ if (!provider || !bucket)
70
+ return null;
71
+ return {
72
+ provider,
73
+ bucket,
74
+ region: adminCfg.region || p.region,
75
+ accountId,
76
+ endpointUrl: adminCfg.endpointUrl || p.endpointUrl,
77
+ accessKeyId: adminCfg.accessKeyId || p.accessKeyId || '',
78
+ secretAccessKey: adminCfg.secretAccessKey || p.secretAccessKey || '',
79
+ publicUrl: adminCfg.publicUrl || p.publicUrl,
80
+ prefix: adminCfg.prefix || p.prefix,
81
+ };
82
+ }
83
+ /**
84
+ * Resolves the storage config to use for a tenant's screenshots/artifacts:
85
+ * the tenant's own connector if they've configured one, otherwise the
86
+ * platform-managed default bucket (if configured via env vars), otherwise
87
+ * null — meaning the caller should leave the artifact on local disk.
88
+ */
89
+ async function getTenantStorageConfig(tenantId) {
90
+ const { withTenant: wt } = await import('@taodigital/database');
91
+ const setting = await wt(tenantId, (tx) => tx.tenantSetting.findUnique({ where: { tenantId_category: { tenantId, category: 'STORAGE' } } }));
92
+ const raw = setting?.config;
93
+ if (raw?.bucket && raw?.accessKeyId && raw?.secretAccessKey_enc) {
94
+ try {
95
+ return {
96
+ provider: raw.provider ?? 'S3',
97
+ bucket: raw.bucket,
98
+ region: raw.region ?? undefined,
99
+ accountId: raw.accountId ?? undefined,
100
+ endpointUrl: raw.endpointUrl ?? undefined,
101
+ accessKeyId: raw.accessKeyId,
102
+ secretAccessKey: (0, core_1.decrypt)(raw.secretAccessKey_enc),
103
+ publicUrl: raw.publicUrl ?? undefined,
104
+ prefix: raw.prefix ?? undefined,
105
+ };
106
+ }
107
+ catch { /* fall through to platform default below */ }
108
+ }
109
+ return getPlatformStorageConfig();
110
+ }
111
+ /**
112
+ * Upload a screenshot to tenant-level S3/R2 storage.
113
+ * Returns public URL if publicUrl configured, otherwise returns the storage key
114
+ * (callers should use resolveCloudUrl() to generate presigned URLs on demand).
115
+ */
116
+ async function uploadScreenshotToTenantStorage(cfg, localPath, screenshotKey) {
117
+ const isR2 = cfg.provider === 'R2';
118
+ const creds = {
119
+ bucket: cfg.bucket,
120
+ region: cfg.region ?? 'auto',
121
+ accountId: cfg.accountId ?? '',
122
+ endpointUrl: cfg.endpointUrl ?? '',
123
+ accessKeyId: cfg.accessKeyId,
124
+ secretAccessKey: cfg.secretAccessKey,
125
+ };
126
+ await s3Upload(creds, localPath, screenshotKey, 'image/png', isR2);
127
+ if (cfg.publicUrl) {
128
+ const base = cfg.publicUrl.replace(/\/$/, '');
129
+ return `${base}/${screenshotKey}`;
130
+ }
131
+ return screenshotKey;
132
+ }
133
+ /**
134
+ * Upload any file to tenant-level S3/R2 storage.
135
+ * Returns public URL if publicUrl configured, otherwise returns the storage key.
136
+ */
137
+ async function uploadFileToTenantStorage(cfg, localPath, storageKey, contentType) {
138
+ const isR2 = cfg.provider === 'R2';
139
+ const creds = {
140
+ bucket: cfg.bucket,
141
+ region: cfg.region ?? 'auto',
142
+ accountId: cfg.accountId ?? '',
143
+ endpointUrl: cfg.endpointUrl ?? '',
144
+ accessKeyId: cfg.accessKeyId,
145
+ secretAccessKey: cfg.secretAccessKey,
146
+ };
147
+ await s3Upload(creds, localPath, storageKey, contentType, isR2);
148
+ if (cfg.publicUrl) {
149
+ const base = cfg.publicUrl.replace(/\/$/, '');
150
+ return `${base}/${storageKey}`;
151
+ }
152
+ return storageKey;
153
+ }
154
+ /**
155
+ * Resolve a stored cloudUrl to a usable URL for the browser.
156
+ * - If it starts with https:// → return as-is (public URL, no expiry).
157
+ * - Otherwise → treat as storage key and generate a presigned GET URL.
158
+ */
159
+ async function resolveCloudUrl(cfg, cloudUrl, expiresIn = 3600) {
160
+ if (cloudUrl.startsWith('https://') || cloudUrl.startsWith('http://')) {
161
+ return cloudUrl;
162
+ }
163
+ return generatePresignedGetUrl(cfg, cloudUrl, expiresIn);
164
+ }
165
+ async function getProjectStorageConfig(tenantId, projectId) {
166
+ const connector = await (0, database_1.withTenant)(tenantId, (tx) => tx.connector.findFirst({
167
+ where: { tenantId, projectId, kind: { in: ['S3', 'R2', 'GDRIVE', 'DROPBOX'] } },
168
+ }));
169
+ if (connector) {
170
+ const creds = decryptConfig(connector.config);
171
+ return { kind: connector.kind, creds, prefix: creds.prefix ?? '' };
172
+ }
173
+ const tenantCfg = await getTenantStorageConfig(tenantId);
174
+ if (!tenantCfg)
175
+ return null;
176
+ return {
177
+ kind: tenantCfg.provider,
178
+ prefix: tenantCfg.prefix ?? '',
179
+ creds: {
180
+ bucket: tenantCfg.bucket,
181
+ region: tenantCfg.region ?? '',
182
+ accountId: tenantCfg.accountId ?? '',
183
+ endpointUrl: tenantCfg.endpointUrl ?? '',
184
+ accessKeyId: tenantCfg.accessKeyId,
185
+ secretAccessKey: tenantCfg.secretAccessKey,
186
+ publicUrl: tenantCfg.publicUrl ?? '',
187
+ prefix: tenantCfg.prefix ?? '',
188
+ },
189
+ };
190
+ }
191
+ /** Build the cloud storage key for an artifact. */
192
+ function buildArtifactKey(tenantId, projectId, type, filename, prefix = '') {
193
+ return [prefix, tenantId, projectId, type, filename].filter(Boolean).join('/');
194
+ }
195
+ /** Upload a single file to the configured cloud storage provider. Returns storage key/path. */
196
+ async function uploadArtifact(cfg, localPath, storageKey, contentType = 'application/octet-stream') {
197
+ switch (cfg.kind) {
198
+ case 'S3': return s3Upload(cfg.creds, localPath, storageKey, contentType, false);
199
+ case 'R2': return s3Upload(cfg.creds, localPath, storageKey, contentType, true);
200
+ case 'GDRIVE': return gdriveUpload(cfg.creds, localPath, storageKey, contentType);
201
+ case 'DROPBOX': return dropboxUpload(cfg.creds, localPath, storageKey);
202
+ default: throw new Error(`Unknown storage provider: ${cfg.kind}`);
203
+ }
204
+ }
205
+ /** Upload a Buffer (for generated content like test plans, scripts). */
206
+ async function uploadArtifactBuffer(cfg, buffer, storageKey, contentType = 'text/plain; charset=utf-8') {
207
+ switch (cfg.kind) {
208
+ case 'S3': return s3UploadBuffer(cfg.creds, buffer, storageKey, contentType, false);
209
+ case 'R2': return s3UploadBuffer(cfg.creds, buffer, storageKey, contentType, true);
210
+ case 'GDRIVE': return gdriveUploadBuffer(cfg.creds, buffer, storageKey, contentType);
211
+ case 'DROPBOX': return dropboxUploadBuffer(cfg.creds, buffer, storageKey);
212
+ default: throw new Error(`Unknown storage provider: ${cfg.kind}`);
213
+ }
214
+ }
215
+ // ── S3 / R2 ─────────────────────────────────────────────────────────────────
216
+ function buildS3Client(creds, isR2) {
217
+ const { S3Client } = require('@aws-sdk/client-s3');
218
+ const endpoint = isR2 ? `https://${creds.accountId}.r2.cloudflarestorage.com` : creds.endpointUrl;
219
+ return new S3Client({
220
+ region: isR2 ? 'auto' : (creds.region || 'us-east-1'),
221
+ // No explicit key pair (e.g. platform-default bucket on ECS with a task IAM
222
+ // role) — omit `credentials` entirely so the SDK's default provider chain
223
+ // (env vars, IMDS, task role) resolves them instead of an empty key pair.
224
+ ...(creds.accessKeyId && creds.secretAccessKey
225
+ ? { credentials: { accessKeyId: creds.accessKeyId, secretAccessKey: creds.secretAccessKey } }
226
+ : {}),
227
+ ...(endpoint ? { endpoint, forcePathStyle: true } : {}),
228
+ });
229
+ }
230
+ async function s3Upload(creds, localPath, key, ct, isR2) {
231
+ const { PutObjectCommand } = require('@aws-sdk/client-s3');
232
+ const { createReadStream } = require('node:fs');
233
+ const client = buildS3Client(creds, isR2);
234
+ await client.send(new PutObjectCommand({ Bucket: creds.bucket, Key: key, Body: createReadStream(localPath), ContentType: ct }));
235
+ return key;
236
+ }
237
+ async function s3UploadBuffer(creds, buffer, key, ct, isR2) {
238
+ const { PutObjectCommand } = require('@aws-sdk/client-s3');
239
+ const client = buildS3Client(creds, isR2);
240
+ await client.send(new PutObjectCommand({ Bucket: creds.bucket, Key: key, Body: buffer, ContentType: ct }));
241
+ return key;
242
+ }
243
+ async function s3PresignedGetUrl(creds, key, isR2, expiresIn) {
244
+ const { GetObjectCommand } = require('@aws-sdk/client-s3');
245
+ const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
246
+ const client = buildS3Client(creds, isR2);
247
+ return getSignedUrl(client, new GetObjectCommand({ Bucket: creds.bucket, Key: key }), { expiresIn });
248
+ }
249
+ async function s3DownloadBuffer(creds, key, isR2) {
250
+ const { GetObjectCommand } = require('@aws-sdk/client-s3');
251
+ const client = buildS3Client(creds, isR2);
252
+ const res = await client.send(new GetObjectCommand({ Bucket: creds.bucket, Key: key }));
253
+ const chunks = [];
254
+ for await (const chunk of res.Body)
255
+ chunks.push(chunk);
256
+ return Buffer.concat(chunks);
257
+ }
258
+ // ── Google Drive ─────────────────────────────────────────────────────────────
259
+ async function getGDriveToken(sa) {
260
+ const { createSign } = require('node:crypto');
261
+ const now = Math.floor(Date.now() / 1000);
262
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
263
+ const payload = Buffer.from(JSON.stringify({
264
+ iss: sa.client_email,
265
+ scope: 'https://www.googleapis.com/auth/drive',
266
+ aud: 'https://oauth2.googleapis.com/token',
267
+ iat: now, exp: now + 3600,
268
+ })).toString('base64url');
269
+ const sigInput = `${header}.${payload}`;
270
+ const sign = createSign('RSA-SHA256');
271
+ sign.update(sigInput);
272
+ const jwt = `${sigInput}.${sign.sign(sa.private_key, 'base64url')}`;
273
+ const res = await fetch('https://oauth2.googleapis.com/token', {
274
+ method: 'POST',
275
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
276
+ body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
277
+ });
278
+ if (!res.ok)
279
+ throw new Error(`GDrive token error: ${await res.text()}`);
280
+ return (await res.json()).access_token;
281
+ }
282
+ async function gdriveUpload(creds, localPath, key, ct) {
283
+ return gdriveUploadBuffer(creds, (0, node_fs_1.readFileSync)(localPath), key, ct);
284
+ }
285
+ async function gdriveUploadBuffer(creds, buffer, key, ct) {
286
+ const sa = JSON.parse(creds.serviceAccountJson);
287
+ const token = await getGDriveToken(sa);
288
+ const name = node_path_1.default.basename(key);
289
+ const boundary = 'ZeTa_b';
290
+ const body = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify({ name, parents: creds.folderId ? [creds.folderId] : [] })}\r\n--${boundary}\r\nContent-Type: ${ct}\r\n\r\n`;
291
+ const tail = `\r\n--${boundary}--`;
292
+ const full = Buffer.concat([Buffer.from(body), buffer, Buffer.from(tail)]);
293
+ const res = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', {
294
+ method: 'POST',
295
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': `multipart/related; boundary=${boundary}` },
296
+ body: full,
297
+ });
298
+ if (!res.ok)
299
+ throw new Error(`GDrive upload failed: ${await res.text()}`);
300
+ return (await res.json()).id;
301
+ }
302
+ // ── Dropbox ──────────────────────────────────────────────────────────────────
303
+ async function dropboxUpload(creds, localPath, key) {
304
+ return dropboxUploadBuffer(creds, (0, node_fs_1.readFileSync)(localPath), key);
305
+ }
306
+ async function dropboxUploadBuffer(creds, buffer, key) {
307
+ const folder = creds.folderPath ?? '/ZeTa-AI';
308
+ const dropboxPath = `${folder}/${key}`.replace(/\/+/g, '/');
309
+ const res = await fetch('https://content.dropboxapi.com/2/files/upload', {
310
+ method: 'POST',
311
+ headers: {
312
+ Authorization: `Bearer ${creds.accessToken}`,
313
+ 'Dropbox-API-Arg': JSON.stringify({ path: dropboxPath, mode: 'overwrite' }),
314
+ 'Content-Type': 'application/octet-stream',
315
+ },
316
+ body: buffer,
317
+ });
318
+ if (!res.ok)
319
+ throw new Error(`Dropbox upload failed: ${await res.text()}`);
320
+ return dropboxPath;
321
+ }
322
+ async function gdriveDownloadBuffer(creds, fileId) {
323
+ const sa = JSON.parse(creds.serviceAccountJson);
324
+ const token = await getGDriveToken(sa);
325
+ const res = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {
326
+ headers: { Authorization: `Bearer ${token}` },
327
+ });
328
+ if (!res.ok)
329
+ throw new Error(`GDrive download failed: ${await res.text()}`);
330
+ return Buffer.from(await res.arrayBuffer());
331
+ }
332
+ async function dropboxDownloadBuffer(creds, dropboxPath) {
333
+ const res = await fetch('https://content.dropboxapi.com/2/files/download', {
334
+ method: 'POST',
335
+ headers: {
336
+ Authorization: `Bearer ${creds.accessToken}`,
337
+ 'Dropbox-API-Arg': JSON.stringify({ path: dropboxPath }),
338
+ },
339
+ });
340
+ if (!res.ok)
341
+ throw new Error(`Dropbox download failed: ${await res.text()}`);
342
+ return Buffer.from(await res.arrayBuffer());
343
+ }
344
+ // ── Public API ───────────────────────────────────────────────────────────────
345
+ /**
346
+ * Generate a presigned GET URL for S3/R2 (time-limited, auth-gated).
347
+ * GDrive/Dropbox: returns null (caller must use downloadBufferFromTenantStorage + proxy).
348
+ */
349
+ async function generatePresignedGetUrl(cfg, storageKey, expiresIn = 3600) {
350
+ if (cfg.provider === 'S3' || cfg.provider === 'R2') {
351
+ const isR2 = cfg.provider === 'R2';
352
+ const creds = {
353
+ bucket: cfg.bucket,
354
+ region: cfg.region ?? 'auto',
355
+ accountId: cfg.accountId ?? '',
356
+ endpointUrl: cfg.endpointUrl ?? '',
357
+ accessKeyId: cfg.accessKeyId,
358
+ secretAccessKey: cfg.secretAccessKey,
359
+ };
360
+ return s3PresignedGetUrl(creds, storageKey, isR2, expiresIn);
361
+ }
362
+ return null;
363
+ }
364
+ /**
365
+ * Download raw bytes from tenant storage (for GDrive/Dropbox proxy, or fallback).
366
+ */
367
+ async function downloadBufferFromTenantStorage(cfg, storageKey) {
368
+ const isR2 = cfg.provider === 'R2';
369
+ const creds = {
370
+ bucket: cfg.bucket,
371
+ region: cfg.region ?? 'auto',
372
+ accountId: cfg.accountId ?? '',
373
+ endpointUrl: cfg.endpointUrl ?? '',
374
+ accessKeyId: cfg.accessKeyId,
375
+ secretAccessKey: cfg.secretAccessKey,
376
+ };
377
+ return s3DownloadBuffer(creds, storageKey, isR2);
378
+ }
379
+ /** Upload a buffer to tenant storage. Returns the storage key. */
380
+ async function uploadBufferToTenantStorage(cfg, buffer, storageKey, contentType = 'application/octet-stream') {
381
+ const isR2 = cfg.provider === 'R2';
382
+ const creds = {
383
+ bucket: cfg.bucket,
384
+ region: cfg.region ?? 'auto',
385
+ accountId: cfg.accountId ?? '',
386
+ endpointUrl: cfg.endpointUrl ?? '',
387
+ accessKeyId: cfg.accessKeyId,
388
+ secretAccessKey: cfg.secretAccessKey,
389
+ };
390
+ return s3UploadBuffer(creds, buffer, storageKey, contentType, isR2);
391
+ }
392
+ /** Build the canonical storage key for a knowledge artifact. */
393
+ function buildKnowledgeKey(tenantId, projectId, artifactId, filename, prefix) {
394
+ return [prefix, tenantId, projectId, 'knowledge', artifactId, filename].filter(Boolean).join('/');
395
+ }
396
+ /** List all S3/R2 objects under a project prefix. Paginates automatically. */
397
+ async function listProjectStorageObjects(cfg, tenantId, projectId) {
398
+ const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
399
+ const isR2 = cfg.provider === 'R2';
400
+ const creds = {
401
+ bucket: cfg.bucket,
402
+ region: cfg.region ?? 'auto',
403
+ accountId: cfg.accountId ?? '',
404
+ endpointUrl: cfg.endpointUrl ?? '',
405
+ accessKeyId: cfg.accessKeyId,
406
+ secretAccessKey: cfg.secretAccessKey,
407
+ };
408
+ const client = buildS3Client(creds, isR2);
409
+ const prefix = [cfg.prefix, tenantId, projectId].filter(Boolean).join('/') + '/';
410
+ const objects = [];
411
+ let continuationToken;
412
+ do {
413
+ const res = await client.send(new ListObjectsV2Command({
414
+ Bucket: cfg.bucket,
415
+ Prefix: prefix,
416
+ ContinuationToken: continuationToken,
417
+ MaxKeys: 1000,
418
+ }));
419
+ for (const obj of (res.Contents ?? [])) {
420
+ objects.push({ key: obj.Key, size: obj.Size ?? 0, lastModified: obj.LastModified ? obj.LastModified.toISOString() : null });
421
+ }
422
+ continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined;
423
+ } while (continuationToken);
424
+ return objects;
425
+ }
426
+ /**
427
+ * Delete all S3/R2 objects under a project prefix (GDPR cleanup on project delete).
428
+ * Batch-deletes in groups of 1 000 (S3 DeleteObjects limit).
429
+ * No-ops silently when cfg is null.
430
+ */
431
+ async function deleteProjectStorageObjects(tenantId, projectId) {
432
+ const cfg = await getTenantStorageConfig(tenantId);
433
+ if (!cfg)
434
+ return;
435
+ const objects = await listProjectStorageObjects(cfg, tenantId, projectId);
436
+ if (objects.length === 0)
437
+ return;
438
+ const { DeleteObjectsCommand } = require('@aws-sdk/client-s3');
439
+ const isR2 = cfg.provider === 'R2';
440
+ const creds = {
441
+ bucket: cfg.bucket,
442
+ region: cfg.region ?? 'auto',
443
+ accountId: cfg.accountId ?? '',
444
+ endpointUrl: cfg.endpointUrl ?? '',
445
+ accessKeyId: cfg.accessKeyId,
446
+ secretAccessKey: cfg.secretAccessKey,
447
+ };
448
+ const client = buildS3Client(creds, isR2);
449
+ for (let i = 0; i < objects.length; i += 1000) {
450
+ const batch = objects.slice(i, i + 1000).map((o) => ({ Key: o.key }));
451
+ await client.send(new DeleteObjectsCommand({ Bucket: cfg.bucket, Delete: { Objects: batch, Quiet: true } }));
452
+ }
453
+ }
454
+ /**
455
+ * Delete all S3/R2 objects for an entire tenant (GDPR cleanup on tenant delete).
456
+ * Lists by tenant prefix and batch-deletes everything found.
457
+ * No-ops silently when cfg is null.
458
+ */
459
+ async function deleteTenantStorageObjects(tenantId) {
460
+ const cfg = await getTenantStorageConfig(tenantId);
461
+ if (!cfg)
462
+ return;
463
+ const { ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
464
+ const isR2 = cfg.provider === 'R2';
465
+ const creds = {
466
+ bucket: cfg.bucket,
467
+ region: cfg.region ?? 'auto',
468
+ accountId: cfg.accountId ?? '',
469
+ endpointUrl: cfg.endpointUrl ?? '',
470
+ accessKeyId: cfg.accessKeyId,
471
+ secretAccessKey: cfg.secretAccessKey,
472
+ };
473
+ const client = buildS3Client(creds, isR2);
474
+ const prefix = [cfg.prefix, tenantId].filter(Boolean).join('/') + '/';
475
+ let continuationToken;
476
+ do {
477
+ const res = await client.send(new ListObjectsV2Command({
478
+ Bucket: cfg.bucket,
479
+ Prefix: prefix,
480
+ ContinuationToken: continuationToken,
481
+ MaxKeys: 1000,
482
+ }));
483
+ const keys = (res.Contents ?? []).map((o) => ({ Key: o.Key }));
484
+ if (keys.length > 0) {
485
+ await client.send(new DeleteObjectsCommand({ Bucket: cfg.bucket, Delete: { Objects: keys, Quiet: true } }));
486
+ }
487
+ continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined;
488
+ } while (continuationToken);
489
+ }
490
+ // ── DB→S3 Content Pointer Helpers (doc 18) ────────────────────────────────────
491
+ //
492
+ // These implement the DB→S3 pointer pattern: large content fields (script code,
493
+ // artifact markdown, test case steps/gherkin) are stored in tenant S3 when
494
+ // configured, with only an object key kept in the DB column. When tenant S3
495
+ // is not configured, content stays in the DB and the key field remains NULL.
496
+ //
497
+ // Key convention: {prefix}/{tenantId}/{projectId}/{type}/{entityId}.{ext}
498
+ // type: "scripts" | "artifacts" | "testcases"
499
+ // ext: "ts" | "md" | "json" | "feature"
500
+ /** Build a content object key for a specific entity type. */
501
+ function buildContentKey(tenantId, projectId, type, entityId, ext, prefix) {
502
+ return [prefix, tenantId, projectId, type, `${entityId}.${ext}`].filter(Boolean).join('/');
503
+ }
504
+ /**
505
+ * Persist string content to tenant S3 if configured.
506
+ * Returns the storage key on success, or null if no S3 is configured
507
+ * (caller should keep content in the DB column in that case).
508
+ */
509
+ async function persistContent(tenantId, projectId, type, entityId, ext, content, mimeType = 'text/plain; charset=utf-8') {
510
+ try {
511
+ const cfg = await getTenantStorageConfig(tenantId);
512
+ if (!cfg)
513
+ return null;
514
+ const key = buildContentKey(tenantId, projectId, type, entityId, ext, cfg.prefix);
515
+ await uploadBufferToTenantStorage(cfg, Buffer.from(content, 'utf-8'), key, mimeType);
516
+ return key;
517
+ }
518
+ catch {
519
+ return null;
520
+ }
521
+ }
522
+ /**
523
+ * Resolve content: if a storage key is provided, download from S3.
524
+ * Otherwise (or on error) return the fallback DB content.
525
+ */
526
+ async function resolveContent(tenantId, storageKey, fallback) {
527
+ if (!storageKey)
528
+ return fallback;
529
+ try {
530
+ const cfg = await getTenantStorageConfig(tenantId);
531
+ if (!cfg)
532
+ return fallback;
533
+ const buf = await downloadBufferFromTenantStorage(cfg, storageKey);
534
+ return buf.toString('utf-8');
535
+ }
536
+ catch {
537
+ return fallback;
538
+ }
539
+ }
@@ -0,0 +1,21 @@
1
+ export interface EmailPayload {
2
+ to: string;
3
+ name?: string;
4
+ type: string;
5
+ title: string;
6
+ body: string;
7
+ metadata?: Record<string, unknown>;
8
+ tenantId?: string;
9
+ }
10
+ export interface SendEmailResult {
11
+ sent: boolean;
12
+ reason?: string;
13
+ }
14
+ export interface EmailDiagnostics {
15
+ configured: boolean;
16
+ provider: 'platform-smtp' | 'tenant-smtp' | 'tenant-resend' | 'env-resend' | 'none';
17
+ missing: string[];
18
+ message: string;
19
+ }
20
+ export declare function getEmailDiagnostics(tenantId?: string): Promise<EmailDiagnostics>;
21
+ export declare function sendEmail(payload: EmailPayload): Promise<SendEmailResult>;