@critical-path/core 0.29.0 → 0.31.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +11 -1
  3. package/dist/engine/authorization.test.js +8 -4
  4. package/dist/engine/authorization.test.js.map +1 -1
  5. package/dist/engine/index.d.ts +26 -4
  6. package/dist/engine/index.d.ts.map +1 -1
  7. package/dist/engine/index.js +68 -13
  8. package/dist/engine/index.js.map +1 -1
  9. package/dist/schemas/index.d.ts +84 -33
  10. package/dist/schemas/index.d.ts.map +1 -1
  11. package/dist/schemas/index.js +29 -3
  12. package/dist/schemas/index.js.map +1 -1
  13. package/dist/storage/file-storage.d.ts +10 -0
  14. package/dist/storage/file-storage.d.ts.map +1 -1
  15. package/dist/storage/file-storage.js +40 -4
  16. package/dist/storage/file-storage.js.map +1 -1
  17. package/dist/storage/firebase-storage.d.ts.map +1 -1
  18. package/dist/storage/firebase-storage.js +10 -13
  19. package/dist/storage/firebase-storage.js.map +1 -1
  20. package/dist/storage/s3.d.ts +46 -18
  21. package/dist/storage/s3.d.ts.map +1 -1
  22. package/dist/storage/s3.js +59 -89
  23. package/dist/storage/s3.js.map +1 -1
  24. package/dist/storage/storage.test.js +88 -27
  25. package/dist/storage/storage.test.js.map +1 -1
  26. package/dist/store/firebase.d.ts +4 -0
  27. package/dist/store/firebase.d.ts.map +1 -1
  28. package/dist/store/firebase.js +10 -0
  29. package/dist/store/firebase.js.map +1 -1
  30. package/dist/store/index.d.ts +8 -0
  31. package/dist/store/index.d.ts.map +1 -1
  32. package/dist/store/index.js +10 -0
  33. package/dist/store/index.js.map +1 -1
  34. package/dist/store/query.d.ts +50 -0
  35. package/dist/store/query.d.ts.map +1 -0
  36. package/dist/store/query.js +79 -0
  37. package/dist/store/query.js.map +1 -0
  38. package/dist/store/query.test.d.ts +2 -0
  39. package/dist/store/query.test.d.ts.map +1 -0
  40. package/dist/store/query.test.js +89 -0
  41. package/dist/store/query.test.js.map +1 -0
  42. package/dist/store/sqlite.d.ts +3 -0
  43. package/dist/store/sqlite.d.ts.map +1 -1
  44. package/dist/store/sqlite.js +90 -0
  45. package/dist/store/sqlite.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/engine/authorization.test.ts +13 -5
  48. package/src/engine/index.ts +73 -16
  49. package/src/schemas/index.ts +34 -3
  50. package/src/storage/file-storage.ts +44 -4
  51. package/src/storage/firebase-storage.ts +10 -13
  52. package/src/storage/s3.ts +98 -101
  53. package/src/storage/storage.test.ts +86 -28
  54. package/src/store/firebase.ts +14 -0
  55. package/src/store/index.ts +24 -0
  56. package/src/store/query.test.ts +110 -0
  57. package/src/store/query.ts +113 -0
  58. package/src/store/sqlite.ts +93 -0
  59. package/tsconfig.tsbuildinfo +1 -1
