@proteinjs/db-file 1.8.4 → 1.9.1

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 (102) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +3 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/src/DbFileStorageDriver.d.ts +7 -0
  10. package/dist/src/DbFileStorageDriver.d.ts.map +1 -1
  11. package/dist/src/DbFileStorageDriver.js +69 -41
  12. package/dist/src/DbFileStorageDriver.js.map +1 -1
  13. package/dist/src/FileCopyForOthers.d.ts +52 -0
  14. package/dist/src/FileCopyForOthers.d.ts.map +1 -0
  15. package/dist/src/FileCopyForOthers.js +62 -0
  16. package/dist/src/FileCopyForOthers.js.map +1 -0
  17. package/dist/src/FileStorage.d.ts +158 -11
  18. package/dist/src/FileStorage.d.ts.map +1 -1
  19. package/dist/src/FileStorage.js +562 -41
  20. package/dist/src/FileStorage.js.map +1 -1
  21. package/dist/src/FileStorageDriver.d.ts +11 -0
  22. package/dist/src/FileStorageDriver.d.ts.map +1 -1
  23. package/dist/src/FileStorageError.d.ts +62 -0
  24. package/dist/src/FileStorageError.d.ts.map +1 -0
  25. package/dist/src/FileStorageError.js +118 -0
  26. package/dist/src/FileStorageError.js.map +1 -0
  27. package/dist/src/FileVariantMaker.d.ts +66 -0
  28. package/dist/src/FileVariantMaker.d.ts.map +1 -0
  29. package/dist/src/FileVariantMaker.js +62 -0
  30. package/dist/src/FileVariantMaker.js.map +1 -0
  31. package/dist/src/routes/FileResponder.d.ts +41 -0
  32. package/dist/src/routes/FileResponder.d.ts.map +1 -0
  33. package/dist/src/routes/FileResponder.js +135 -0
  34. package/dist/src/routes/FileResponder.js.map +1 -0
  35. package/dist/src/routes/getFile.d.ts +5 -13
  36. package/dist/src/routes/getFile.d.ts.map +1 -1
  37. package/dist/src/routes/getFile.js +20 -56
  38. package/dist/src/routes/getFile.js.map +1 -1
  39. package/dist/src/routes/getFileVariant.d.ts +20 -0
  40. package/dist/src/routes/getFileVariant.d.ts.map +1 -0
  41. package/dist/src/routes/getFileVariant.js +112 -0
  42. package/dist/src/routes/getFileVariant.js.map +1 -0
  43. package/dist/src/routes/getFileVariantRoute.d.ts +7 -0
  44. package/dist/src/routes/getFileVariantRoute.d.ts.map +1 -0
  45. package/dist/src/routes/getFileVariantRoute.js +9 -0
  46. package/dist/src/routes/getFileVariantRoute.js.map +1 -0
  47. package/dist/src/tables/FileTable.d.ts +70 -2
  48. package/dist/src/tables/FileTable.d.ts.map +1 -1
  49. package/dist/src/tables/FileTable.js +30 -0
  50. package/dist/src/tables/FileTable.js.map +1 -1
  51. package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts +2 -0
  52. package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts.map +1 -0
  53. package/dist/test/DbFileStorageDriverMissingBytes.test.js +184 -0
  54. package/dist/test/DbFileStorageDriverMissingBytes.test.js.map +1 -0
  55. package/dist/test/FileByteAccess.test.js +6 -3
  56. package/dist/test/FileByteAccess.test.js.map +1 -1
  57. package/dist/test/FileCopyForOthers.test.d.ts +2 -0
  58. package/dist/test/FileCopyForOthers.test.d.ts.map +1 -0
  59. package/dist/test/FileCopyForOthers.test.js +1143 -0
  60. package/dist/test/FileCopyForOthers.test.js.map +1 -0
  61. package/dist/test/FileDerivedProvenance.test.d.ts +2 -0
  62. package/dist/test/FileDerivedProvenance.test.d.ts.map +1 -0
  63. package/dist/test/FileDerivedProvenance.test.js +420 -0
  64. package/dist/test/FileDerivedProvenance.test.js.map +1 -0
  65. package/dist/test/FileMediaMetadata.test.js +60 -5
  66. package/dist/test/FileMediaMetadata.test.js.map +1 -1
  67. package/dist/test/FileStorageError.test.d.ts +2 -0
  68. package/dist/test/FileStorageError.test.d.ts.map +1 -0
  69. package/dist/test/FileStorageError.test.js +68 -0
  70. package/dist/test/FileStorageError.test.js.map +1 -0
  71. package/dist/test/FileTestEnvironment.d.ts.map +1 -1
  72. package/dist/test/FileTestEnvironment.js +3 -0
  73. package/dist/test/FileTestEnvironment.js.map +1 -1
  74. package/dist/test/FileVariants.test.d.ts +2 -0
  75. package/dist/test/FileVariants.test.d.ts.map +1 -0
  76. package/dist/test/FileVariants.test.js +1008 -0
  77. package/dist/test/FileVariants.test.js.map +1 -0
  78. package/dist/test/GetFileRoute.test.js +29 -0
  79. package/dist/test/GetFileRoute.test.js.map +1 -1
  80. package/generated/index.ts +3 -1
  81. package/index.ts +4 -0
  82. package/package.json +5 -5
  83. package/src/DbFileStorageDriver.ts +32 -21
  84. package/src/FileCopyForOthers.ts +71 -0
  85. package/src/FileStorage.ts +377 -19
  86. package/src/FileStorageDriver.ts +12 -0
  87. package/src/FileStorageError.ts +123 -0
  88. package/src/FileVariantMaker.ts +89 -0
  89. package/src/routes/FileResponder.ts +85 -0
  90. package/src/routes/getFile.ts +9 -44
  91. package/src/routes/getFileVariant.ts +53 -0
  92. package/src/routes/getFileVariantRoute.ts +7 -0
  93. package/src/tables/FileTable.ts +82 -1
  94. package/test/DbFileStorageDriverMissingBytes.test.ts +85 -0
  95. package/test/FileByteAccess.test.ts +7 -1
  96. package/test/FileCopyForOthers.test.ts +702 -0
  97. package/test/FileDerivedProvenance.test.ts +248 -0
  98. package/test/FileMediaMetadata.test.ts +44 -0
  99. package/test/FileStorageError.test.ts +79 -0
  100. package/test/FileTestEnvironment.ts +3 -0
  101. package/test/FileVariants.test.ts +604 -0
  102. package/test/GetFileRoute.test.ts +19 -0
