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