@things-factory/attachment-base 10.1.111 → 10.1.113

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 (39) hide show
  1. package/dist-server/attachment-access.d.ts +26 -0
  2. package/dist-server/attachment-access.js +115 -0
  3. package/dist-server/attachment-access.js.map +1 -0
  4. package/dist-server/attachment-const.d.ts +5 -0
  5. package/dist-server/attachment-const.js +6 -1
  6. package/dist-server/attachment-const.js.map +1 -1
  7. package/dist-server/index.d.ts +1 -0
  8. package/dist-server/index.js +1 -0
  9. package/dist-server/index.js.map +1 -1
  10. package/dist-server/routes.js +59 -17
  11. package/dist-server/routes.js.map +1 -1
  12. package/dist-server/service/attachment-share/attachment-share-mutation.d.ts +11 -0
  13. package/dist-server/service/attachment-share/attachment-share-mutation.js +87 -0
  14. package/dist-server/service/attachment-share/attachment-share-mutation.js.map +1 -0
  15. package/dist-server/service/attachment-share/attachment-share-rule.d.ts +16 -0
  16. package/dist-server/service/attachment-share/attachment-share-rule.js +41 -0
  17. package/dist-server/service/attachment-share/attachment-share-rule.js.map +1 -0
  18. package/dist-server/service/attachment-share/attachment-share.d.ts +15 -0
  19. package/dist-server/service/attachment-share/attachment-share.js +72 -0
  20. package/dist-server/service/attachment-share/attachment-share.js.map +1 -0
  21. package/dist-server/service/attachment-share/index.d.ts +4 -0
  22. package/dist-server/service/attachment-share/index.js +8 -0
  23. package/dist-server/service/attachment-share/index.js.map +1 -0
  24. package/dist-server/service/index.d.ts +4 -2
  25. package/dist-server/service/index.js +7 -2
  26. package/dist-server/service/index.js.map +1 -1
  27. package/dist-server/storage-azure-blob.js +1 -1
  28. package/dist-server/storage-azure-blob.js.map +1 -1
  29. package/dist-server/storage-s3.js +1 -1
  30. package/dist-server/storage-s3.js.map +1 -1
  31. package/dist-server/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +3 -3
  33. package/tests/attachment-access.test.ts +104 -0
  34. package/tests/attachment-route.test.ts +88 -0
  35. package/translations/en.json +3 -2
  36. package/translations/ja.json +2 -1
  37. package/translations/ko.json +3 -2
  38. package/translations/ms.json +2 -1
  39. package/translations/zh.json +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/attachment-base",
3
- "version": "10.1.111",
3
+ "version": "10.1.113",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "client/index.js",
6
6
  "things-factory": true,
@@ -29,12 +29,12 @@
29
29
  "@aws-sdk/s3-presigned-post": "^3.46.0",
30
30
  "@azure/storage-blob": "^12.18.0",
31
31
  "@koa/multer": "^3.0.0",
32
- "@things-factory/auth-base": "^10.1.111",
32
+ "@things-factory/auth-base": "^10.1.113",
33
33
  "@things-factory/env": "^10.1.20",
34
34
  "@things-factory/shell": "^10.1.106",
35
35
  "mime": "^3.0.0",
36
36
  "multer": "^1.4.5-lts.1",
37
37
  "sharp": "^0.33.0"
38
38
  },
