@critical-path/core 0.34.0 → 0.36.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 +18 -0
- package/README.md +11 -0
- package/dist/engine/index.d.ts +2 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +25 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/storage/upload-limits.test.d.ts +2 -0
- package/dist/storage/upload-limits.test.d.ts.map +1 -0
- package/dist/storage/upload-limits.test.js +31 -0
- package/dist/storage/upload-limits.test.js.map +1 -0
- package/dist/store/firebase.js +5 -5
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/index.d.ts +5 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +17 -0
- package/dist/store/index.js.map +1 -1
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +15 -0
- package/dist/testing/conformance.js.map +1 -1
- package/dist/types/index.d.ts +11 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/engine/index.ts +25 -2
- package/src/storage/upload-limits.test.ts +41 -0
- package/src/store/firebase.ts +5 -5
- package/src/store/index.ts +17 -0
- package/src/testing/conformance.ts +18 -0
- package/src/types/index.ts +12 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/engine/index.ts
CHANGED
|
@@ -97,7 +97,7 @@ import { validateCustomFieldValues, validateCustomFieldDefinitions } from '../do
|
|
|
97
97
|
import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
|
|
98
98
|
import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
|
|
99
99
|
import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
|
|
100
|
-
import { buildStorageKey } from '../storage/file-storage.js';
|
|
100
|
+
import { buildStorageKey, normalizeUploadData } from '../storage/file-storage.js';
|
|
101
101
|
import { generateWebhookSecret } from '../webhooks/signature.js';
|
|
102
102
|
import type { DomainEvent } from '../domain/events.js';
|
|
103
103
|
import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
|
|
@@ -1595,6 +1595,7 @@ export class CriticalPathEngine {
|
|
|
1595
1595
|
input: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt' | 'uploaderId'> & { uploaderId?: string }
|
|
1596
1596
|
): Promise<Attachment> {
|
|
1597
1597
|
await this.requireAttachmentCreate(input);
|
|
1598
|
+
this.assertUploadAllowed(input.mimeType, input.sizeBytes);
|
|
1598
1599
|
if (this.actor) await this.assertStorageKeyBelongs(input);
|
|
1599
1600
|
const attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
|
|
1600
1601
|
? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
|
|
@@ -1676,9 +1677,13 @@ export class CriticalPathEngine {
|
|
|
1676
1677
|
throw new ValidationError('Attachments must reference a project, task or comment.');
|
|
1677
1678
|
}
|
|
1678
1679
|
|
|
1680
|
+
// Decode once so limits apply to the real byte size, then hand the bytes to the adapter.
|
|
1681
|
+
const data = normalizeUploadData(input.data, input.encoding, input.mimeType);
|
|
1682
|
+
this.assertUploadAllowed(input.mimeType || 'application/octet-stream', data.byteLength);
|
|
1683
|
+
|
|
1679
1684
|
const uploadResult = await this.fileStorage.upload({
|
|
1680
1685
|
filename: input.filename,
|
|
1681
|
-
data
|
|
1686
|
+
data,
|
|
1682
1687
|
mimeType: input.mimeType,
|
|
1683
1688
|
pathPrefix: projectId && (this.actor || !input.pathPrefix) ? `projects/${projectId}` : input.pathPrefix
|
|
1684
1689
|
});
|
|
@@ -1747,6 +1752,7 @@ export class CriticalPathEngine {
|
|
|
1747
1752
|
throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
|
|
1748
1753
|
}
|
|
1749
1754
|
if (!options.projectId) throw new ValidationError('projectId is required for presigned uploads.');
|
|
1755
|
+
this.assertUploadAllowed(options.contentType ?? 'application/octet-stream');
|
|
1750
1756
|
if (this.enforcing) {
|
|
1751
1757
|
await this.requireProjectAccess('attachment.create', options.projectId);
|
|
1752
1758
|
} else if (!(await this.store.getProject(options.projectId))) {
|
|
@@ -1759,6 +1765,23 @@ export class CriticalPathEngine {
|
|
|
1759
1765
|
});
|
|
1760
1766
|
}
|
|
1761
1767
|
|
|
1768
|
+
/** Enforces `config.uploads` (MIME allow-list with `type/*` wildcards, and maximum size). */
|
|
1769
|
+
private assertUploadAllowed(mimeType: string, sizeBytes?: number): void {
|
|
1770
|
+
const limits = this.config.uploads;
|
|
1771
|
+
if (!limits) return;
|
|
1772
|
+
if (limits.maxBytes !== undefined && sizeBytes !== undefined && sizeBytes > limits.maxBytes) {
|
|
1773
|
+
throw new ValidationError(`File is ${sizeBytes} bytes; the maximum is ${limits.maxBytes} bytes.`);
|
|
1774
|
+
}
|
|
1775
|
+
if (limits.allowedMimeTypes) {
|
|
1776
|
+
const type = mimeType.split(';')[0].trim().toLowerCase();
|
|
1777
|
+
const allowed = limits.allowedMimeTypes.some((pattern) => {
|
|
1778
|
+
const p = pattern.toLowerCase();
|
|
1779
|
+
return p.endsWith('/*') ? type.startsWith(p.slice(0, -1)) : type === p;
|
|
1780
|
+
});
|
|
1781
|
+
if (!allowed) throw new ValidationError(`File type "${type}" is not allowed.`);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1762
1785
|
/**
|
|
1763
1786
|
* A caller-supplied `storageKey` must live under the attachment's project prefix, because
|
|
1764
1787
|
* deleting the attachment deletes that file: otherwise anyone could register, then delete,
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
});
|
package/src/store/firebase.ts
CHANGED
|
@@ -56,14 +56,14 @@ export class InMemoryFirestoreMock implements FirestoreDBInterface {
|
|
|
56
56
|
return {
|
|
57
57
|
exists: !!data,
|
|
58
58
|
id: docId,
|
|
59
|
-
data: () => data
|
|
59
|
+
data: () => structuredClone(data)
|
|
60
60
|
};
|
|
61
61
|
},
|
|
62
62
|
async set(data: any, options?: { merge?: boolean }) {
|
|
63
63
|
if (options?.merge && colMap.has(docId)) {
|
|
64
|
-
colMap.set(docId, { ...colMap.get(docId), ...data });
|
|
64
|
+
colMap.set(docId, structuredClone({ ...colMap.get(docId), ...data }));
|
|
65
65
|
} else {
|
|
66
|
-
colMap.set(docId, data);
|
|
66
|
+
colMap.set(docId, structuredClone(data));
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
async delete() {
|
|
@@ -74,7 +74,7 @@ export class InMemoryFirestoreMock implements FirestoreDBInterface {
|
|
|
74
74
|
async get() {
|
|
75
75
|
const docs = Array.from(colMap.entries()).map(([id, data]) => ({
|
|
76
76
|
id,
|
|
77
|
-
data: () => data
|
|
77
|
+
data: () => structuredClone(data)
|
|
78
78
|
}));
|
|
79
79
|
return { docs };
|
|
80
80
|
},
|
|
@@ -88,7 +88,7 @@ export class InMemoryFirestoreMock implements FirestoreDBInterface {
|
|
|
88
88
|
})
|
|
89
89
|
.map(([id, data]) => ({
|
|
90
90
|
id,
|
|
91
|
-
data: () => data
|
|
91
|
+
data: () => structuredClone(data)
|
|
92
92
|
}));
|
|
93
93
|
return { docs };
|
|
94
94
|
}
|
package/src/store/index.ts
CHANGED
|
@@ -148,6 +148,10 @@ export interface StorageAdapter
|
|
|
148
148
|
DependencyRepository,
|
|
149
149
|
WebhookRepository {}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Map-backed store for development and tests. Reads and writes are deep-copied, so it behaves
|
|
153
|
+
* like a database: returned records can be mutated freely without affecting stored state.
|
|
154
|
+
*/
|
|
151
155
|
export class InMemoryStore implements StorageAdapter {
|
|
152
156
|
private projects = new Map<string, Project>();
|
|
153
157
|
private workflows = new Map<string, Workflow>();
|
|
@@ -163,6 +167,19 @@ export class InMemoryStore implements StorageAdapter {
|
|
|
163
167
|
private dependencies = new Map<string, TaskDependency>();
|
|
164
168
|
private webhooks = new Map<string, Webhook>();
|
|
165
169
|
|
|
170
|
+
constructor() {
|
|
171
|
+
// Hand out copies: callers mutating a returned record (or an input they keep using) must not
|
|
172
|
+
// change what is stored, matching the database-backed adapters. Internal calls between
|
|
173
|
+
// methods use the unwrapped instance, so each call clones only at the boundary.
|
|
174
|
+
return new Proxy(this, {
|
|
175
|
+
get(target, property, receiver) {
|
|
176
|
+
const value = Reflect.get(target, property, receiver);
|
|
177
|
+
if (typeof value !== 'function' || property === 'constructor') return value;
|
|
178
|
+
return async (...args: unknown[]) => structuredClone(await value.apply(target, structuredClone(args)));
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
166
183
|
async getProjects(): Promise<Project[]> {
|
|
167
184
|
return Array.from(this.projects.values());
|
|
168
185
|
}
|
|
@@ -140,6 +140,24 @@ export function runStorageAdapterConformance({ name, createStore, describe, it,
|
|
|
140
140
|
expect(updated.parentId === null).toBe(false);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
it('returns copies, so mutating results or reused inputs does not change stored records', async () => {
|
|
144
|
+
const store = await createStore();
|
|
145
|
+
const project = await store.createProject({ name: 'P' });
|
|
146
|
+
const input = { projectId: project.id, title: 'Original', status: 'todo', priority: 'medium', tags: ['a'] };
|
|
147
|
+
const created = await store.createTask(input);
|
|
148
|
+
|
|
149
|
+
input.tags.push('mutated-input');
|
|
150
|
+
input.title = 'Mutated input';
|
|
151
|
+
const fetched = (await store.getTask(created.id))!;
|
|
152
|
+
fetched.tags!.push('mutated-result');
|
|
153
|
+
fetched.title = 'Mutated result';
|
|
154
|
+
(await store.getTasks(project.id))[0].title = 'Mutated list';
|
|
155
|
+
|
|
156
|
+
const stored = await store.getTask(created.id);
|
|
157
|
+
expect(stored?.title).toBe('Original');
|
|
158
|
+
expect(stored?.tags).toEqual(['a']);
|
|
159
|
+
});
|
|
160
|
+
|
|
143
161
|
it('follows the not-found contract', async () => {
|
|
144
162
|
const store = await createStore();
|
|
145
163
|
expect(await store.getProject('missing')).toBeNull();
|
package/src/types/index.ts
CHANGED
|
@@ -378,6 +378,16 @@ export interface UploadFileInput {
|
|
|
378
378
|
encoding?: 'base64' | 'utf-8' | 'binary';
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
export interface UploadLimits {
|
|
382
|
+
/** Largest accepted upload in bytes. Applies to direct uploads and declared `sizeBytes`. */
|
|
383
|
+
maxBytes?: number;
|
|
384
|
+
/**
|
|
385
|
+
* Accepted MIME types; entries may end in `/*` (e.g. `image/*`). Applies to uploads, presigned
|
|
386
|
+
* uploads and registered attachments. Default: any type.
|
|
387
|
+
*/
|
|
388
|
+
allowedMimeTypes?: string[];
|
|
389
|
+
}
|
|
390
|
+
|
|
381
391
|
export interface UploadFileResult {
|
|
382
392
|
storageKey: string;
|
|
383
393
|
url: string;
|
|
@@ -542,6 +552,8 @@ export interface CriticalPathConfig {
|
|
|
542
552
|
plugins?: CriticalPathPlugin[];
|
|
543
553
|
/** Static webhooks (not stored or editable through the API). */
|
|
544
554
|
webhooks?: Omit<Webhook, 'id' | 'createdAt'>[];
|
|
555
|
+
/** Limits applied to attachment uploads, presigned uploads and attachment registration. */
|
|
556
|
+
uploads?: UploadLimits;
|
|
545
557
|
/** Delivery behaviour: queue, timeouts, retries, private URL policy, failure callbacks. */
|
|
546
558
|
webhookDelivery?: import('../webhooks/dispatcher.js').WebhookDeliveryOptions;
|
|
547
559
|
defaultSchedule?: WorkSchedule;
|
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/config.test.ts","./src/engine/entity-events.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/conformance.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/testing/conformance.ts","./src/testing/index.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/entity-events.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/storage/upload-limits.test.ts","./src/store/conformance.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/testing/conformance.ts","./src/testing/index.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"}
|