@critical-path/core 0.36.0 → 0.36.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/package.json +5 -1
- package/CHANGELOG.md +0 -593
- package/dist/deliverables.test.d.ts +0 -2
- package/dist/deliverables.test.d.ts.map +0 -1
- package/dist/deliverables.test.js +0 -232
- package/dist/deliverables.test.js.map +0 -1
- package/dist/domain/calendar.test.d.ts +0 -2
- package/dist/domain/calendar.test.d.ts.map +0 -1
- package/dist/domain/calendar.test.js +0 -181
- package/dist/domain/calendar.test.js.map +0 -1
- package/dist/domain/cpm.test.d.ts +0 -2
- package/dist/domain/cpm.test.d.ts.map +0 -1
- package/dist/domain/cpm.test.js +0 -166
- package/dist/domain/cpm.test.js.map +0 -1
- package/dist/domain/ddd.test.d.ts +0 -2
- package/dist/domain/ddd.test.d.ts.map +0 -1
- package/dist/domain/ddd.test.js +0 -349
- package/dist/domain/ddd.test.js.map +0 -1
- package/dist/domain/ladder.test.d.ts +0 -2
- package/dist/domain/ladder.test.d.ts.map +0 -1
- package/dist/domain/ladder.test.js +0 -213
- package/dist/domain/ladder.test.js.map +0 -1
- package/dist/domain/metrics.test.d.ts +0 -2
- package/dist/domain/metrics.test.d.ts.map +0 -1
- package/dist/domain/metrics.test.js +0 -252
- package/dist/domain/metrics.test.js.map +0 -1
- package/dist/domain/task-duration.test.d.ts +0 -2
- package/dist/domain/task-duration.test.d.ts.map +0 -1
- package/dist/domain/task-duration.test.js +0 -307
- package/dist/domain/task-duration.test.js.map +0 -1
- package/dist/domain/workload.test.d.ts +0 -2
- package/dist/domain/workload.test.d.ts.map +0 -1
- package/dist/domain/workload.test.js +0 -261
- package/dist/domain/workload.test.js.map +0 -1
- package/dist/engine/actor.test.d.ts +0 -2
- package/dist/engine/actor.test.d.ts.map +0 -1
- package/dist/engine/actor.test.js +0 -54
- package/dist/engine/actor.test.js.map +0 -1
- package/dist/engine/authorization.test.d.ts +0 -2
- package/dist/engine/authorization.test.d.ts.map +0 -1
- package/dist/engine/authorization.test.js +0 -154
- package/dist/engine/authorization.test.js.map +0 -1
- package/dist/engine/cascade.test.d.ts +0 -2
- package/dist/engine/cascade.test.d.ts.map +0 -1
- package/dist/engine/cascade.test.js +0 -99
- package/dist/engine/cascade.test.js.map +0 -1
- package/dist/engine/config.test.d.ts +0 -2
- package/dist/engine/config.test.d.ts.map +0 -1
- package/dist/engine/config.test.js +0 -46
- package/dist/engine/config.test.js.map +0 -1
- package/dist/engine/entity-events.test.d.ts +0 -2
- package/dist/engine/entity-events.test.d.ts.map +0 -1
- package/dist/engine/entity-events.test.js +0 -61
- package/dist/engine/entity-events.test.js.map +0 -1
- package/dist/index.test.d.ts +0 -2
- package/dist/index.test.d.ts.map +0 -1
- package/dist/index.test.js +0 -429
- package/dist/index.test.js.map +0 -1
- package/dist/plugins/plugins.test.d.ts +0 -2
- package/dist/plugins/plugins.test.d.ts.map +0 -1
- package/dist/plugins/plugins.test.js +0 -137
- package/dist/plugins/plugins.test.js.map +0 -1
- package/dist/schemas/schemas.test.d.ts +0 -2
- package/dist/schemas/schemas.test.d.ts.map +0 -1
- package/dist/schemas/schemas.test.js +0 -58
- package/dist/schemas/schemas.test.js.map +0 -1
- package/dist/status.test.d.ts +0 -2
- package/dist/status.test.d.ts.map +0 -1
- package/dist/status.test.js +0 -295
- package/dist/status.test.js.map +0 -1
- package/dist/storage/storage.test.d.ts +0 -2
- package/dist/storage/storage.test.d.ts.map +0 -1
- package/dist/storage/storage.test.js +0 -236
- package/dist/storage/storage.test.js.map +0 -1
- package/dist/storage/upload-limits.test.d.ts +0 -2
- package/dist/storage/upload-limits.test.d.ts.map +0 -1
- package/dist/storage/upload-limits.test.js +0 -31
- package/dist/storage/upload-limits.test.js.map +0 -1
- package/dist/store/conformance.test.d.ts +0 -2
- package/dist/store/conformance.test.d.ts.map +0 -1
- package/dist/store/conformance.test.js +0 -15
- package/dist/store/conformance.test.js.map +0 -1
- package/dist/store/firebase.test.d.ts +0 -2
- package/dist/store/firebase.test.d.ts.map +0 -1
- package/dist/store/firebase.test.js +0 -103
- package/dist/store/firebase.test.js.map +0 -1
- package/dist/store/query.test.d.ts +0 -2
- package/dist/store/query.test.d.ts.map +0 -1
- package/dist/store/query.test.js +0 -89
- package/dist/store/query.test.js.map +0 -1
- package/dist/store/sqlite.test.d.ts +0 -2
- package/dist/store/sqlite.test.d.ts.map +0 -1
- package/dist/store/sqlite.test.js +0 -177
- package/dist/store/sqlite.test.js.map +0 -1
- package/dist/task-state.test.d.ts +0 -2
- package/dist/task-state.test.d.ts.map +0 -1
- package/dist/task-state.test.js +0 -423
- package/dist/task-state.test.js.map +0 -1
- package/dist/use-case-validation.test.d.ts +0 -2
- package/dist/use-case-validation.test.d.ts.map +0 -1
- package/dist/use-case-validation.test.js +0 -281
- package/dist/use-case-validation.test.js.map +0 -1
- package/dist/utils/fractional-index.test.d.ts +0 -2
- package/dist/utils/fractional-index.test.d.ts.map +0 -1
- package/dist/utils/fractional-index.test.js +0 -182
- package/dist/utils/fractional-index.test.js.map +0 -1
- package/dist/utils/key.test.d.ts +0 -2
- package/dist/utils/key.test.d.ts.map +0 -1
- package/dist/utils/key.test.js +0 -65
- package/dist/utils/key.test.js.map +0 -1
- package/dist/utils/mentions.test.d.ts +0 -2
- package/dist/utils/mentions.test.d.ts.map +0 -1
- package/dist/utils/mentions.test.js +0 -114
- package/dist/utils/mentions.test.js.map +0 -1
- package/dist/webhooks/webhooks.test.d.ts +0 -2
- package/dist/webhooks/webhooks.test.d.ts.map +0 -1
- package/dist/webhooks/webhooks.test.js +0 -154
- package/dist/webhooks/webhooks.test.js.map +0 -1
- package/src/deliverables.test.ts +0 -284
- package/src/domain/authorization.ts +0 -82
- package/src/domain/calendar.test.ts +0 -216
- package/src/domain/calendar.ts +0 -502
- package/src/domain/cpm.test.ts +0 -189
- package/src/domain/cpm.ts +0 -258
- package/src/domain/custom-fields.ts +0 -172
- package/src/domain/ddd.test.ts +0 -442
- package/src/domain/entities.ts +0 -798
- package/src/domain/errors.ts +0 -38
- package/src/domain/events.ts +0 -369
- package/src/domain/graph.ts +0 -70
- package/src/domain/index.ts +0 -12
- package/src/domain/ladder.test.ts +0 -251
- package/src/domain/ladder.ts +0 -474
- package/src/domain/metrics.test.ts +0 -288
- package/src/domain/metrics.ts +0 -497
- package/src/domain/task-duration.test.ts +0 -354
- package/src/domain/workload.test.ts +0 -311
- package/src/domain/workload.ts +0 -457
- package/src/engine/actor.test.ts +0 -63
- package/src/engine/authorization.test.ts +0 -199
- package/src/engine/cascade.test.ts +0 -121
- package/src/engine/config.test.ts +0 -51
- package/src/engine/entity-events.test.ts +0 -67
- package/src/engine/index.ts +0 -2495
- package/src/index.test.ts +0 -514
- package/src/index.ts +0 -9
- package/src/plugins/index.ts +0 -103
- package/src/plugins/plugins.test.ts +0 -161
- package/src/schemas/index.ts +0 -402
- package/src/schemas/schemas.test.ts +0 -116
- package/src/status.test.ts +0 -354
- package/src/storage/file-storage.ts +0 -200
- package/src/storage/firebase-storage.ts +0 -209
- package/src/storage/index.ts +0 -3
- package/src/storage/s3.ts +0 -151
- package/src/storage/storage.test.ts +0 -274
- package/src/storage/upload-limits.test.ts +0 -41
- package/src/store/conformance.test.ts +0 -15
- package/src/store/firebase.test.ts +0 -131
- package/src/store/firebase.ts +0 -658
- package/src/store/index.ts +0 -623
- package/src/store/query.test.ts +0 -110
- package/src/store/query.ts +0 -113
- package/src/store/sqlite.test.ts +0 -218
- package/src/store/sqlite.ts +0 -1480
- package/src/task-state.test.ts +0 -506
- package/src/testing/conformance.ts +0 -319
- package/src/testing/index.ts +0 -1
- package/src/types/index.ts +0 -871
- package/src/use-case-validation.test.ts +0 -324
- package/src/utils/fractional-index.test.ts +0 -228
- package/src/utils/fractional-index.ts +0 -254
- package/src/utils/index.ts +0 -7
- package/src/utils/key.test.ts +0 -76
- package/src/utils/key.ts +0 -62
- package/src/utils/mentions.test.ts +0 -132
- package/src/utils/mentions.ts +0 -131
- package/src/utils/status.ts +0 -419
- package/src/utils/workflow.ts +0 -306
- package/src/webhooks/dispatcher.ts +0 -190
- package/src/webhooks/index.ts +0 -2
- package/src/webhooks/signature.ts +0 -57
- package/src/webhooks/webhooks.test.ts +0 -201
- package/tsconfig.json +0 -8
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
FileStorageAdapter,
|
|
3
|
-
UploadFileInput,
|
|
4
|
-
UploadFileResult,
|
|
5
|
-
PresignedUrlOptions,
|
|
6
|
-
PresignedUploadResult
|
|
7
|
-
} from '../types/index.js';
|
|
8
|
-
import { buildStorageKey, contentDispositionFor, normalizeUploadData } from './file-storage.js';
|
|
9
|
-
|
|
10
|
-
export interface FirebaseStorageBucketInterface {
|
|
11
|
-
name?: string;
|
|
12
|
-
file(path: string): {
|
|
13
|
-
save(
|
|
14
|
-
data: Uint8Array | Buffer | string,
|
|
15
|
-
options?: { metadata?: { contentType?: string; [key: string]: any }; resumable?: boolean }
|
|
16
|
-
): Promise<void>;
|
|
17
|
-
delete(options?: any): Promise<any>;
|
|
18
|
-
getSignedUrl?(config: { action: 'read' | 'write'; expires: string | number | Date; contentType?: string }): Promise<[string]>;
|
|
19
|
-
publicUrl?(): string;
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export class InMemoryFirebaseStorageMock implements FirebaseStorageBucketInterface {
|
|
24
|
-
name: string;
|
|
25
|
-
private files = new Map<string, { data: Uint8Array; contentType: string; token?: string }>();
|
|
26
|
-
|
|
27
|
-
constructor(name: string = 'mock-firebase-bucket') {
|
|
28
|
-
this.name = name;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
file(path: string) {
|
|
32
|
-
const files = this.files;
|
|
33
|
-
const bucketName = this.name;
|
|
34
|
-
return {
|
|
35
|
-
async save(data: Uint8Array | Buffer | string, options?: any) {
|
|
36
|
-
let bytes: Uint8Array;
|
|
37
|
-
if (typeof data === 'string') {
|
|
38
|
-
bytes = new TextEncoder().encode(data);
|
|
39
|
-
} else if (data instanceof Uint8Array) {
|
|
40
|
-
bytes = data;
|
|
41
|
-
} else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) {
|
|
42
|
-
bytes = new Uint8Array(data);
|
|
43
|
-
} else {
|
|
44
|
-
bytes = new Uint8Array();
|
|
45
|
-
}
|
|
46
|
-
files.set(path, {
|
|
47
|
-
data: bytes,
|
|
48
|
-
contentType: options?.metadata?.contentType || 'application/octet-stream',
|
|
49
|
-
token: options?.metadata?.metadata?.firebaseStorageDownloadTokens
|
|
50
|
-
});
|
|
51
|
-
},
|
|
52
|
-
async delete() {
|
|
53
|
-
files.delete(path);
|
|
54
|
-
},
|
|
55
|
-
async download(): Promise<[Uint8Array]> {
|
|
56
|
-
const file = files.get(path);
|
|
57
|
-
if (!file) throw new Error(`File not found: ${path}`);
|
|
58
|
-
return [file.data];
|
|
59
|
-
},
|
|
60
|
-
async getMetadata(): Promise<[any]> {
|
|
61
|
-
const file = files.get(path);
|
|
62
|
-
return [{
|
|
63
|
-
contentType: file?.contentType,
|
|
64
|
-
metadata: {
|
|
65
|
-
firebaseStorageDownloadTokens: file?.token
|
|
66
|
-
}
|
|
67
|
-
}];
|
|
68
|
-
},
|
|
69
|
-
async getSignedUrl(config: any): Promise<[string]> {
|
|
70
|
-
const file = files.get(path);
|
|
71
|
-
const token = file?.token || 'mock-signed-token';
|
|
72
|
-
return [`https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(bucketName)}/o/${encodeURIComponent(path)}?alt=media&token=${token}`];
|
|
73
|
-
},
|
|
74
|
-
publicUrl() {
|
|
75
|
-
return `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(bucketName)}/o/${encodeURIComponent(path)}?alt=media`;
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface FirebaseStorageConfig {
|
|
82
|
-
bucket?: FirebaseStorageBucketInterface;
|
|
83
|
-
bucketName?: string;
|
|
84
|
-
publicUrlBase?: string;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export class FirebaseStorageAdapter implements FileStorageAdapter {
|
|
88
|
-
private bucket: FirebaseStorageBucketInterface;
|
|
89
|
-
private bucketName: string;
|
|
90
|
-
private publicUrlBase?: string;
|
|
91
|
-
|
|
92
|
-
constructor(config: FirebaseStorageConfig = {}) {
|
|
93
|
-
if (!config.bucket) {
|
|
94
|
-
// Never fall back to an in-memory mock silently: files would vanish on restart.
|
|
95
|
-
throw new Error('FirebaseStorageAdapter requires a "bucket" (e.g. getStorage().bucket()). Use InMemoryFirebaseStorageMock in tests.');
|
|
96
|
-
}
|
|
97
|
-
this.bucket = config.bucket;
|
|
98
|
-
this.bucketName = config.bucketName || this.bucket.name || 'default-bucket';
|
|
99
|
-
if (this.bucket && config.bucketName && this.bucket.name !== config.bucketName) {
|
|
100
|
-
this.bucket.name = config.bucketName;
|
|
101
|
-
}
|
|
102
|
-
this.publicUrlBase = config.publicUrlBase?.replace(/\/+$/, '');
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
private getPublicUrl(storageKey: string, token?: string): string {
|
|
106
|
-
if (this.publicUrlBase) {
|
|
107
|
-
return `${this.publicUrlBase}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
|
|
108
|
-
}
|
|
109
|
-
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '';
|
|
110
|
-
return `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(this.bucketName)}/o/${encodeURIComponent(storageKey)}?alt=media${tokenParam}`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async upload(input: UploadFileInput): Promise<UploadFileResult> {
|
|
114
|
-
const storageKey = buildStorageKey(input.pathPrefix, input.filename);
|
|
115
|
-
|
|
116
|
-
const bodyData = normalizeUploadData(input.data, input.encoding, input.mimeType);
|
|
117
|
-
const mimeType = input.mimeType || 'application/octet-stream';
|
|
118
|
-
const sizeBytes = bodyData.byteLength;
|
|
119
|
-
const downloadToken =
|
|
120
|
-
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
121
|
-
? crypto.randomUUID()
|
|
122
|
-
: Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
|
123
|
-
|
|
124
|
-
const fileRef = this.bucket.file(storageKey);
|
|
125
|
-
await fileRef.save(bodyData, {
|
|
126
|
-
metadata: {
|
|
127
|
-
contentType: mimeType,
|
|
128
|
-
...(contentDispositionFor(mimeType, input.filename) ? { contentDisposition: contentDispositionFor(mimeType, input.filename) } : {}),
|
|
129
|
-
metadata: {
|
|
130
|
-
firebaseStorageDownloadTokens: downloadToken
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
resumable: false
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
const url = this.getPublicUrl(storageKey, downloadToken);
|
|
137
|
-
|
|
138
|
-
return {
|
|
139
|
-
storageKey,
|
|
140
|
-
url,
|
|
141
|
-
sizeBytes,
|
|
142
|
-
mimeType
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async delete(storageKey: string): Promise<boolean> {
|
|
147
|
-
try {
|
|
148
|
-
const fileRef = this.bucket.file(storageKey);
|
|
149
|
-
await fileRef.delete();
|
|
150
|
-
return true;
|
|
151
|
-
} catch {
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async download(storageKey: string): Promise<Buffer | Uint8Array> {
|
|
157
|
-
const fileRef = this.bucket.file(storageKey);
|
|
158
|
-
if (typeof (fileRef as any).download === 'function') {
|
|
159
|
-
const [buf] = await (fileRef as any).download();
|
|
160
|
-
return buf;
|
|
161
|
-
}
|
|
162
|
-
const url = await this.getDownloadUrl(storageKey);
|
|
163
|
-
const res = await fetch(url);
|
|
164
|
-
if (!res.ok) {
|
|
165
|
-
throw new Error(`Failed to download file from "${url}": ${res.statusText}`);
|
|
166
|
-
}
|
|
167
|
-
const arrayBuffer = await res.arrayBuffer();
|
|
168
|
-
return new Uint8Array(arrayBuffer);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
async getDownloadUrl(storageKey: string): Promise<string> {
|
|
172
|
-
const fileRef = this.bucket.file(storageKey);
|
|
173
|
-
if (typeof (fileRef as any).getMetadata === 'function') {
|
|
174
|
-
try {
|
|
175
|
-
const [meta] = await (fileRef as any).getMetadata();
|
|
176
|
-
const token = meta?.metadata?.firebaseStorageDownloadTokens;
|
|
177
|
-
return this.getPublicUrl(storageKey, token);
|
|
178
|
-
} catch {
|
|
179
|
-
// Fallback to public URL without token
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return this.getPublicUrl(storageKey);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async getPresignedUploadUrl(options: PresignedUrlOptions): Promise<PresignedUploadResult> {
|
|
186
|
-
const storageKey = options.storageKey;
|
|
187
|
-
const fileRef = this.bucket.file(storageKey);
|
|
188
|
-
|
|
189
|
-
if (typeof fileRef.getSignedUrl === 'function') {
|
|
190
|
-
const expiresInMs = (options.expiresInSeconds || 900) * 1000;
|
|
191
|
-
const expires = Date.now() + expiresInMs;
|
|
192
|
-
const [url] = await fileRef.getSignedUrl({
|
|
193
|
-
action: 'write',
|
|
194
|
-
expires,
|
|
195
|
-
contentType: options.contentType
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
return {
|
|
199
|
-
uploadUrl: url,
|
|
200
|
-
storageKey,
|
|
201
|
-
method: 'PUT',
|
|
202
|
-
headers: options.contentType ? { 'Content-Type': options.contentType } : {}
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// A public URL is not an upload credential; refuse rather than hand out a URL that cannot work.
|
|
207
|
-
throw new Error('FirebaseStorageAdapter cannot presign uploads: the bucket does not support getSignedUrl.');
|
|
208
|
-
}
|
|
209
|
-
}
|
package/src/storage/index.ts
DELETED
package/src/storage/s3.ts
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
FileStorageAdapter,
|
|
3
|
-
UploadFileInput,
|
|
4
|
-
UploadFileResult,
|
|
5
|
-
PresignedUrlOptions,
|
|
6
|
-
PresignedUploadResult
|
|
7
|
-
} from '../types/index.js';
|
|
8
|
-
import { buildStorageKey, contentDispositionFor, normalizeUploadData } from './file-storage.js';
|
|
9
|
-
|
|
10
|
-
/** The subset of the AWS SDK v3 `S3Client` the adapter uses. */
|
|
11
|
-
export interface S3ClientLike {
|
|
12
|
-
send(command: unknown): Promise<unknown>;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
type CommandClass = new (input: Record<string, unknown>) => unknown;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Command classes from `@aws-sdk/client-s3`, passed in so `@critical-path/core` has no AWS
|
|
19
|
-
* dependency: `{ PutObjectCommand, DeleteObjectCommand, GetObjectCommand }`.
|
|
20
|
-
*/
|
|
21
|
-
export interface S3Commands {
|
|
22
|
-
PutObjectCommand: CommandClass;
|
|
23
|
-
DeleteObjectCommand: CommandClass;
|
|
24
|
-
/** Needed for signed download URLs (private buckets). */
|
|
25
|
-
GetObjectCommand?: CommandClass;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface S3StorageConfig {
|
|
29
|
-
bucket: string;
|
|
30
|
-
region?: string;
|
|
31
|
-
/** Custom endpoint for S3-compatible services (MinIO, R2, LocalStack). */
|
|
32
|
-
endpoint?: string;
|
|
33
|
-
/** Base URL for public object links (e.g. a CDN). */
|
|
34
|
-
publicUrlBase?: string;
|
|
35
|
-
forcePathStyle?: boolean;
|
|
36
|
-
/** An AWS SDK v3 `S3Client` (or compatible). Required for uploads and deletes. */
|
|
37
|
-
client?: S3ClientLike;
|
|
38
|
-
commands?: S3Commands;
|
|
39
|
-
/**
|
|
40
|
-
* Signs a command into a URL, typically
|
|
41
|
-
* `(command, { expiresIn }) => getSignedUrl(client, command, { expiresIn })` from
|
|
42
|
-
* `@aws-sdk/s3-request-presigner`. Required for presigned uploads and signed downloads.
|
|
43
|
-
*/
|
|
44
|
-
presign?: (command: unknown, options: { expiresIn: number }) => Promise<string>;
|
|
45
|
-
/**
|
|
46
|
-
* Serve downloads through signed URLs instead of public object URLs (for private buckets).
|
|
47
|
-
* Requires `presign` and `commands.GetObjectCommand`. Default `false`.
|
|
48
|
-
*/
|
|
49
|
-
signedDownloads?: boolean;
|
|
50
|
-
/** Lifetime of signed download URLs in seconds. Default 3600. */
|
|
51
|
-
downloadUrlExpiresIn?: number;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Stores attachments in Amazon S3 (or an S3-compatible service) using the AWS SDK v3 client
|
|
56
|
-
* you provide. Errors from S3 propagate; nothing is reported as stored unless S3 accepted it.
|
|
57
|
-
*/
|
|
58
|
-
export class S3StorageAdapter implements FileStorageAdapter {
|
|
59
|
-
private readonly bucket: string;
|
|
60
|
-
private readonly endpoint: string;
|
|
61
|
-
private readonly publicUrlBase?: string;
|
|
62
|
-
private readonly forcePathStyle: boolean;
|
|
63
|
-
private readonly config: S3StorageConfig;
|
|
64
|
-
|
|
65
|
-
constructor(config: S3StorageConfig) {
|
|
66
|
-
if (!config.bucket) {
|
|
67
|
-
throw new Error('S3StorageAdapter requires a "bucket" name in its config.');
|
|
68
|
-
}
|
|
69
|
-
if (config.client && !config.commands) {
|
|
70
|
-
throw new Error('S3StorageAdapter needs "commands" ({ PutObjectCommand, DeleteObjectCommand }) from @aws-sdk/client-s3 when a client is given.');
|
|
71
|
-
}
|
|
72
|
-
if (config.signedDownloads && (!config.presign || !config.commands?.GetObjectCommand)) {
|
|
73
|
-
throw new Error('S3StorageAdapter signedDownloads requires "presign" and "commands.GetObjectCommand".');
|
|
74
|
-
}
|
|
75
|
-
this.config = config;
|
|
76
|
-
this.bucket = config.bucket;
|
|
77
|
-
const region = config.region || 'us-east-1';
|
|
78
|
-
this.endpoint = (config.endpoint || `https://s3.${region}.amazonaws.com`).replace(/\/+$/, '');
|
|
79
|
-
this.publicUrlBase = config.publicUrlBase?.replace(/\/+$/, '');
|
|
80
|
-
this.forcePathStyle =
|
|
81
|
-
config.forcePathStyle ?? (this.endpoint.includes('localhost') || this.endpoint.includes('127.0.0.1'));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
private requireClient(operation: string): { client: S3ClientLike; commands: S3Commands } {
|
|
85
|
-
if (!this.config.client || !this.config.commands) {
|
|
86
|
-
throw new Error(`S3StorageAdapter cannot ${operation}: configure "client" and "commands".`);
|
|
87
|
-
}
|
|
88
|
-
return { client: this.config.client, commands: this.config.commands };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
private getObjectUrl(storageKey: string): string {
|
|
92
|
-
const path = encodeURIComponent(storageKey).replace(/%2F/g, '/');
|
|
93
|
-
if (this.publicUrlBase) return `${this.publicUrlBase}/${path}`;
|
|
94
|
-
if (this.forcePathStyle) return `${this.endpoint}/${this.bucket}/${path}`;
|
|
95
|
-
return `${this.endpoint.replace('://', `://${this.bucket}.`)}/${path}`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async upload(input: UploadFileInput): Promise<UploadFileResult> {
|
|
99
|
-
const { client, commands } = this.requireClient('upload');
|
|
100
|
-
const storageKey = buildStorageKey(input.pathPrefix, input.filename);
|
|
101
|
-
const body = normalizeUploadData(input.data, input.encoding, input.mimeType);
|
|
102
|
-
const mimeType = input.mimeType || 'application/octet-stream';
|
|
103
|
-
const disposition = contentDispositionFor(mimeType, input.filename);
|
|
104
|
-
|
|
105
|
-
await client.send(
|
|
106
|
-
new commands.PutObjectCommand({
|
|
107
|
-
Bucket: this.bucket,
|
|
108
|
-
Key: storageKey,
|
|
109
|
-
Body: body,
|
|
110
|
-
ContentType: mimeType,
|
|
111
|
-
...(disposition ? { ContentDisposition: disposition } : {})
|
|
112
|
-
})
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
return { storageKey, url: await this.getDownloadUrl(storageKey), sizeBytes: body.byteLength, mimeType };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Deletes an object. S3 deletes are idempotent, so a missing object still returns `true`. */
|
|
119
|
-
async delete(storageKey: string): Promise<boolean> {
|
|
120
|
-
const { client, commands } = this.requireClient('delete');
|
|
121
|
-
await client.send(new commands.DeleteObjectCommand({ Bucket: this.bucket, Key: storageKey }));
|
|
122
|
-
return true;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async getDownloadUrl(storageKey: string): Promise<string> {
|
|
126
|
-
if (this.config.signedDownloads) {
|
|
127
|
-
const command = new this.config.commands!.GetObjectCommand!({ Bucket: this.bucket, Key: storageKey });
|
|
128
|
-
return this.config.presign!(command, { expiresIn: this.config.downloadUrlExpiresIn ?? 3600 });
|
|
129
|
-
}
|
|
130
|
-
return this.getObjectUrl(storageKey);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async getPresignedUploadUrl(options: PresignedUrlOptions): Promise<PresignedUploadResult> {
|
|
134
|
-
if (!this.config.presign || !this.config.commands) {
|
|
135
|
-
throw new Error('S3StorageAdapter cannot presign uploads: configure "presign" and "commands".');
|
|
136
|
-
}
|
|
137
|
-
const command = new this.config.commands.PutObjectCommand({
|
|
138
|
-
Bucket: this.bucket,
|
|
139
|
-
Key: options.storageKey,
|
|
140
|
-
...(options.contentType ? { ContentType: options.contentType } : {})
|
|
141
|
-
});
|
|
142
|
-
const uploadUrl = await this.config.presign(command, { expiresIn: options.expiresInSeconds ?? 900 });
|
|
143
|
-
return {
|
|
144
|
-
uploadUrl,
|
|
145
|
-
storageKey: options.storageKey,
|
|
146
|
-
method: 'PUT',
|
|
147
|
-
// The signature covers Content-Type, so the uploader must send exactly this header.
|
|
148
|
-
headers: options.contentType ? { 'Content-Type': options.contentType } : {}
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
}
|
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
InMemoryFileStore,
|
|
4
|
-
S3StorageAdapter,
|
|
5
|
-
FirebaseStorageAdapter,
|
|
6
|
-
InMemoryFirebaseStorageMock,
|
|
7
|
-
CriticalPathEngine
|
|
8
|
-
} from '../index.js';
|
|
9
|
-
|
|
10
|
-
describe('Storage Adapters', () => {
|
|
11
|
-
describe('InMemoryFileStore', () => {
|
|
12
|
-
it('uploads, downloads, and deletes files in memory', async () => {
|
|
13
|
-
const store = new InMemoryFileStore({ publicUrlBase: 'https://cdn.example.com/files' });
|
|
14
|
-
|
|
15
|
-
const uploadResult = await store.upload({
|
|
16
|
-
filename: 'diagram.png',
|
|
17
|
-
data: 'file-contents-here',
|
|
18
|
-
mimeType: 'image/png',
|
|
19
|
-
pathPrefix: 'attachments/task-1'
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
expect(uploadResult.storageKey).toContain('attachments/task-1/');
|
|
23
|
-
expect(uploadResult.storageKey).toContain('diagram.png');
|
|
24
|
-
expect(uploadResult.mimeType).toBe('image/png');
|
|
25
|
-
expect(uploadResult.url).toContain('https://cdn.example.com/files/');
|
|
26
|
-
expect(uploadResult.sizeBytes).toBeGreaterThan(0);
|
|
27
|
-
|
|
28
|
-
const downloadUrl = await store.getDownloadUrl(uploadResult.storageKey);
|
|
29
|
-
expect(downloadUrl).toBe(uploadResult.url);
|
|
30
|
-
|
|
31
|
-
const presigned = await store.getPresignedUploadUrl({
|
|
32
|
-
storageKey: 'presigned-key.png',
|
|
33
|
-
contentType: 'image/png'
|
|
34
|
-
});
|
|
35
|
-
expect(presigned.uploadUrl).toContain('presigned-key.png');
|
|
36
|
-
expect(presigned.method).toBe('PUT');
|
|
37
|
-
|
|
38
|
-
const deleted = await store.delete(uploadResult.storageKey);
|
|
39
|
-
expect(deleted).toBe(true);
|
|
40
|
-
await expect(store.getDownloadUrl(uploadResult.storageKey)).rejects.toThrow();
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe('S3StorageAdapter', () => {
|
|
45
|
-
// Stand-ins for @aws-sdk/client-s3 command classes: they just record their input
|
|
46
|
-
class PutObjectCommand { constructor(public input: Record<string, unknown>) {} }
|
|
47
|
-
class DeleteObjectCommand { constructor(public input: Record<string, unknown>) {} }
|
|
48
|
-
class GetObjectCommand { constructor(public input: Record<string, unknown>) {} }
|
|
49
|
-
const commands = { PutObjectCommand, DeleteObjectCommand, GetObjectCommand };
|
|
50
|
-
|
|
51
|
-
function setup(send: (command: any) => Promise<unknown> = async () => ({})) {
|
|
52
|
-
const sent: any[] = [];
|
|
53
|
-
const presigned: Array<{ command: any; expiresIn: number }> = [];
|
|
54
|
-
const adapter = new S3StorageAdapter({
|
|
55
|
-
bucket: 'my-project-bucket',
|
|
56
|
-
region: 'eu-west-1',
|
|
57
|
-
client: { send: async (command) => { sent.push(command); return send(command); } },
|
|
58
|
-
commands,
|
|
59
|
-
presign: async (command, { expiresIn }) => {
|
|
60
|
-
presigned.push({ command, expiresIn });
|
|
61
|
-
return `https://signed.example/${(command as any).input.Key}?X-Amz-Expires=${expiresIn}`;
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
return { adapter, sent, presigned };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
it('uploads with a real command object and returns the object URL', async () => {
|
|
68
|
-
const { adapter, sent } = setup();
|
|
69
|
-
const res = await adapter.upload({ filename: 'spec.pdf', data: new Uint8Array([1, 2, 3, 4]), mimeType: 'application/pdf', pathPrefix: 'docs' });
|
|
70
|
-
|
|
71
|
-
expect(res.storageKey).toMatch(/^docs\/\d+_[0-9a-f]{12}_spec\.pdf$/);
|
|
72
|
-
expect(res.sizeBytes).toBe(4);
|
|
73
|
-
expect(res.url).toBe(`https://my-project-bucket.s3.eu-west-1.amazonaws.com/${res.storageKey}`);
|
|
74
|
-
expect(sent[0]).toBeInstanceOf(PutObjectCommand);
|
|
75
|
-
expect(sent[0].input).toMatchObject({ Bucket: 'my-project-bucket', Key: res.storageKey, ContentType: 'application/pdf' });
|
|
76
|
-
expect(sent[0].input.ContentDisposition).toBeUndefined();
|
|
77
|
-
|
|
78
|
-
expect(await adapter.delete(res.storageKey)).toBe(true);
|
|
79
|
-
expect(sent[1]).toBeInstanceOf(DeleteObjectCommand);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('forces active content to download', async () => {
|
|
83
|
-
const { adapter, sent } = setup();
|
|
84
|
-
await adapter.upload({ filename: 'x.svg', data: '<svg/>', mimeType: 'image/svg+xml', encoding: 'utf-8' });
|
|
85
|
-
expect(sent[0].input.ContentDisposition).toBe('attachment; filename="x.svg"');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('propagates S3 errors instead of reporting success', async () => {
|
|
89
|
-
const { adapter } = setup(async () => {
|
|
90
|
-
throw new Error('AccessDenied');
|
|
91
|
-
});
|
|
92
|
-
await expect(adapter.upload({ filename: 'a.txt', data: 'x' })).rejects.toThrow('AccessDenied');
|
|
93
|
-
await expect(adapter.delete('a.txt')).rejects.toThrow('AccessDenied');
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('refuses to upload or presign without the AWS pieces', async () => {
|
|
97
|
-
const bare = new S3StorageAdapter({ bucket: 'b' });
|
|
98
|
-
await expect(bare.upload({ filename: 'a.txt', data: 'x' })).rejects.toThrow(/configure "client"/);
|
|
99
|
-
await expect(bare.getPresignedUploadUrl({ storageKey: 'k' })).rejects.toThrow(/presign/);
|
|
100
|
-
expect(() => new S3StorageAdapter({ bucket: 'b', client: { send: async () => ({}) } })).toThrow(/commands/);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('presigns uploads through the provided signer, covering the content type', async () => {
|
|
104
|
-
const { adapter, presigned } = setup();
|
|
105
|
-
const result = await adapter.getPresignedUploadUrl({ storageKey: 'projects/p1/file.zip', contentType: 'application/zip', expiresInSeconds: 60 });
|
|
106
|
-
|
|
107
|
-
expect(result.uploadUrl).toBe('https://signed.example/projects/p1/file.zip?X-Amz-Expires=60');
|
|
108
|
-
expect(result.headers).toEqual({ 'Content-Type': 'application/zip' });
|
|
109
|
-
expect(presigned[0].command).toBeInstanceOf(PutObjectCommand);
|
|
110
|
-
expect(presigned[0].command.input).toMatchObject({ Key: 'projects/p1/file.zip', ContentType: 'application/zip' });
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('serves signed download URLs for private buckets', async () => {
|
|
114
|
-
const presign = async (command: any, { expiresIn }: { expiresIn: number }) => `https://signed.example/get/${command.input.Key}?e=${expiresIn}`;
|
|
115
|
-
const adapter = new S3StorageAdapter({ bucket: 'b', client: { send: async () => ({}) }, commands, presign, signedDownloads: true });
|
|
116
|
-
expect(await adapter.getDownloadUrl('k')).toBe('https://signed.example/get/k?e=3600');
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('rejects path prefixes that escape their directory', async () => {
|
|
120
|
-
const { adapter } = setup();
|
|
121
|
-
for (const pathPrefix of ['../other', 'projects/../../x', '/abs', 'a//b', 'bad prefix']) {
|
|
122
|
-
await expect(adapter.upload({ filename: 'a.txt', data: 'x', pathPrefix })).rejects.toThrow(/Invalid storage path prefix/);
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe('FirebaseStorageAdapter & InMemoryFirebaseStorageMock', () => {
|
|
128
|
-
it('works with InMemoryFirebaseStorageMock', async () => {
|
|
129
|
-
const mockStorage = new InMemoryFirebaseStorageMock();
|
|
130
|
-
const adapter = new FirebaseStorageAdapter({
|
|
131
|
-
bucket: mockStorage,
|
|
132
|
-
bucketName: 'my-app.appspot.com'
|
|
133
|
-
});
|
|
134
|
-
expect(() => new FirebaseStorageAdapter()).toThrow(/requires a "bucket"/);
|
|
135
|
-
|
|
136
|
-
const uploadResult = await adapter.upload({
|
|
137
|
-
filename: 'architecture.png',
|
|
138
|
-
data: 'png-data-mock',
|
|
139
|
-
mimeType: 'image/png',
|
|
140
|
-
pathPrefix: 'projects/p1'
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
expect(uploadResult.storageKey).toContain('projects/p1/');
|
|
144
|
-
expect(uploadResult.url).toContain('https://firebasestorage.googleapis.com/v0/b/my-app.appspot.com/o/');
|
|
145
|
-
|
|
146
|
-
const downloadUrl = await adapter.getDownloadUrl(uploadResult.storageKey);
|
|
147
|
-
expect(downloadUrl).toBe(uploadResult.url);
|
|
148
|
-
|
|
149
|
-
const deleted = await adapter.delete(uploadResult.storageKey);
|
|
150
|
-
expect(deleted).toBe(true);
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
describe('CriticalPathEngine with File Storage', () => {
|
|
155
|
-
it('creates attachments with direct upload & handles file deletion on attachment removal', async () => {
|
|
156
|
-
const fileStore = new InMemoryFileStore();
|
|
157
|
-
const engine = new CriticalPathEngine({ fileStorage: fileStore });
|
|
158
|
-
|
|
159
|
-
const project = await engine.createProject({ key: 'ATT', name: 'Attachment Project' });
|
|
160
|
-
const task = await engine.createTask({ projectId: project.id, title: 'Upload test task', status: 'todo' });
|
|
161
|
-
|
|
162
|
-
// Upload file directly through engine (which also creates attachment)
|
|
163
|
-
const attachment = await engine.uploadAttachmentFile({
|
|
164
|
-
filename: 'screenshot.png',
|
|
165
|
-
data: 'image-binary-bytes',
|
|
166
|
-
mimeType: 'image/png',
|
|
167
|
-
taskId: task.id,
|
|
168
|
-
projectId: project.id,
|
|
169
|
-
uploaderId: 'user_1',
|
|
170
|
-
uploaderType: 'user'
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
expect(attachment.id).toBeDefined();
|
|
174
|
-
expect(attachment.filename).toBe('screenshot.png');
|
|
175
|
-
expect(attachment.storageKey).toBeDefined();
|
|
176
|
-
|
|
177
|
-
const taskAttachments = await engine.getAttachments({ taskId: task.id });
|
|
178
|
-
expect(taskAttachments.length).toBe(1);
|
|
179
|
-
expect(taskAttachments[0].id).toBe(attachment.id);
|
|
180
|
-
|
|
181
|
-
// Deleting attachment should also delete file from fileStorage
|
|
182
|
-
const deleted = await engine.deleteAttachment(attachment.id);
|
|
183
|
-
expect(deleted).toBe(true);
|
|
184
|
-
|
|
185
|
-
const remainingAttachments = await engine.getAttachments({ taskId: task.id });
|
|
186
|
-
expect(remainingAttachments.length).toBe(0);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('decodes base64 strings and data URIs into binary Uint8Array data', async () => {
|
|
190
|
-
const fileStore = new InMemoryFileStore();
|
|
191
|
-
const rawText = 'Hello Critical Path Storage';
|
|
192
|
-
const base64Text = Buffer.from(rawText).toString('base64');
|
|
193
|
-
|
|
194
|
-
const resBase64 = await fileStore.upload({
|
|
195
|
-
filename: 'test.png',
|
|
196
|
-
data: base64Text,
|
|
197
|
-
mimeType: 'image/png',
|
|
198
|
-
encoding: 'base64'
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
const stored = fileStore.getFile(resBase64.storageKey);
|
|
202
|
-
expect(stored).toBeDefined();
|
|
203
|
-
expect(stored?.sizeBytes).toBe(Buffer.from(rawText).length);
|
|
204
|
-
expect(new TextDecoder().decode(stored?.data)).toBe(rawText);
|
|
205
|
-
|
|
206
|
-
// Data URI
|
|
207
|
-
const dataUri = `data:image/png;base64,${base64Text}`;
|
|
208
|
-
const resDataUri = await fileStore.upload({
|
|
209
|
-
filename: 'datauri.png',
|
|
210
|
-
data: dataUri,
|
|
211
|
-
mimeType: 'image/png'
|
|
212
|
-
});
|
|
213
|
-
const storedUri = fileStore.getFile(resDataUri.storageKey);
|
|
214
|
-
expect(storedUri).toBeDefined();
|
|
215
|
-
expect(storedUri?.sizeBytes).toBe(Buffer.from(rawText).length);
|
|
216
|
-
expect(new TextDecoder().decode(storedUri?.data)).toBe(rawText);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it('downloads file bytes and reads text content from attachments via engine', async () => {
|
|
220
|
-
const mockStorage = new InMemoryFirebaseStorageMock();
|
|
221
|
-
const storageAdapter = new FirebaseStorageAdapter({ bucket: mockStorage, bucketName: 'test-bucket' });
|
|
222
|
-
const engine = new CriticalPathEngine({ fileStorage: storageAdapter });
|
|
223
|
-
|
|
224
|
-
const project = await engine.createProject({ name: 'Attachment Reading Test' });
|
|
225
|
-
const task = await engine.createTask({ projectId: project.id, title: 'Document Task' });
|
|
226
|
-
|
|
227
|
-
const planMarkdown = '# Project Roadmap\n\nPhase 1: Research\nPhase 2: Execution';
|
|
228
|
-
const attachment = await engine.uploadAttachmentFile({
|
|
229
|
-
filename: 'Roadmap.md',
|
|
230
|
-
data: planMarkdown,
|
|
231
|
-
mimeType: 'text/markdown',
|
|
232
|
-
taskId: task.id,
|
|
233
|
-
projectId: project.id,
|
|
234
|
-
uploaderId: 'agent_planner',
|
|
235
|
-
uploaderType: 'agent',
|
|
236
|
-
artifactType: 'plan'
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
expect(attachment.id).toBeDefined();
|
|
240
|
-
expect(attachment.artifactType).toBe('plan');
|
|
241
|
-
|
|
242
|
-
// Test download method on adapter
|
|
243
|
-
const downloadedBytes = await storageAdapter.download(attachment.storageKey!);
|
|
244
|
-
expect(new TextDecoder().decode(downloadedBytes)).toBe(planMarkdown);
|
|
245
|
-
|
|
246
|
-
// Test engine readAttachmentText
|
|
247
|
-
const readText = await engine.readAttachmentText(attachment.id);
|
|
248
|
-
expect(readText).toBe(planMarkdown);
|
|
249
|
-
|
|
250
|
-
// Test filtering by artifactType
|
|
251
|
-
const planAttachments = await engine.getAttachments({ taskId: task.id, artifactType: 'plan' });
|
|
252
|
-
expect(planAttachments.length).toBe(1);
|
|
253
|
-
expect(planAttachments[0].id).toBe(attachment.id);
|
|
254
|
-
|
|
255
|
-
const reviewAttachments = await engine.getAttachments({ taskId: task.id, artifactType: 'review' });
|
|
256
|
-
expect(reviewAttachments.length).toBe(0);
|
|
257
|
-
});
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it('stores uploads made through an actor view under the project prefix', async () => {
|
|
261
|
-
const engine = new CriticalPathEngine({ fileStorage: new InMemoryFileStore() });
|
|
262
|
-
const project = await engine.createProject({ name: 'Uploads' });
|
|
263
|
-
const task = await engine.createTask({ projectId: project.id, title: 'T' });
|
|
264
|
-
|
|
265
|
-
const attachment = await engine.withActor({ userId: 'u1' }).uploadAttachmentFile({ taskId: task.id, filename: 'notes.txt', data: 'hi', encoding: 'utf-8' });
|
|
266
|
-
expect(attachment.storageKey?.startsWith(`projects/${project.id}/`)).toBe(true);
|
|
267
|
-
expect(attachment.uploaderId).toBe('u1');
|
|
268
|
-
|
|
269
|
-
await expect(
|
|
270
|
-
engine.withActor({ userId: 'u1' }).uploadAttachmentFile({ filename: 'orphan.txt', data: 'x' })
|
|
271
|
-
).rejects.toThrow(/must reference a project/);
|
|
272
|
-
});
|
|
273
|
-
});
|
|
274
|
-
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { CriticalPathEngine, InMemoryFileStore, ValidationError } from '../index.js';
|
|
3
|
-
|
|
4
|
-
async function setup() {
|
|
5
|
-
const engine = new CriticalPathEngine({
|
|
6
|
-
fileStorage: new InMemoryFileStore(),
|
|
7
|
-
uploads: { maxBytes: 10, allowedMimeTypes: ['image/*', 'application/pdf'] }
|
|
8
|
-
});
|
|
9
|
-
const project = await engine.createProject({ name: 'Limits' });
|
|
10
|
-
return { engine, project };
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
describe('upload limits', () => {
|
|
14
|
-
it('enforces the decoded size of direct uploads', async () => {
|
|
15
|
-
const { engine, project } = await setup();
|
|
16
|
-
await expect(
|
|
17
|
-
engine.uploadAttachmentFile({ projectId: project.id, filename: 'ok.png', mimeType: 'image/png', data: new Uint8Array(10), uploaderId: 'u' })
|
|
18
|
-
).resolves.toMatchObject({ sizeBytes: 10 });
|
|
19
|
-
// 16 base64 characters decode to 12 bytes
|
|
20
|
-
await expect(
|
|
21
|
-
engine.uploadAttachmentFile({ projectId: project.id, filename: 'big.png', mimeType: 'image/png', data: 'AAAAAAAAAAAAAAAA', encoding: 'base64', uploaderId: 'u' })
|
|
22
|
-
).rejects.toThrow(/maximum is 10 bytes/);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('allows MIME types by exact match or wildcard', async () => {
|
|
26
|
-
const { engine, project } = await setup();
|
|
27
|
-
const upload = (mimeType: string) =>
|
|
28
|
-
engine.uploadAttachmentFile({ projectId: project.id, filename: 'f', mimeType, data: new Uint8Array(1), uploaderId: 'u' });
|
|
29
|
-
await expect(upload('image/webp')).resolves.toBeDefined();
|
|
30
|
-
await expect(upload('application/pdf; charset=binary')).resolves.toBeDefined();
|
|
31
|
-
await expect(upload('text/html')).rejects.toThrow(ValidationError);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('applies to presigned uploads and registered attachments', async () => {
|
|
35
|
-
const { engine, project } = await setup();
|
|
36
|
-
await expect(engine.getPresignedAttachmentUploadUrl({ projectId: project.id, filename: 'x.html', contentType: 'text/html' })).rejects.toThrow(/not allowed/);
|
|
37
|
-
await expect(
|
|
38
|
-
engine.createAttachment({ projectId: project.id, filename: 'x', url: 'https://e.com/x', mimeType: 'image/png', sizeBytes: 11, uploaderId: 'u' })
|
|
39
|
-
).rejects.toThrow(/maximum/);
|
|
40
|
-
});
|
|
41
|
-
});
|