@critical-path/core 0.30.0 → 0.32.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/CHANGELOG.md +28 -0
- package/README.md +10 -1
- package/dist/engine/authorization.test.js +8 -4
- package/dist/engine/authorization.test.js.map +1 -1
- package/dist/engine/config.test.d.ts +2 -0
- package/dist/engine/config.test.d.ts.map +1 -0
- package/dist/engine/config.test.js +46 -0
- package/dist/engine/config.test.js.map +1 -0
- package/dist/engine/index.d.ts +22 -3
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +60 -16
- package/dist/engine/index.js.map +1 -1
- package/dist/schemas/index.d.ts +9 -11
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +3 -3
- package/dist/schemas/index.js.map +1 -1
- package/dist/storage/file-storage.d.ts +10 -0
- package/dist/storage/file-storage.d.ts.map +1 -1
- package/dist/storage/file-storage.js +40 -4
- package/dist/storage/file-storage.js.map +1 -1
- package/dist/storage/firebase-storage.d.ts.map +1 -1
- package/dist/storage/firebase-storage.js +10 -13
- package/dist/storage/firebase-storage.js.map +1 -1
- package/dist/storage/s3.d.ts +46 -18
- package/dist/storage/s3.d.ts.map +1 -1
- package/dist/storage/s3.js +59 -89
- package/dist/storage/s3.js.map +1 -1
- package/dist/storage/storage.test.js +88 -27
- package/dist/storage/storage.test.js.map +1 -1
- package/dist/types/index.d.ts +12 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/engine/authorization.test.ts +13 -5
- package/src/engine/config.test.ts +51 -0
- package/src/engine/index.ts +67 -18
- package/src/schemas/index.ts +3 -3
- package/src/storage/file-storage.ts +44 -4
- package/src/storage/firebase-storage.ts +10 -13
- package/src/storage/s3.ts +98 -101
- package/src/storage/storage.test.ts +86 -28
- package/src/types/index.ts +12 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -15,6 +15,49 @@ export interface StoredFile {
|
|
|
15
15
|
createdAt: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Builds a storage key `<prefix>/<timestamp>_<random>_<filename>`. The prefix is validated (no
|
|
20
|
+
* `.`/`..` segments, no leading slash, safe characters only) so callers cannot write outside it.
|
|
21
|
+
*/
|
|
22
|
+
export function buildStorageKey(pathPrefix: string | undefined, filename: string): string {
|
|
23
|
+
let prefix = '';
|
|
24
|
+
if (pathPrefix) {
|
|
25
|
+
const trimmed = pathPrefix.replace(/\/+$/, '');
|
|
26
|
+
const segments = trimmed.split('/');
|
|
27
|
+
if (
|
|
28
|
+
trimmed.startsWith('/') ||
|
|
29
|
+
segments.some((segment) => segment === '' || segment === '.' || segment === '..' || !/^[A-Za-z0-9._-]+$/.test(segment))
|
|
30
|
+
) {
|
|
31
|
+
throw new Error(`Invalid storage path prefix "${pathPrefix}".`);
|
|
32
|
+
}
|
|
33
|
+
prefix = `${trimmed}/`;
|
|
34
|
+
}
|
|
35
|
+
const random = Array.from(crypto.getRandomValues(new Uint8Array(6)), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
36
|
+
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^\.+/, '_') || 'file';
|
|
37
|
+
return `${prefix}${Date.now()}_${random}_${safeName}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Content types a browser would execute or render as a page if opened from the storage host. */
|
|
41
|
+
const ACTIVE_CONTENT_TYPES = new Set([
|
|
42
|
+
'text/html',
|
|
43
|
+
'application/xhtml+xml',
|
|
44
|
+
'image/svg+xml',
|
|
45
|
+
'text/javascript',
|
|
46
|
+
'application/javascript',
|
|
47
|
+
'text/xml',
|
|
48
|
+
'application/xml'
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `Content-Disposition` for stored files: active content (HTML, SVG, scripts) is forced to
|
|
53
|
+
* download so a stored file cannot run script on the storage origin.
|
|
54
|
+
*/
|
|
55
|
+
export function contentDispositionFor(mimeType: string, filename: string): string | undefined {
|
|
56
|
+
const base = mimeType.split(';')[0].trim().toLowerCase();
|
|
57
|
+
if (!ACTIVE_CONTENT_TYPES.has(base)) return undefined;
|
|
58
|
+
return `attachment; filename="${filename.replace(/["\\\r\n]/g, '_')}"`;
|
|
59
|
+
}
|
|
60
|
+
|
|
18
61
|
export function normalizeUploadData(
|
|
19
62
|
data: Uint8Array | ArrayBuffer | Buffer | Blob | string | unknown,
|
|
20
63
|
encoding?: 'base64' | 'utf-8' | 'binary',
|
|
@@ -92,10 +135,7 @@ export class InMemoryFileStore implements FileStorageAdapter {
|
|
|
92
135
|
}
|
|
93
136
|
|
|
94
137
|
async upload(input: UploadFileInput): Promise<UploadFileResult> {
|
|
95
|
-
const
|
|
96
|
-
const randomId = Math.random().toString(36).substring(2, 9);
|
|
97
|
-
const sanitizedFilename = input.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
98
|
-
const storageKey = `${prefix}${Date.now()}_${randomId}_${sanitizedFilename}`;
|
|
138
|
+
const storageKey = buildStorageKey(input.pathPrefix, input.filename);
|
|
99
139
|
|
|
100
140
|
const buffer = normalizeUploadData(input.data, input.encoding, input.mimeType);
|
|
101
141
|
const mimeType = input.mimeType || 'application/octet-stream';
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
PresignedUrlOptions,
|
|
6
6
|
PresignedUploadResult
|
|
7
7
|
} from '../types/index.js';
|
|
8
|
-
import { normalizeUploadData } from './file-storage.js';
|
|
8
|
+
import { buildStorageKey, contentDispositionFor, normalizeUploadData } from './file-storage.js';
|
|
9
9
|
|
|
10
10
|
export interface FirebaseStorageBucketInterface {
|
|
11
11
|
name?: string;
|
|
@@ -90,7 +90,11 @@ export class FirebaseStorageAdapter implements FileStorageAdapter {
|
|
|
90
90
|
private publicUrlBase?: string;
|
|
91
91
|
|
|
92
92
|
constructor(config: FirebaseStorageConfig = {}) {
|
|
93
|
-
|
|
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;
|
|
94
98
|
this.bucketName = config.bucketName || this.bucket.name || 'default-bucket';
|
|
95
99
|
if (this.bucket && config.bucketName && this.bucket.name !== config.bucketName) {
|
|
96
100
|
this.bucket.name = config.bucketName;
|
|
@@ -107,10 +111,7 @@ export class FirebaseStorageAdapter implements FileStorageAdapter {
|
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
async upload(input: UploadFileInput): Promise<UploadFileResult> {
|
|
110
|
-
const
|
|
111
|
-
const randomId = Math.random().toString(36).substring(2, 9);
|
|
112
|
-
const sanitizedFilename = input.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
113
|
-
const storageKey = `${prefix}${Date.now()}_${randomId}_${sanitizedFilename}`;
|
|
114
|
+
const storageKey = buildStorageKey(input.pathPrefix, input.filename);
|
|
114
115
|
|
|
115
116
|
const bodyData = normalizeUploadData(input.data, input.encoding, input.mimeType);
|
|
116
117
|
const mimeType = input.mimeType || 'application/octet-stream';
|
|
@@ -124,6 +125,7 @@ export class FirebaseStorageAdapter implements FileStorageAdapter {
|
|
|
124
125
|
await fileRef.save(bodyData, {
|
|
125
126
|
metadata: {
|
|
126
127
|
contentType: mimeType,
|
|
128
|
+
...(contentDispositionFor(mimeType, input.filename) ? { contentDisposition: contentDispositionFor(mimeType, input.filename) } : {}),
|
|
127
129
|
metadata: {
|
|
128
130
|
firebaseStorageDownloadTokens: downloadToken
|
|
129
131
|
}
|
|
@@ -201,12 +203,7 @@ export class FirebaseStorageAdapter implements FileStorageAdapter {
|
|
|
201
203
|
};
|
|
202
204
|
}
|
|
203
205
|
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
uploadUrl: this.getPublicUrl(storageKey),
|
|
207
|
-
storageKey,
|
|
208
|
-
method: 'PUT',
|
|
209
|
-
headers: options.contentType ? { 'Content-Type': options.contentType } : {}
|
|
210
|
-
};
|
|
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.');
|
|
211
208
|
}
|
|
212
209
|
}
|
package/src/storage/s3.ts
CHANGED
|
@@ -5,150 +5,147 @@ import type {
|
|
|
5
5
|
PresignedUrlOptions,
|
|
6
6
|
PresignedUploadResult
|
|
7
7
|
} from '../types/index.js';
|
|
8
|
-
import { normalizeUploadData } from './file-storage.js';
|
|
8
|
+
import { buildStorageKey, contentDispositionFor, normalizeUploadData } from './file-storage.js';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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;
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
export interface S3StorageConfig {
|
|
18
29
|
bucket: string;
|
|
19
30
|
region?: string;
|
|
31
|
+
/** Custom endpoint for S3-compatible services (MinIO, R2, LocalStack). */
|
|
20
32
|
endpoint?: string;
|
|
21
|
-
|
|
22
|
-
secretAccessKey?: string;
|
|
23
|
-
sessionToken?: string;
|
|
33
|
+
/** Base URL for public object links (e.g. a CDN). */
|
|
24
34
|
publicUrlBase?: string;
|
|
25
35
|
forcePathStyle?: boolean;
|
|
26
|
-
|
|
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;
|
|
27
52
|
}
|
|
28
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
|
+
*/
|
|
29
58
|
export class S3StorageAdapter implements FileStorageAdapter {
|
|
30
|
-
private bucket: string;
|
|
31
|
-
private
|
|
32
|
-
private
|
|
33
|
-
private
|
|
34
|
-
private
|
|
35
|
-
private sessionToken?: string;
|
|
36
|
-
private publicUrlBase?: string;
|
|
37
|
-
private forcePathStyle: boolean;
|
|
38
|
-
private s3Client?: S3ClientInterface;
|
|
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;
|
|
39
64
|
|
|
40
65
|
constructor(config: S3StorageConfig) {
|
|
41
66
|
if (!config.bucket) {
|
|
42
67
|
throw new Error('S3StorageAdapter requires a "bucket" name in its config.');
|
|
43
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;
|
|
44
76
|
this.bucket = config.bucket;
|
|
45
|
-
|
|
46
|
-
this.endpoint = (config.endpoint || `https://s3.${
|
|
47
|
-
this.accessKeyId = config.accessKeyId;
|
|
48
|
-
this.secretAccessKey = config.secretAccessKey;
|
|
49
|
-
this.sessionToken = config.sessionToken;
|
|
77
|
+
const region = config.region || 'us-east-1';
|
|
78
|
+
this.endpoint = (config.endpoint || `https://s3.${region}.amazonaws.com`).replace(/\/+$/, '');
|
|
50
79
|
this.publicUrlBase = config.publicUrlBase?.replace(/\/+$/, '');
|
|
51
|
-
this.forcePathStyle =
|
|
52
|
-
|
|
80
|
+
this.forcePathStyle =
|
|
81
|
+
config.forcePathStyle ?? (this.endpoint.includes('localhost') || this.endpoint.includes('127.0.0.1'));
|
|
53
82
|
}
|
|
54
83
|
|
|
55
|
-
private
|
|
56
|
-
if (this.
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
if (this.forcePathStyle) {
|
|
60
|
-
return `${this.endpoint}/${this.bucket}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
|
|
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".`);
|
|
61
87
|
}
|
|
62
|
-
|
|
63
|
-
return `${hostWithBucket}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
|
|
88
|
+
return { client: this.config.client, commands: this.config.commands };
|
|
64
89
|
}
|
|
65
90
|
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
}
|
|
71
97
|
|
|
72
|
-
|
|
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);
|
|
73
102
|
const mimeType = input.mimeType || 'application/octet-stream';
|
|
74
|
-
const
|
|
75
|
-
const url = this.getObjectUrl(storageKey);
|
|
103
|
+
const disposition = contentDispositionFor(mimeType, input.filename);
|
|
76
104
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
} else if (typeof this.s3Client.putObject === 'function') {
|
|
89
|
-
await this.s3Client.putObject({
|
|
90
|
-
Bucket: this.bucket,
|
|
91
|
-
Key: storageKey,
|
|
92
|
-
Body: bodyData,
|
|
93
|
-
ContentType: mimeType
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
} catch {
|
|
97
|
-
// Fallback
|
|
98
|
-
}
|
|
99
|
-
}
|
|
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
|
+
);
|
|
100
114
|
|
|
101
|
-
return {
|
|
102
|
-
storageKey,
|
|
103
|
-
url,
|
|
104
|
-
sizeBytes,
|
|
105
|
-
mimeType
|
|
106
|
-
};
|
|
115
|
+
return { storageKey, url: await this.getDownloadUrl(storageKey), sizeBytes: body.byteLength, mimeType };
|
|
107
116
|
}
|
|
108
117
|
|
|
118
|
+
/** Deletes an object. S3 deletes are idempotent, so a missing object still returns `true`. */
|
|
109
119
|
async delete(storageKey: string): Promise<boolean> {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (typeof this.s3Client.send === 'function') {
|
|
113
|
-
await this.s3Client.send({
|
|
114
|
-
input: {
|
|
115
|
-
Bucket: this.bucket,
|
|
116
|
-
Key: storageKey
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
return true;
|
|
120
|
-
} else if (typeof this.s3Client.deleteObject === 'function') {
|
|
121
|
-
await this.s3Client.deleteObject({
|
|
122
|
-
Bucket: this.bucket,
|
|
123
|
-
Key: storageKey
|
|
124
|
-
});
|
|
125
|
-
return true;
|
|
126
|
-
}
|
|
127
|
-
} catch {
|
|
128
|
-
return false;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
120
|
+
const { client, commands } = this.requireClient('delete');
|
|
121
|
+
await client.send(new commands.DeleteObjectCommand({ Bucket: this.bucket, Key: storageKey }));
|
|
131
122
|
return true;
|
|
132
123
|
}
|
|
133
124
|
|
|
134
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
|
+
}
|
|
135
130
|
return this.getObjectUrl(storageKey);
|
|
136
131
|
}
|
|
137
132
|
|
|
138
133
|
async getPresignedUploadUrl(options: PresignedUrlOptions): Promise<PresignedUploadResult> {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const headers: Record<string, string> = {};
|
|
143
|
-
if (options.contentType) {
|
|
144
|
-
headers['Content-Type'] = options.contentType;
|
|
134
|
+
if (!this.config.presign || !this.config.commands) {
|
|
135
|
+
throw new Error('S3StorageAdapter cannot presign uploads: configure "presign" and "commands".');
|
|
145
136
|
}
|
|
146
|
-
|
|
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 });
|
|
147
143
|
return {
|
|
148
144
|
uploadUrl,
|
|
149
|
-
storageKey,
|
|
145
|
+
storageKey: options.storageKey,
|
|
150
146
|
method: 'PUT',
|
|
151
|
-
|
|
147
|
+
// The signature covers Content-Type, so the uploader must send exactly this header.
|
|
148
|
+
headers: options.contentType ? { 'Content-Type': options.contentType } : {}
|
|
152
149
|
};
|
|
153
150
|
}
|
|
154
151
|
}
|
|
@@ -42,43 +42,85 @@ describe('Storage Adapters', () => {
|
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
describe('S3StorageAdapter', () => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
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
55
|
bucket: 'my-project-bucket',
|
|
56
56
|
region: 'eu-west-1',
|
|
57
|
-
|
|
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
|
+
}
|
|
58
63
|
});
|
|
64
|
+
return { adapter, sent, presigned };
|
|
65
|
+
}
|
|
59
66
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
mimeType: 'application/pdf',
|
|
64
|
-
pathPrefix: 'docs'
|
|
65
|
-
});
|
|
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' });
|
|
66
70
|
|
|
67
|
-
expect(res.storageKey).
|
|
68
|
-
expect(res.
|
|
71
|
+
expect(res.storageKey).toMatch(/^docs\/\d+_[0-9a-f]{12}_spec\.pdf$/);
|
|
72
|
+
expect(res.sizeBytes).toBe(4);
|
|
69
73
|
expect(res.url).toBe(`https://my-project-bucket.s3.eu-west-1.amazonaws.com/${res.storageKey}`);
|
|
70
|
-
expect(
|
|
71
|
-
expect(
|
|
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
|
+
});
|
|
72
81
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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');
|
|
76
91
|
});
|
|
77
|
-
expect(
|
|
78
|
-
expect(
|
|
92
|
+
await expect(adapter.upload({ filename: 'a.txt', data: 'x' })).rejects.toThrow('AccessDenied');
|
|
93
|
+
await expect(adapter.delete('a.txt')).rejects.toThrow('AccessDenied');
|
|
94
|
+
});
|
|
79
95
|
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
}
|
|
82
124
|
});
|
|
83
125
|
});
|
|
84
126
|
|
|
@@ -89,6 +131,7 @@ describe('Storage Adapters', () => {
|
|
|
89
131
|
bucket: mockStorage,
|
|
90
132
|
bucketName: 'my-app.appspot.com'
|
|
91
133
|
});
|
|
134
|
+
expect(() => new FirebaseStorageAdapter()).toThrow(/requires a "bucket"/);
|
|
92
135
|
|
|
93
136
|
const uploadResult = await adapter.upload({
|
|
94
137
|
filename: 'architecture.png',
|
|
@@ -213,4 +256,19 @@ describe('Storage Adapters', () => {
|
|
|
213
256
|
expect(reviewAttachments.length).toBe(0);
|
|
214
257
|
});
|
|
215
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
|
+
});
|
|
216
273
|
});
|
|
274
|
+
|
package/src/types/index.ts
CHANGED
|
@@ -527,7 +527,17 @@ export interface CriticalPathConfig {
|
|
|
527
527
|
* When omitted, actors may do anything within their tenant.
|
|
528
528
|
*/
|
|
529
529
|
authorize?: AuthorizationPolicy;
|
|
530
|
-
|
|
530
|
+
/**
|
|
531
|
+
* Storage adapter instance, e.g. `new SQLiteStore({ filename: 'app.db' })` or
|
|
532
|
+
* `new FirebaseStore({ db })`. Defaults to an `InMemoryStore`.
|
|
533
|
+
*/
|
|
534
|
+
store?: import('../store/index.js').StorageAdapter;
|
|
535
|
+
/**
|
|
536
|
+
* Your app's user directory: names, weekly capacity and work schedules used by workload and
|
|
537
|
+
* capacity calculations. Critical Path does not store users; pass a list, or a function
|
|
538
|
+
* (called per calculation, with the calling actor) that loads them from your auth system.
|
|
539
|
+
*/
|
|
540
|
+
users?: User[] | ((actor?: Actor) => User[] | Promise<User[]>);
|
|
531
541
|
fileStorage?: FileStorageAdapter;
|
|
532
542
|
plugins?: CriticalPathPlugin[];
|
|
533
543
|
/** Static webhooks (not stored or editable through the API). */
|
|
@@ -538,6 +548,7 @@ export interface CriticalPathConfig {
|
|
|
538
548
|
initialData?: {
|
|
539
549
|
projects?: Project[];
|
|
540
550
|
tasks?: Task[];
|
|
551
|
+
/** Added to the user directory (see `CriticalPathConfig.users`). */
|
|
541
552
|
users?: User[];
|
|
542
553
|
iterations?: Iteration[];
|
|
543
554
|
teams?: Team[];
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/authorization.ts","./src/domain/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/errors.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/task-duration.test.ts","./src/domain/workload.test.ts","./src/domain/workload.ts","./src/engine/actor.test.ts","./src/engine/authorization.test.ts","./src/engine/cascade.test.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/plugins/plugins.test.ts","./src/schemas/index.ts","./src/schemas/schemas.test.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/query.test.ts","./src/store/query.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/fractional-index.test.ts","./src/utils/fractional-index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts","./src/webhooks/dispatcher.ts","./src/webhooks/index.ts","./src/webhooks/signature.ts","./src/webhooks/webhooks.test.ts"],"version":"5.9.3"}
|
|
1
|
+
{"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/authorization.ts","./src/domain/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/errors.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/task-duration.test.ts","./src/domain/workload.test.ts","./src/domain/workload.ts","./src/engine/actor.test.ts","./src/engine/authorization.test.ts","./src/engine/cascade.test.ts","./src/engine/config.test.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/plugins/plugins.test.ts","./src/schemas/index.ts","./src/schemas/schemas.test.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/query.test.ts","./src/store/query.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/fractional-index.test.ts","./src/utils/fractional-index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts","./src/webhooks/dispatcher.ts","./src/webhooks/index.ts","./src/webhooks/signature.ts","./src/webhooks/webhooks.test.ts"],"version":"5.9.3"}
|