@@ -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
- this.bucket = config.bucket || new InMemoryFirebaseStorageMock(config.bucketName || 'default-bucket');
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 prefix = input.pathPrefix ? `${input.pathPrefix.replace(/\/+$/, '')}/` : '';
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
- // Fallback to direct public upload url
205
- return {
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
- export interface S3ClientInterface {
11
- send?(command: any): Promise<any>;
12
- putObject?(params: any): Promise<any>;
13
- deleteObject?(params: any): Promise<any>;
14
- getSignedUrlPromise?(operation: string, params: any): Promise<string>;
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
- accessKeyId?: string;
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
- s3Client?: S3ClientInterface;
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 region: string;
32
- private endpoint: string;
33
- private accessKeyId?: string;
34
- private secretAccessKey?: string;
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
- this.region = config.region || 'us-east-1';
46
- this.endpoint = (config.endpoint || `https://s3.${this.region}.amazonaws.com`).replace(/\/+$/, '');
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 = config.forcePathStyle ?? (this.endpoint.includes('localhost') || this.endpoint.includes('127.0.0.1'));
52
- this.s3Client = config.s3Client;
80
+ this.forcePathStyle =
81
+ config.forcePathStyle ?? (this.endpoint.includes('localhost') || this.endpoint.includes('127.0.0.1'));
53
82
  }
54
83
 
55
- private getObjectUrl(storageKey: string): string {
56
- if (this.publicUrlBase) {
57
- return `${this.publicUrlBase}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
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
- const hostWithBucket = this.endpoint.replace('://', `://${this.bucket}.`);
63
- return `${hostWithBucket}/${encodeURIComponent(storageKey).replace(/%2F/g, '/')}`;
88
+ return { client: this.config.client, commands: this.config.commands };
64
89
  }
65
90
 
66
- async upload(input: UploadFileInput): Promise<UploadFileResult> {
67
- const prefix = input.pathPrefix ? `${input.pathPrefix.replace(/\/+$/, '')}/` : '';
68
- const randomId = Math.random().toString(36).substring(2, 9);
69
- const sanitizedFilename = input.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
70
- const storageKey = `${prefix}${Date.now()}_${randomId}_${sanitizedFilename}`;
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
- const bodyData = normalizeUploadData(input.data, input.encoding, input.mimeType);
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 sizeBytes = bodyData.byteLength;
75
- const url = this.getObjectUrl(storageKey);
103
+ const disposition = contentDispositionFor(mimeType, input.filename);
76
104
 
77
- if (this.s3Client) {
78
- try {
79
- if (typeof this.s3Client.send === 'function') {
80
- await this.s3Client.send({
81
- input: {
82
- Bucket: this.bucket,
83
- Key: storageKey,
84
- Body: bodyData,
85
- ContentType: mimeType
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
- if (this.s3Client) {
111
- try {
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
- const storageKey = options.storageKey;
140
- const uploadUrl = this.getObjectUrl(storageKey);
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
- headers
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
- it('generates correct URLs and handles duck-typed client calls', async () => {
46
- let sentCommand: any = null;
47
- const mockClient = {
48
- send: async (cmd: any) => {
49
- sentCommand = cmd;
50
- return {};
51
- }
52
- };
53
-
54
- const s3 = new 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
55
  bucket: 'my-project-bucket',
56
56
  region: 'eu-west-1',
57
- s3Client: mockClient
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
- const res = await s3.upload({
61
- filename: 'spec.pdf',
62
- data: new Uint8Array([1, 2, 3, 4]),
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).toContain('docs/');
68
- expect(res.storageKey).toContain('spec.pdf');
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(sentCommand).toBeDefined();
71
- expect(sentCommand.input.Bucket).toBe('my-project-bucket');
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
- const presigned = await s3.getPresignedUploadUrl({
74
- storageKey: 'uploads/file.zip',
75
- contentType: 'application/zip'
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(presigned.uploadUrl).toContain('my-project-bucket');
78
- expect(presigned.headers?.['Content-Type']).toBe('application/zip');
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
- const deleted = await s3.delete(res.storageKey);
81
- expect(deleted).toBe(true);
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
+
@@ -1,4 +1,5 @@
1
1
  import type { StorageAdapter } from './index.js';
2
+ import { matchesActivityQuery, matchesTaskQuery, paginate, type ActivityQuery, type Page, type TaskQuery } from './query.js';
2
3
  import type {
3
4
  Project,
4
5
  Task,
@@ -344,6 +345,12 @@ export class FirebaseStore implements StorageAdapter {
344
345
  return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
345
346
  }
346
347
 
348
+ /** Pushes down the project filter; remaining filters and pagination are applied in memory. */
349
+ async queryTasks(query: TaskQuery): Promise<Page<Task>> {
350
+ const tasks = (await this.getTasks(query.projectId)).filter((t) => matchesTaskQuery(t, query));
351
+ return paginate(tasks, (t) => t.createdAt, 'asc', query.limit, query.cursor);
352
+ }
353
+
347
354
  async getTask(id: string): Promise<Task | null> {
348
355
  const snap = await this.db.collection('tasks').doc(id).get();
349
356
  if (!snap.exists) return null;
@@ -526,6 +533,13 @@ export class FirebaseStore implements StorageAdapter {
526
533
  return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
527
534
  }
528
535
 
536
+ async queryActivities(query: ActivityQuery): Promise<Page<Activity>> {
537
+ const activities = (await this.getActivities({ projectId: query.projectId, taskId: query.taskId })).filter((a) =>
538
+ matchesActivityQuery(a, query)
539
+ );
540
+ return paginate(activities, (a) => a.createdAt, 'desc', query.limit, query.cursor);
541
+ }
542
+
529
543
  async logActivity(activity: Omit<Activity, 'id' | 'createdAt'>): Promise<Activity> {
530
544
  const docRef = this.db.collection('activities').doc();
531
545
  const now = new Date().toISOString();
@@ -18,6 +18,16 @@ import type {
18
18
  CreateDeliverableInput
19
19
  } from '../types/index.js';
20
20
  import { generateProjectKey } from '../utils/key.js';
21
+ import {
22
+ matchesActivityQuery,
23
+ matchesTaskQuery,
24
+ paginate,
25
+ type ActivityQuery,
26
+ type Page,
27
+ type TaskQuery
28
+ } from './query.js';
29
+
30
+ export * from './query.js';
21
31
 
22
32
  export interface ProjectRepository {
23
33
  getProjects(): Promise<Project[]>;
@@ -37,6 +47,8 @@ export interface WorkflowRepository {
37
47
 
38
48
  export interface TaskRepository {
39
49
  getTasks(projectId?: string): Promise<Task[]>;
50
+ /** Filtered, paginated tasks ordered by `(createdAt, id)` ascending. */
51
+ queryTasks(query: TaskQuery): Promise<Page<Task>>;
40
52
  getTask(id: string): Promise<Task | null>;
41
53
  createTask(task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<Task>;
42
54
  updateTask(id: string, updates: Partial<Task>): Promise<Task | null>;
@@ -86,6 +98,8 @@ export interface AttachmentRepository {
86
98
 
87
99
  export interface ActivityRepository {
88
100
  getActivities(filter?: { projectId?: string; taskId?: string }): Promise<Activity[]>;
101
+ /** Paginated activity feed ordered by `(createdAt, id)` descending (newest first). */
102
+ queryActivities(query: ActivityQuery): Promise<Page<Activity>>;
89
103
  logActivity(activity: Omit<Activity, 'id' | 'createdAt'>): Promise<Activity>;
90
104
  }
91
105
 
@@ -234,6 +248,11 @@ export class InMemoryStore implements StorageAdapter {
234
248
  return all;
235
249
  }
236
250
 
251
+ async queryTasks(query: TaskQuery): Promise<Page<Task>> {
252
+ const tasks = Array.from(this.tasks.values()).filter((t) => matchesTaskQuery(t, query));
253
+ return paginate(tasks, (t) => t.createdAt, 'asc', query.limit, query.cursor);
254
+ }
255
+
237
256
  async getTask(id: string): Promise<Task | null> {
238
257
  return this.tasks.get(id) || null;
239
258
  }
@@ -507,6 +526,11 @@ export class InMemoryStore implements StorageAdapter {
507
526
  });
508
527
  }
509
528
 
529
+ async queryActivities(query: ActivityQuery): Promise<Page<Activity>> {
530
+ const activities = this.activities.filter((a) => matchesActivityQuery(a, query));
531
+ return paginate(activities, (a) => a.createdAt, 'desc', query.limit, query.cursor);
532
+ }
533
+
510
534
  async logActivity(activity: Omit<Activity, 'id' | 'createdAt'>): Promise<Activity> {
511
535
  const id = `act_${Math.random().toString(36).substring(2, 9)}`;
512
536
  const newAct: Activity = { ...activity, id, createdAt: new Date().toISOString() };
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ CriticalPathEngine,
4
+ InMemoryStore,
5
+ SQLiteStore,
6
+ FirebaseStore,
7
+ InMemoryFirestoreMock,
8
+ ValidationError,
9
+ createRolePolicy,
10
+ MAX_PAGE_SIZE,
11
+ type StorageAdapter,
12
+ type Task
13
+ } from '../index.js';
14
+
15
+ const stores: Array<[string, () => StorageAdapter]> = [
16
+ ['InMemoryStore', () => new InMemoryStore()],
17
+ ['SQLiteStore', () => new SQLiteStore({ filename: ':memory:' })],
18
+ ['FirebaseStore', () => new FirebaseStore({ db: new InMemoryFirestoreMock() })]
19
+ ];
20
+
21
+ describe.each(stores)('task and activity queries (%s)', (_name, makeStore) => {
22
+ async function setup() {
23
+ const engine = new CriticalPathEngine({ store: makeStore() });
24
+ const project = await engine.createProject({ name: 'Query' });
25
+ const other = await engine.createProject({ name: 'Other' });
26
+ const sprint = await engine.createIteration({ projectId: project.id, name: 'S1', status: 'active' });
27
+ const parent = await engine.createTask({ projectId: project.id, title: 'Parent', status: 'in_progress', priority: 'high', assigneeId: 'ana' });
28
+ const child = await engine.createTask({ projectId: project.id, title: 'Child', parentId: parent.id, status: 'todo', priority: 'low', iterationId: sprint.id });
29
+ const shared = await engine.createTask({
30
+ projectId: project.id,
31
+ title: 'Shared',
32
+ status: 'done',
33
+ priority: 'high',
34
+ assignees: [{ id: 'ana', type: 'user' }, { id: 'ben', type: 'user' }]
35
+ });
36
+ const elsewhere = await engine.createTask({ projectId: other.id, title: 'Elsewhere', assigneeId: 'ana' });
37
+ return { engine, project, other, sprint, parent, child, shared, elsewhere };
38
+ }
39
+ const titles = (tasks: Task[]) => tasks.map((t) => t.title).sort();
40
+
41
+ it('filters by status, priority, assignee (including assignees), iteration and parent', async () => {
42
+ const { engine, project, sprint, parent } = await setup();
43
+ const q = (query: object) => engine.queryTasks({ projectId: project.id, ...query }).then((p) => titles(p.items));
44
+
45
+ expect(await q({ status: ['todo', 'done'] })).toEqual(['Child', 'Shared']);
46
+ expect(await q({ priority: ['high'] })).toEqual(['Parent', 'Shared']);
47
+ expect(await q({ assigneeId: 'ana' })).toEqual(['Parent', 'Shared']);
48
+ expect(await q({ assigneeId: 'ben' })).toEqual(['Shared']);
49
+ expect(await q({ iterationId: sprint.id })).toEqual(['Child']);
50
+ expect(await q({ parentId: parent.id })).toEqual(['Child']);
51
+ expect(await q({ parentId: null })).toEqual(['Parent', 'Shared']);
52
+ expect(titles((await engine.queryTasks({ assigneeId: 'ana' })).items)).toEqual(['Elsewhere', 'Parent', 'Shared']);
53
+ });
54
+
55
+ it('pages through every task exactly once, oldest first', async () => {
56
+ const { engine, project } = await setup();
57
+ for (let i = 0; i < 12; i++) await engine.createTask({ projectId: project.id, title: `Bulk ${i}` });
58
+
59
+ const seen: Task[] = [];
60
+ let cursor: string | undefined;
61
+ do {
62
+ const page = await engine.queryTasks({ projectId: project.id, limit: 5, cursor });
63
+ expect(page.items.length).toBeLessThanOrEqual(5);
64
+ seen.push(...page.items);
65
+ cursor = page.nextCursor;
66
+ } while (cursor);
67
+
68
+ expect(seen).toHaveLength(15);
69
+ expect(new Set(seen.map((t) => t.id)).size).toBe(15);
70
+ const keys = seen.map((t) => `${t.createdAt}|${t.id}`);
71
+ expect([...keys].sort()).toEqual(keys);
72
+ });
73
+
74
+ it('pages the activity feed newest first', async () => {
75
+ const { engine, project } = await setup();
76
+ const all = (await engine.queryActivities({ projectId: project.id, limit: MAX_PAGE_SIZE })).items;
77
+ expect(all.length).toBeGreaterThan(3);
78
+ const keys = all.map((a) => `${a.createdAt}|${a.id}`);
79
+ expect([...keys].sort().reverse()).toEqual(keys);
80
+
81
+ const first = await engine.queryActivities({ projectId: project.id, limit: 2 });
82
+ const second = await engine.queryActivities({ projectId: project.id, limit: 2, cursor: first.nextCursor });
83
+ expect([...first.items, ...second.items].map((a) => a.id)).toEqual(all.slice(0, 4).map((a) => a.id));
84
+ });
85
+
86
+ it('rejects malformed cursors and clamps page sizes', async () => {
87
+ const { engine, project } = await setup();
88
+ await expect(engine.queryTasks({ projectId: project.id, cursor: 'not-a-cursor' })).rejects.toThrow(ValidationError);
89
+ expect((await engine.queryTasks({ projectId: project.id, limit: 0 })).items).toHaveLength(1);
90
+ expect((await engine.queryTasks({ projectId: project.id, limit: 10_000 })).items).toHaveLength(3);
91
+ });
92
+ });
93
+
94
+ describe('query authorization', () => {
95
+ it('limits cross-project queries and activity feeds to readable projects', async () => {
96
+ const engine = new CriticalPathEngine({ authorize: createRolePolicy() });
97
+ const mine = await engine.createProject({ name: 'Mine', members: [{ userId: 'ana', role: 'viewer' }] });
98
+ const secret = await engine.createProject({ name: 'Secret' });
99
+ await engine.createTask({ projectId: mine.id, title: 'Visible' });
100
+ await engine.createTask({ projectId: secret.id, title: 'Hidden' });
101
+ const ana = engine.withActor({ userId: 'ana' });
102
+
103
+ expect((await ana.queryTasks()).items.map((t) => t.title)).toEqual(['Visible']);
104
+ expect((await ana.queryTasks({ projectId: secret.id })).items).toEqual([]);
105
+ expect((await ana.queryTasks({ projectIds: [mine.id, secret.id] })).items.map((t) => t.title)).toEqual(['Visible']);
106
+ const feed = (await ana.queryActivities()).items;
107
+ expect(feed.length).toBeGreaterThan(0);
108
+ expect(feed.every((a) => a.projectId === mine.id)).toBe(true);
109
+ });
110
+ });