@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.
@@ -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: input.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
+ });
@@ -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
  }
@@ -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();
@@ -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;
@@ -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"}