@@ -0,0 +1,248 @@
1
+ import { getDbAsSystem } from '@proteinjs/db';
2
+ import { SourceRepository } from '@proteinjs/reflection';
3
+ import { UserAuth, UserRepo, User } from '@proteinjs/user';
4
+ import { File, FileTable } from '../src/tables/FileTable';
5
+ import { tables } from '../src/tables/tables';
6
+ import { FileStorage } from '../src/FileStorage';
7
+ import { FileStorageDriver } from '../src/FileStorageDriver';
8
+ import { FileVariantKind } from '../src/FileVariantMaker';
9
+ import { FileTestEnvironment } from './FileTestEnvironment';
10
+
11
+ /**
12
+ * A derived File — a variant the seam makes (at ingest with the bytes in hand, or on the read
13
+ * path's first request) and the copy for others — carries its ORIGINAL's provenance: who made the
14
+ * bytes (`origin`, `originModel`), where they came from (`sourceUrl`, `sourcePageUrl`,
15
+ * `retrievedAt`) and the rights that ride with them (`license`, `licenseUrl`, `attribution`) —
16
+ * the columns the table itself names as provenance (`FileTable.provenanceColumns`). A rendition's
17
+ * provenance is its original's: a consumer that labels a thumbnail with what made it, or refuses a
18
+ * non-capture picture as evidence, reads the row it draws, without a join. The library that makes
19
+ * the row writes it, whichever door made the row; a row with no provenance derives rows with none
20
+ * (nothing is invented); a row derived from a derived row carries what that row carries — its
21
+ * original's. The facts of a row's OWN bytes (`size`, `contentHash`, the dimensions) stay the
22
+ * maker's answer, never the original's.
23
+ */
24
+
25
+ const originalBytes = Buffer.concat([Buffer.from('ORIGINAL:'), Buffer.from(Array.from({ length: 64 }, (_, i) => i))]);
26
+ /** What the copy-for-others maker takes off a copy (the location marker of the real one). */
27
+ const COPY_MARK = Buffer.from('-FOR-OTHERS');
28
+
29
+ /**
30
+ * Every provenance column populated at once — whatever the story, each is seen to travel. The
31
+ * fixture is tied to the table's list below: a provenance column added to the table must be added
32
+ * here, and is then proven to travel like the rest.
33
+ */
34
+ const PROVENANCE = {
35
+ origin: 'generation',
36
+ originModel: 'vendor/picture-model-1',
37
+ sourceUrl: 'https://source.test/pictures/1.jpg',
38
+ sourcePageUrl: 'https://source.test/pages/1',
39
+ retrievedAt: new Date('2026-09-25T10:00:00.000Z'),
40
+ license: 'CC BY-SA 4.0',
41
+ licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
42
+ attribution: 'Photo by Someone, CC BY-SA 4.0',
43
+ };
44
+
45
+ /** In-memory proxy-shape driver (no signed URLs) — the DbFileStorageDriver serving shape. */
46
+ class ProxyDriver implements FileStorageDriver {
47
+ readonly store = new Map<string, Buffer>();
48
+
49
+ async createFile(file: File, fileData: string): Promise<void> {
50
+ this.store.set(file.id, Buffer.from(fileData, 'base64'));
51
+ }
52
+
53
+ async getFileData(fileId: string): Promise<string> {
54
+ const data = this.store.get(fileId);
55
+ if (data === undefined) {
56
+ throw new Error(`No such object: ${fileId}`);
57
+ }
58
+ return data.toString('base64');
59
+ }
60
+
61
+ async updateFileData(fileId: string, data: string): Promise<void> {
62
+ this.store.set(fileId, Buffer.from(data, 'base64'));
63
+ }
64
+
65
+ async deleteFile(fileId: string): Promise<void> {
66
+ this.store.delete(fileId);
67
+ }
68
+ }
69
+
70
+ const testEnv = new FileTestEnvironment();
71
+ type UserAuthInternals = { userRepo?: unknown };
72
+ type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
73
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
74
+
75
+ let owner: User;
76
+ let recipient: User;
77
+ const reachableFileIds = new Set<string>();
78
+
79
+ /** The stub variant maker: a picture's `preview` and `stage` — bytes marked with the kind, a type, dimensions. Keeps the copy mark on (the real one keeps a picture's tags for its owner). */
80
+ const registerVariantMaker = () => {
81
+ objectCache()['@proteinjs/db-file/FileVariantMaker'] = [
82
+ {
83
+ appliesTo: (file: File) => file.type.startsWith('image/'),
84
+ make: async (file: File, bytes: Buffer, kind: FileVariantKind) => ({
85
+ bytes: Buffer.concat([Buffer.from(`${kind.toUpperCase()}:`), bytes.subarray(0, 8), COPY_MARK]),
86
+ type: 'image/webp',
87
+ width: kind === 'stage' ? 1600 : 512,
88
+ height: kind === 'stage' ? 800 : 256,
89
+ }),
90
+ },
91
+ ];
92
+ };
93
+
94
+ /** The stub copy maker: a picture's copy is its bytes with the mark taken off. */
95
+ const registerCopyMaker = () => {
96
+ objectCache()['@proteinjs/db-file/FileCopyForOthers'] = [
97
+ {
98
+ appliesTo: (file: File) => file.type.startsWith('image/'),
99
+ make: async (file: File, bytes: Buffer) => bytes.subarray(0, bytes.length - COPY_MARK.length),
100
+ },
101
+ ];
102
+ };
103
+
104
+ beforeAll(async () => {
105
+ await testEnv.beforeAll();
106
+ objectCache()['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
107
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
108
+ objectCache()['@proteinjs/db-file/FileReachabilityResolver'] = [
109
+ // The content leg vouches for the recipient alone (a share they accepted).
110
+ {
111
+ canReadViaReference: async (fileId: string) =>
112
+ reachableFileIds.has(fileId) && new UserRepo().getUser().id === recipient.id,
113
+ },
114
+ ];
115
+ owner = await testEnv.createUser({ name: 'File owner', email: 'provenance-owner@test.local' });
116
+ recipient = await testEnv.createUser({ name: 'Share recipient', email: 'provenance-recipient@test.local' });
117
+ testEnv.actAs(owner);
118
+ });
119
+
120
+ afterAll(async () => {
121
+ delete objectCache()['@proteinjs/db-file/FileReachabilityResolver'];
122
+ delete objectCache()['@proteinjs/db-file/FileVariantMaker'];
123
+ delete objectCache()['@proteinjs/db-file/FileCopyForOthers'];
124
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
125
+ await testEnv.afterAll();
126
+ });
127
+
128
+ const driver = new ProxyDriver();
129
+
130
+ beforeEach(() => {
131
+ reachableFileIds.clear();
132
+ registerVariantMaker();
133
+ registerCopyMaker();
134
+ testEnv.setDriver(driver);
135
+ testEnv.actAs(owner);
136
+ });
137
+
138
+ /** A picture of the owner's, with (or without) its provenance, stored with no variant. */
139
+ const createOwnerPicture = async (name: string, provenance: Partial<File> = {}): Promise<File> =>
140
+ await new FileStorage().createFile(
141
+ {
142
+ name,
143
+ type: 'image/jpeg',
144
+ size: originalBytes.length,
145
+ width: 4032,
146
+ height: 3024,
147
+ contentHash: 'a'.repeat(64),
148
+ ...provenance,
149
+ } as File,
150
+ Buffer.concat([originalBytes, COPY_MARK]).toString('base64')
151
+ );
152
+
153
+ const rowAsSystem = async (fileId: string): Promise<File> => (await getDbAsSystem().get(tables.File, { id: fileId }))!;
154
+
155
+ /** The provenance a row carries, column for column as the table names them — `null` for a column it does not hold. */
156
+ const provenanceOf = (row: File): Record<string, unknown> =>
157
+ FileTable.provenanceColumns.reduce<Record<string, unknown>>(
158
+ (carried, column) => ({ ...carried, [column]: row[column] ?? null }),
159
+ {}
160
+ );
161
+
162
+ const NO_PROVENANCE = provenanceOf({} as File);
163
+
164
+ /** The copy for others of a file, made by a recipient's read of its bytes. */
165
+ const copyForOthersOf = async (file: File): Promise<File> => {
166
+ reachableFileIds.add(file.id);
167
+ testEnv.actAs(recipient);
168
+ await new FileStorage().getFileData(file.id);
169
+ testEnv.actAs(owner);
170
+ const copyId = (await rowAsSystem(file.id)).copyForOthers?._id;
171
+ expect(copyId).toBeTruthy();
172
+ return await rowAsSystem(copyId!);
173
+ };
174
+
175
+ describe('the list is the table’s own', () => {
176
+ it('names the producer, the web source and the rights record — and the fixture covers every column it names, so a column added there is proven to travel', () => {
177
+ expect([...FileTable.provenanceColumns].sort()).toEqual(Object.keys(PROVENANCE).sort());
178
+ expect(FileTable.provenanceColumns).toEqual(expect.arrayContaining(['origin', 'originModel']));
179
+ // The facts of a row's own bytes are not provenance.
180
+ expect(FileTable.provenanceColumns).not.toEqual(expect.arrayContaining(['contentHash']));
181
+ expect(FileTable.provenanceColumns).not.toEqual(expect.arrayContaining(['size']));
182
+ });
183
+ });
184
+
185
+ describe('a derived row carries its original’s provenance', () => {
186
+ it("the ingest door: every variant deriveVariants makes carries the original's provenance, column for column; the maker's own answer (bytes, type, dimensions) stays its own", async () => {
187
+ const file = await createOwnerPicture('made.jpg', PROVENANCE);
188
+
189
+ const { variants } = await new FileStorage().deriveVariants(file, Buffer.concat([originalBytes, COPY_MARK]));
190
+
191
+ expect(Object.keys(variants).sort()).toEqual(['preview', 'stage']);
192
+ for (const kind of ['preview', 'stage'] as const) {
193
+ const variant = await rowAsSystem(variants[kind]!.id);
194
+ expect(provenanceOf(variant)).toEqual(PROVENANCE);
195
+ expect([variant.type, variant.width, variant.height]).toEqual([
196
+ 'image/webp',
197
+ kind === 'stage' ? 1600 : 512,
198
+ kind === 'stage' ? 800 : 256,
199
+ ]);
200
+ expect(variant.size).toEqual(driver.store.get(variant.id)!.length);
201
+ // The hash is of THESE bytes, which the maker did not answer: never the original's.
202
+ expect(variant.contentHash ?? null).toBeNull();
203
+ }
204
+ });
205
+
206
+ it("the read path's door: the variant getVariant derives on the first request carries the original's provenance", async () => {
207
+ const file = await createOwnerPicture('existing.jpg', PROVENANCE);
208
+ expect((await rowAsSystem(file.id)).stage?._id ?? null).toBeNull();
209
+
210
+ const stage = (await new FileStorage().getVariant(file.id, 'stage'))!;
211
+
212
+ expect(stage.variantOf?._id).toEqual(file.id);
213
+ expect(provenanceOf(await rowAsSystem(stage.id))).toEqual(PROVENANCE);
214
+ });
215
+
216
+ it("the copy for others carries the original's provenance", async () => {
217
+ const file = await createOwnerPicture('shared.jpg', PROVENANCE);
218
+
219
+ const copy = await copyForOthersOf(file);
220
+
221
+ expect(copy.scope).toEqual(owner.id);
222
+ expect(provenanceOf(copy)).toEqual(PROVENANCE);
223
+ expect(copy.contentHash ?? null).toBeNull();
224
+ });
225
+
226
+ it('a row derived from a derived row carries what that row carries — its original’s: the copy for others of a stage variant', async () => {
227
+ const file = await createOwnerPicture('chain.jpg', PROVENANCE);
228
+ const stage = (await new FileStorage().getVariant(file.id, 'stage'))!;
229
+ reachableFileIds.add(file.id); // a variant's reachability is its original's: the grant names the original
230
+
231
+ const copyOfStage = await copyForOthersOf(await rowAsSystem(stage.id));
232
+
233
+ expect(copyOfStage.variantOf?._id ?? null).toBeNull(); // a copy's own id stays nobody's but the owner's
234
+ expect(provenanceOf(copyOfStage)).toEqual(PROVENANCE);
235
+ });
236
+
237
+ it('a row with NO provenance derives rows with none — nothing is invented, at any door', async () => {
238
+ const file = await createOwnerPicture('plain.jpg');
239
+ expect(provenanceOf(await rowAsSystem(file.id))).toEqual(NO_PROVENANCE);
240
+
241
+ const { variants } = await new FileStorage().deriveVariants(file, Buffer.concat([originalBytes, COPY_MARK]));
242
+ const copy = await copyForOthersOf(file);
243
+
244
+ expect(provenanceOf(await rowAsSystem(variants.preview!.id))).toEqual(NO_PROVENANCE);
245
+ expect(provenanceOf(await rowAsSystem(variants.stage!.id))).toEqual(NO_PROVENANCE);
246
+ expect(provenanceOf(copy)).toEqual(NO_PROVENANCE);
247
+ });
248
+ });
@@ -60,6 +60,49 @@ describe('File media metadata columns', () => {
60
60
  expect(row.durationMs).toEqual(24_500);
61
61
  });
62
62
 
63
+ it('round-trips the producer attribution — how the bytes came to exist (`origin`)', async () => {
64
+ const file = await new FileStorage().createFile(
65
+ { name: 'frame.png', type: 'image/png', size: 4, width: 1440, height: 900, origin: 'mockup' } as File,
66
+ Buffer.from('png!').toString('base64')
67
+ );
68
+
69
+ const row = await getDbAsSystem().get(tables.File, { id: file.id });
70
+ expect(row.origin).toEqual('mockup');
71
+ // Absent for everything that does not state its producer (a NULL column reads back empty).
72
+ const plain = await new FileStorage().createFile(
73
+ { name: 'note.txt', type: 'text/plain', size: 2 } as File,
74
+ Buffer.from('hi').toString('base64')
75
+ );
76
+ expect((await getDbAsSystem().get(tables.File, { id: plain.id })).origin).toBeFalsy();
77
+ });
78
+
79
+ it('round-trips the model beside the producer — which model made the bytes (`originModel`), only where one did', async () => {
80
+ const made = await new FileStorage().createFile(
81
+ {
82
+ name: 'mark.png',
83
+ type: 'image/png',
84
+ size: 4,
85
+ width: 1024,
86
+ height: 1024,
87
+ origin: 'generation',
88
+ originModel: 'example-image-model-1',
89
+ } as File,
90
+ Buffer.from('mark').toString('base64')
91
+ );
92
+ const madeRow = await getDbAsSystem().get(tables.File, { id: made.id });
93
+ expect(madeRow.origin).toEqual('generation');
94
+ expect(madeRow.originModel).toEqual('example-image-model-1');
95
+
96
+ // A producer that is not a model states its kind and no model: the two columns are independent.
97
+ const uploaded = await new FileStorage().createFile(
98
+ { name: 'photo.jpg', type: 'image/jpeg', size: 5, origin: 'upload' } as File,
99
+ Buffer.from('photo').toString('base64')
100
+ );
101
+ const uploadedRow = await getDbAsSystem().get(tables.File, { id: uploaded.id });
102
+ expect(uploadedRow.origin).toEqual('upload');
103
+ expect(uploadedRow.originModel).toBeFalsy();
104
+ });
105
+
63
106
  it('round-trips the rights record on a web-saved copy — licence name, deed URL and the credit sentence', async () => {
64
107
  const file = await new FileStorage().createFile(
65
108
  {
@@ -97,5 +140,6 @@ describe('File media metadata columns', () => {
97
140
  expect(row.durationMs ?? undefined).toBeUndefined();
98
141
  expect(row.license ?? undefined).toBeUndefined();
99
142
  expect(row.attribution ?? undefined).toBeUndefined();
143
+ expect(row.originModel ?? undefined).toBeUndefined();
100
144
  });
101
145
  });
@@ -0,0 +1,79 @@
1
+ import util from 'util';
2
+ import { FileStorageError } from '../src/FileStorageError';
3
+
4
+ /** An obviously fake credential — what a store client's error carries in its request headers. */
5
+ const MARKER = 'FAKE-TOKEN-MARKER';
6
+
7
+ /**
8
+ * What a driver's error carries is the driver's to own: a code, a plain message, the HTTP status.
9
+ * Whatever a caller does with one — print it, serialise it, inspect it — reads those three facts.
10
+ */
11
+ describe('FileStorageError', () => {
12
+ it('carries the code, a plain message and the status — and nothing else', () => {
13
+ const error = new FileStorageError('not-found', 'getFileData failed for file file-1', {
14
+ status: 404,
15
+ detail: 'No such object: a-bucket/file-1',
16
+ });
17
+
18
+ expect(error).toBeInstanceOf(Error);
19
+ expect(error.code).toEqual('not-found');
20
+ expect(error.status).toEqual(404);
21
+ expect(error.message).toEqual(
22
+ 'getFileData failed for file file-1: not-found (HTTP 404) — No such object: a-bucket/file-1'
23
+ );
24
+ expect(Object.keys(error).sort()).toEqual(['code', 'name', 'status']);
25
+ expect(Object.getOwnPropertyNames(error).sort()).toEqual(['code', 'message', 'name', 'stack', 'status']);
26
+ });
27
+
28
+ it('never reads an object into its message — only text is kept as the store’s detail', () => {
29
+ const vendorError = { message: 'boom', config: { headers: { Authorization: `Bearer ${MARKER}` } } };
30
+
31
+ const error = new FileStorageError('unknown', 'createFile failed for file file-2', { detail: vendorError });
32
+
33
+ expect(error.message).toEqual('createFile failed for file file-2: unknown');
34
+ expect(util.inspect(error, { depth: null, showHidden: true })).not.toContain(MARKER);
35
+ });
36
+
37
+ it('keeps a store’s text plain: one line, bounded, no URL query string, no bearer credential', () => {
38
+ const detail =
39
+ `Request to https://storage.example/b/a-bucket/o/file-3?X-Goog-Signature=${MARKER}&X-Goog-Expires=900 failed\n` +
40
+ `with headers Authorization: Bearer ${MARKER} ` +
41
+ 'x'.repeat(1000);
42
+
43
+ const error = new FileStorageError('unavailable', 'getSignedUrl failed for file file-3', { status: 503, detail });
44
+
45
+ expect(error.message).not.toContain(MARKER);
46
+ expect(error.message).not.toContain('\n');
47
+ expect(error.message).toContain('https://storage.example/b/a-bucket/o/file-3 failed');
48
+ expect(error.message).toContain('Bearer [masked]');
49
+ expect(error.message.length).toBeLessThan(400);
50
+ });
51
+
52
+ it('is recognised by its shape, so a copy from a duplicate package still reads as not-found', () => {
53
+ const fromAnotherCopy = Object.assign(new Error('getFileData failed'), {
54
+ name: 'FileStorageError',
55
+ code: 'not-found',
56
+ });
57
+
58
+ expect(FileStorageError.is(fromAnotherCopy)).toBe(true);
59
+ expect(FileStorageError.isNotFound(fromAnotherCopy)).toBe(true);
60
+ expect(FileStorageError.isNotFound(new FileStorageError('forbidden', 'getFileData failed'))).toBe(false);
61
+ expect(FileStorageError.is(Object.assign(new Error('a vendor error'), { code: 404 }))).toBe(false);
62
+ expect(FileStorageError.is(Object.assign(new Error('x'), { name: 'FileStorageError', code: 'made-up' }))).toBe(
63
+ false
64
+ );
65
+ expect(FileStorageError.is(undefined)).toBe(false);
66
+ });
67
+
68
+ it('reads one HTTP status one way', () => {
69
+ expect(FileStorageError.codeForStatus(404)).toEqual('not-found');
70
+ expect(FileStorageError.codeForStatus(412)).toEqual('precondition-failed');
71
+ expect(FileStorageError.codeForStatus(401)).toEqual('forbidden');
72
+ expect(FileStorageError.codeForStatus(403)).toEqual('forbidden');
73
+ expect(FileStorageError.codeForStatus(408)).toEqual('unavailable');
74
+ expect(FileStorageError.codeForStatus(429)).toEqual('unavailable');
75
+ expect(FileStorageError.codeForStatus(503)).toEqual('unavailable');
76
+ expect(FileStorageError.codeForStatus(400)).toEqual('unknown');
77
+ expect(FileStorageError.codeForStatus(undefined)).toEqual('unknown');
78
+ });
79
+ });
@@ -62,6 +62,9 @@ export class FileTestEnvironment {
62
62
  // implementations from the generated source graph; a graph-less harness lookup THROWS).
63
63
  // Default: no registered resolvers. Suites exercising the seam overwrite this entry.
64
64
  objectCache['@proteinjs/db-file/FileReachabilityResolver'] = [];
65
+ // The copy-for-others seam a non-owner read consults (same graph-less rule). Default: no
66
+ // maker — everyone is served the original. Suites exercising the seam overwrite this entry.
67
+ objectCache['@proteinjs/db-file/FileCopyForOthers'] = [];
65
68
  Session.setData({
66
69
  sessionId: 'test-session',
67
70
  user: 'guest',