39
- "gitHead": "f33122c15e5b3e45393129e9418e2c367a273ade"
39
+ "gitHead": "9da1e467ddce23e600e64f08454289cb3b806212"
40
40
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Who reads an attachment, and the share link - ADR-0106 decisions 2 and 3. Pure rules, compiled package.
3
+ */
4
+ const access = require('../dist-server/attachment-access')
5
+ const rule = require('../dist-server/service/attachment-share/attachment-share-rule')
6
+
7
+ const ID = '3f2a9c1e-1111-4aaa-8bbb-0123456789ab'
8
+ const context = (domainId = 'd1', userId: string | null = 'u1') => ({ state: { domain: { id: domainId }, user: userId ? { id: userId } : undefined } })
9
+ const row = (over: any = {}) => ({ id: ID, path: `${ID}.png`, domainId: 'd1', refType: '', ...over })
10
+
11
+ afterEach(() => {
12
+ access.registerAttachmentReader('', null)
13
+ access.registerAttachmentReader('expense', null)
14
+ })
15
+
16
+ describe('the id a storage path carries', () => {
17
+ it('★ <uuid>.<ext> and <uuid> - nothing else is an attachment path', () => {
18
+ expect(access.attachmentIdOf(`${ID}.png`)).toBe(ID)
19
+ expect(access.attachmentIdOf(ID)).toBe(ID)
20
+ for (const bad of ['', 'x.png', '../etc/passwd', `${ID}/x.png`, `a/${ID}.png`]) expect([bad, access.attachmentIdOf(bad)]).toEqual([bad, null])
21
+ })
22
+ })
23
+
24
+ describe('who may read (decision 2)', () => {
25
+ it('★ only the attachment\'s domain - another domain reads nothing, the parent is not opened here', async () => {
26
+ expect(await access.mayRead(row(), context('d1'))).toBe(true)
27
+ expect(await access.mayRead(row(), context('d2'))).toBe(false)
28
+ })
29
+
30
+ it('★ nobody signed in reads nothing', async () => {
31
+ expect(await access.mayRead(row({ refType: '' }), context('d1', null))).toBe(false)
32
+ })
33
+
34
+ it('★ a registered reader decides for its refType - and a reader that throws is a no', async () => {
35
+ access.registerAttachmentReader('', () => false)
36
+ expect(await access.mayRead(row(), context())).toBe(false)
37
+ access.registerAttachmentReader('', () => {
38
+ throw new Error('broken')
39
+ })
40
+ expect(await access.mayRead(row(), context())).toBe(false)
41
+ access.registerAttachmentReader('', async () => true)
42
+ expect(await access.mayRead(row(), context())).toBe(true)
43
+ })
44
+
45
+ it('★ a refType on neither list stays closed - a new one cannot open by being forgotten', async () => {
46
+ expect(await access.mayRead(row({ refType: 'SomethingNew' }), context())).toBe(false)
47
+ access.registerAttachmentReader('expense', () => true)
48
+ expect(await access.mayRead(row({ refType: 'expense' }), context())).toBe(true)
49
+ })
50
+
51
+ it('the two lists do not overlap', () => {
52
+ for (const refType of access.SENSITIVE_REF_TYPES) expect([refType, access.ORDINARY_REF_TYPES.has(refType)]).toEqual([refType, false])
53
+ })
54
+
55
+ it('★ a sensitive refType with no reader stays closed', async () => {
56
+ for (const refType of access.SENSITIVE_REF_TYPES) {
57
+ expect([refType, await access.mayRead(row({ refType }), context())]).toEqual([refType, false])
58
+ }
59
+ })
60
+ })
61
+
62
+ describe('the share link (decision 3)', () => {
63
+ const NOW = new Date('2026-10-02T09:00:00Z')
64
+ const share = { id: 's1', attachmentId: ID, domainId: 'd1', expiresAt: new Date('2026-10-09T09:00:00Z') }
65
+
66
+ it('★ opens with its own signature, before expiry, while not taken back', () => {
67
+ const signature = rule.shareSignature('secret', share)
68
+ expect(rule.shareOpens('secret', share, signature, NOW)).toBe(true)
69
+ expect(rule.shareOpens('secret', { ...share, revokedAt: NOW }, signature, NOW)).toBe(false)
70
+ expect(rule.shareOpens('secret', share, signature, new Date('2026-10-09T09:00:01Z'))).toBe(false)
71
+ })
72
+
73
+ it('★ the signature binds the attachment, the domain and the expiry - change one and it does not open', () => {
74
+ const signature = rule.shareSignature('secret', share)
75
+ for (const changed of [{ attachmentId: 'other' }, { domainId: 'd2' }, { expiresAt: new Date('2027-01-01T00:00:00Z') }, { id: 's2' }]) {
76
+ expect(rule.shareOpens('secret', { ...share, ...changed }, signature, NOW)).toBe(false)
77
+ }
78
+ expect(rule.shareOpens('other-secret', share, signature, NOW)).toBe(false)
79
+ expect(rule.shareOpens('secret', share, '', NOW)).toBe(false)
80
+ })
81
+
82
+ it('the address carries the link id and the signature, not the file path', () => {
83
+ const url = rule.shareUrl('secret', share)
84
+ expect(url.startsWith('/attachment-share/s1/')).toBe(true)
85
+ expect(url).not.toContain(ID)
86
+ })
87
+
88
+ it('★ a caller may shorten the default expiry, never lengthen it', () => {
89
+ expect(rule.shareExpiry(NOW, 168).toISOString()).toBe('2026-10-09T09:00:00.000Z')
90
+ expect(rule.shareExpiry(NOW, 168, 24).toISOString()).toBe('2026-10-03T09:00:00.000Z')
91
+ expect(rule.shareExpiry(NOW, 168, 9999).toISOString()).toBe('2026-10-09T09:00:00.000Z')
92
+ })
93
+ })
94
+
95
+ describe('a refType its module guards (figure-thumbnail, Figure 711dd85af0)', () => {
96
+ afterEach(() => access.registerAttachmentReader('figure-thumbnail', null))
97
+
98
+ it('★ closed until the module registers its reader - then the reader decides', async () => {
99
+ expect(access.ORDINARY_REF_TYPES.has('figure-thumbnail')).toBe(false)
100
+ expect(await access.mayRead(row({ refType: 'figure-thumbnail' }), context())).toBe(false)
101
+ access.registerAttachmentReader('figure-thumbnail', () => true)
102
+ expect(await access.mayRead(row({ refType: 'figure-thumbnail' }), context())).toBe(true)
103
+ })
104
+ })
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The attachment route, step one of ADR-0106: the anonymous upload is gone, and no shared cache keeps an attachment.
3
+ *
4
+ * The compiled package is used (entities and resolvers need tsc's decorator metadata). The storage is a stand-in -
5
+ * the test environment configures none - so the route and createAttachment run as they are, against a storage that
6
+ * only records what it was asked.
7
+ */
8
+ import { readFileSync } from 'fs'
9
+ import { join } from 'path'
10
+
11
+ const mockStored: any[] = []
12
+ const mockStorage = {
13
+ type: 'stand-in',
14
+ sendFile: jest.fn(async (context: any) => {
15
+ context.body = 'bytes'
16
+ }),
17
+ uploadFile: jest.fn(async () => {
18
+ const one = { id: 'a1', path: 'a1.txt', size: 3, filename: 'note.txt', encoding: '7bit', contents: Buffer.from('abc') }
19
+ mockStored.push(one)
20
+ return one
21
+ }),
22
+ deleteFile: jest.fn()
23
+ }
24
+
25
+ jest.mock('../dist-server/attachment-const', () => ({ ...jest.requireActual('../dist-server/attachment-const'), STORAGE: mockStorage }))
26
+
27
+ const { ATTACHMENT_CACHE_CONTROL } = jest.requireActual('../dist-server/attachment-const')
28
+
29
+ /* Only the listeners this module adds - other modules loaded in the same process listen on the same event. */
30
+ const before = new Set(process.listeners('bootstrap-module-global-public-route' as any))
31
+ require('../dist-server/routes')
32
+ const mine = process
33
+ .listeners('bootstrap-module-global-public-route' as any)
34
+ .filter(listener => !before.has(listener) && /ATTACHMENT_PATH/.test(String(listener)))
35
+
36
+ function routesRegistered() {
37
+ const routes: Record<string, any[]> = { get: [], post: [], put: [], delete: [], all: [] }
38
+ const router: any = { use: () => {} }
39
+ for (const method of Object.keys(routes)) router[method] = (path: string, ...handlers: any[]) => routes[method].push({ path, handlers })
40
+ for (const listener of mine) (listener as any)({}, router)
41
+ return routes
42
+ }
43
+
44
+ describe('the attachment route (ADR-0106 decision 1)', () => {
45
+ it('★ there is no upload route - the anonymous POST /attachment is gone', () => {
46
+ const routes = routesRegistered()
47
+ expect(routes.post.map(r => r.path)).toEqual([])
48
+ expect(routes.get.map(r => r.path)).toEqual(['/attachment/:attachment', '/attachment-share/:share/:signature'])
49
+ })
50
+
51
+ it('★ a served attachment is private - no shared cache keeps it', async () => {
52
+ const routes = routesRegistered()
53
+ const headers: Record<string, string> = {}
54
+ const context: any = { params: { attachment: 'a1.png' }, set: (k: string, v: string) => (headers[k] = v) }
55
+ const handler = routes.get[0].handlers[routes.get[0].handlers.length - 1]
56
+ await handler(context, async () => {})
57
+ expect(headers['Cache-Control']).toBe(ATTACHMENT_CACHE_CONTROL)
58
+ expect(ATTACHMENT_CACHE_CONTROL).toMatch(/^private\b/)
59
+ })
60
+
61
+ it('★ every storage sets the one private value - s3 and azure used to overwrite it with public', () => {
62
+ for (const file of ['routes.ts', 'storage-s3.ts', 'storage-azure-blob.ts', 'storage-file.ts', 'storage-database.ts']) {
63
+ const source = readFileSync(join(__dirname, '..', 'server', file), 'utf8')
64
+ expect([file, /['"]public\b/.test(source)]).toEqual([file, false])
65
+ }
66
+ })
67
+ })
68
+
69
+ describe('uploading still works - through GraphQL createAttachment', () => {
70
+ it('★ createAttachment stores the file and saves a row with the domain and the uploader', async () => {
71
+ const { createAttachment } = require('../dist-server/service/attachment/attachment-mutation')
72
+ const saved: any[] = []
73
+ const context: any = {
74
+ state: {
75
+ domain: { id: 'd1' },
76
+ user: { id: 'u1' },
77
+ tx: { getRepository: () => ({ save: async (row: any) => (saved.push(row), row) }) }
78
+ },
79
+ t: (key: string) => key
80
+ }
81
+ const file = Promise.resolve({ mimetype: 'text/plain', filename: 'note.txt', encoding: '7bit', createReadStream: () => null })
82
+
83
+ const row = await createAttachment(null, { attachment: { file, category: 'doc', refType: 'note', refBy: 'n1' } }, context)
84
+
85
+ expect(mockStorage.uploadFile).toHaveBeenCalledTimes(1)
86
+ expect(row).toMatchObject({ domain: { id: 'd1' }, creator: { id: 'u1' }, path: 'a1.txt', refType: 'note', refBy: 'n1' })
87
+ })
88
+ })
@@ -3,5 +3,6 @@
3
3
  "label.file": "file",
4
4
  "label.select file": "select file",
5
5
  "label.ref_by": "Reference ID",
6
- "error.not allowed file type for upload": "{mimetype} is not an allowed file type for upload."
7
- }
6
+ "error.not allowed file type for upload": "{mimetype} is not an allowed file type for upload.",
7
+ "error.attachment not found": "the attachment is not found."
8
+ }
@@ -3,5 +3,6 @@
3
3
  "label.file": "ファイル",
4
4
  "label.select file": "ファイル選択",
5
5
  "label.ref_by": "参照 ID",
6
- "error.not allowed file type for upload": "{mimetype}はアップロードが許可されていないファイルタイプです。"
6
+ "error.not allowed file type for upload": "{mimetype}はアップロードが許可されていないファイルタイプです。",
7
+ "error.attachment not found": "添付ファイルが見つかりません。"
7
8
  }
@@ -3,5 +3,6 @@
3
3
  "label.file": "파일",
4
4
  "label.select file": "파일 선택",
5
5
  "label.ref_by": "참조 ID",
6
- "error.not allowed file type for upload": "{mimetype}은 업로드가 허가되지 않은 파일형식입니다."
7
- }
6
+ "error.not allowed file type for upload": "{mimetype}은 업로드가 허가되지 않은 파일형식입니다.",
7
+ "error.attachment not found": "첨부를 찾을 수 없습니다."
8
+ }
@@ -3,5 +3,6 @@
3
3
  "label.file": "fail",
4
4
  "label.select file": "pilih fail",
5
5
  "label.ref_by": "rujukan ID",
6
- "error.not allowed file type for upload": "{mimetype} adalah jenis fail yang tidak dibenarkan untuk dimuat naik."
6
+ "error.not allowed file type for upload": "{mimetype} adalah jenis fail yang tidak dibenarkan untuk dimuat naik.",
7
+ "error.attachment not found": "lampiran tidak ditemui."
7
8
  }
@@ -3,5 +3,6 @@
3
3
  "label.file": "文件",
4
4
  "label.select file": "选择文件",
5
5
  "label.ref_by": "参考 ID",
6
- "error.not allowed file type for upload": "{mimetype}是未经允许的上传文件类型。"
6
+ "error.not allowed file type for upload": "{mimetype}是未经允许的上传文件类型。",
7
+ "error.attachment not found": "找不到附件。"
7
8
  }