@byline/core 6.12.0 → 6.13.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 (48) hide show
  1. package/dist/@types/extraction-types.d.ts +423 -0
  2. package/dist/@types/extraction-types.js +8 -0
  3. package/dist/@types/index.d.ts +1 -0
  4. package/dist/@types/index.js +1 -0
  5. package/dist/extraction/artifact.d.ts +46 -0
  6. package/dist/extraction/artifact.js +121 -0
  7. package/dist/extraction/artifact.test.node.d.ts +8 -0
  8. package/dist/extraction/artifact.test.node.js +194 -0
  9. package/dist/extraction/errors.d.ts +47 -0
  10. package/dist/extraction/errors.js +57 -0
  11. package/dist/extraction/errors.test.node.d.ts +8 -0
  12. package/dist/extraction/errors.test.node.js +68 -0
  13. package/dist/extraction/evidence.d.ts +22 -0
  14. package/dist/extraction/evidence.js +54 -0
  15. package/dist/extraction/evidence.test.node.d.ts +8 -0
  16. package/dist/extraction/evidence.test.node.js +138 -0
  17. package/dist/extraction/index.d.ts +14 -0
  18. package/dist/extraction/index.js +17 -0
  19. package/dist/extraction/internal.d.ts +30 -0
  20. package/dist/extraction/internal.js +93 -0
  21. package/dist/extraction/outcomes.d.ts +20 -0
  22. package/dist/extraction/outcomes.js +30 -0
  23. package/dist/extraction/outcomes.test.node.d.ts +8 -0
  24. package/dist/extraction/outcomes.test.node.js +55 -0
  25. package/dist/extraction/page-map.d.ts +40 -0
  26. package/dist/extraction/page-map.js +190 -0
  27. package/dist/extraction/page-map.test.node.d.ts +8 -0
  28. package/dist/extraction/page-map.test.node.js +371 -0
  29. package/dist/extraction/producer.d.ts +62 -0
  30. package/dist/extraction/producer.js +76 -0
  31. package/dist/extraction/producer.test.node.d.ts +8 -0
  32. package/dist/extraction/producer.test.node.js +230 -0
  33. package/dist/extraction/root-graph-guard.test.node.d.ts +8 -0
  34. package/dist/extraction/root-graph-guard.test.node.js +293 -0
  35. package/dist/extraction/work.d.ts +55 -0
  36. package/dist/extraction/work.js +102 -0
  37. package/dist/extraction/work.test.node.d.ts +8 -0
  38. package/dist/extraction/work.test.node.js +409 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +1 -0
  41. package/dist/storage/stored-files-server.d.ts +1 -0
  42. package/dist/storage/stored-files-server.js +9 -24
  43. package/dist/storage/stored-files-server.test.node.js +6 -0
  44. package/dist/storage/verified-read.d.ts +131 -0
  45. package/dist/storage/verified-read.js +328 -0
  46. package/dist/storage/verified-read.test.node.d.ts +8 -0
  47. package/dist/storage/verified-read.test.node.js +645 -0
  48. package/package.json +6 -6
@@ -0,0 +1,131 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { PreconditionFallbackReason, SourceIdentityProblem, SourceUnavailableReason, SourceVerificationEvidence } from '../@types/extraction-types.js';
9
+ import type { StoredFileValue } from '../@types/field-data-types.js';
10
+ import type { IStorageProvider, StorageValidator } from '../@types/storage-types.js';
11
+ /**
12
+ * The file name prefix of a spooled body. A process crash between spooling and
13
+ * `dispose` leaves such a file in the temporary directory, and the prefix lets an
14
+ * operator find and remove it.
15
+ *
16
+ * @provisional
17
+ */
18
+ export declare const VERIFIED_SOURCE_SPOOL_PREFIX = "byline-verified-source-";
19
+ /**
20
+ * Verified source bytes. The caller disposes of the body in a `finally` block.
21
+ *
22
+ * @provisional
23
+ */
24
+ export interface VerifiedSourceBody {
25
+ readonly size: number;
26
+ /** A fresh web stream over the verified bytes; may be opened more than once. */
27
+ stream(): ReadableStream<Uint8Array>;
28
+ /** A copy of the verified bytes in memory. Avoid for large files. */
29
+ bytes(): Promise<Uint8Array>;
30
+ /** Deletes any spool file. Idempotent. */
31
+ dispose(): Promise<void>;
32
+ }
33
+ /**
34
+ * The outcome of a source read. It is returned rather than thrown, so a caller handles
35
+ * each case explicitly. A precondition that prevented reading yields no `observedHash`.
36
+ *
37
+ * @provisional
38
+ */
39
+ export type VerifiedSourceRead = {
40
+ status: 'verified';
41
+ evidence: SourceVerificationEvidence;
42
+ body: VerifiedSourceBody;
43
+ } | {
44
+ status: 'identity-problem';
45
+ problem: SourceIdentityProblem;
46
+ storagePath: string;
47
+ /** Absent for `missing-hash`. */
48
+ expectedHash?: string;
49
+ /** Only for `hash-mismatch`. */
50
+ observedHash?: string;
51
+ /** Only for `hash-mismatch`. */
52
+ observedValidator?: StorageValidator;
53
+ preconditionFallback?: PreconditionFallbackReason;
54
+ } | {
55
+ status: 'unavailable';
56
+ reason: SourceUnavailableReason;
57
+ cause?: unknown;
58
+ };
59
+ /**
60
+ * The outcome of a change probe.
61
+ *
62
+ * `unprobed` is reduced assurance, not `unchanged`. With `head-unsupported`, a caller
63
+ * may reuse a verified artifact without a probe, and only separate verify runs detect a
64
+ * same-path replacement. With `no-evidence`, reuse is not permitted.
65
+ *
66
+ * @provisional
67
+ */
68
+ export type SourceProbe = {
69
+ status: 'unchanged';
70
+ validator: StorageValidator;
71
+ }
72
+ /** Read with `knownChanged`; the hash decides. */
73
+ | {
74
+ status: 'changed';
75
+ validator: StorageValidator;
76
+ } | {
77
+ status: 'missing-object';
78
+ } | {
79
+ status: 'unprobed';
80
+ reason: 'head-unsupported' | 'no-evidence';
81
+ } | {
82
+ status: 'unavailable';
83
+ cause: unknown;
84
+ };
85
+ /** Destroys a stream if it can be destroyed. */
86
+ export declare function destroyStream(stream: NodeJS.ReadableStream, reason?: unknown): void;
87
+ /**
88
+ * Reads a stream to its end, handing each chunk to `onChunk` in order. An abort destroys
89
+ * the stream, which rejects the read, and an abort that fires as the stream ends rejects
90
+ * too. The chunk may be reused by the stream, so `onChunk` copies anything it keeps.
91
+ */
92
+ export declare function consumeStream(stream: NodeJS.ReadableStream, options: {
93
+ signal?: AbortSignal;
94
+ onChunk: (chunk: Buffer) => void | Promise<void>;
95
+ }): Promise<void>;
96
+ /**
97
+ * Reads a stored file and verifies it against the reference's `fileHash`. The body is
98
+ * read to its end and hashed before any caller sees a byte, so only verified bytes can
99
+ * reach an extractor.
100
+ *
101
+ * - Makes at most two reads. A `changed`, `unsupported-kind` or `unestablishable`
102
+ * precondition leads to exactly one unconditional read, because a changed token does
103
+ * not prove changed content and the hash decides.
104
+ * - The evidence validator is the one returned by this read, never one from a probe or
105
+ * from the saved value.
106
+ * - Never writes to storage, never changes the saved value, and never accepts different
107
+ * bytes. Force is a work decision, not a verification bypass.
108
+ *
109
+ * Throws only for a malformed `memoryThreshold`, which is a caller bug.
110
+ *
111
+ * @provisional
112
+ */
113
+ export declare function readVerifiedSource(storage: IStorageProvider, source: Pick<StoredFileValue, 'storagePath' | 'storageProvider' | 'fileHash' | 'storageValidator'>, options?: {
114
+ /** Skip the expectation: a probe already showed a different validator. */
115
+ knownChanged?: boolean;
116
+ signal?: AbortSignal;
117
+ /** Bytes kept in memory before spooling to a temporary file. Default 8 MiB. */
118
+ memoryThreshold?: number;
119
+ }): Promise<VerifiedSourceRead>;
120
+ /**
121
+ * Compares the stored object's current validator with the **evidence's** validator,
122
+ * never the reference's saved one, so a matching copied value cannot stand in for
123
+ * verification. A different validator, or one of a different kind, is `changed`: the
124
+ * caller reads with `knownChanged`, and the hash decides.
125
+ *
126
+ * An unchanged probe does not establish a first baseline, and does not prove the object
127
+ * cannot change afterwards.
128
+ *
129
+ * @provisional
130
+ */
131
+ export declare function probeVerifiedSource(storage: IStorageProvider, storagePath: string, evidence: SourceVerificationEvidence | null): Promise<SourceProbe>;
@@ -0,0 +1,328 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ // Server-only: published through `@byline/core/stored-files`. It hashes and spools with
9
+ // Node built-ins, so it must stay out of the browser-safe main entry.
10
+ import { createHash, randomBytes } from 'node:crypto';
11
+ import { createReadStream } from 'node:fs';
12
+ import { open, readFile, rm } from 'node:fs/promises';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { Readable } from 'node:stream';
16
+ import { isSourceVerificationEvidence } from '../extraction/evidence.js';
17
+ import { ERR_STORAGE, ErrorCodes } from '../lib/errors.js';
18
+ import { isSameStorageValidator } from './content-hash.js';
19
+ /**
20
+ * The file name prefix of a spooled body. A process crash between spooling and
21
+ * `dispose` leaves such a file in the temporary directory, and the prefix lets an
22
+ * operator find and remove it.
23
+ *
24
+ * @provisional
25
+ */
26
+ export const VERIFIED_SOURCE_SPOOL_PREFIX = 'byline-verified-source-';
27
+ const DEFAULT_MEMORY_THRESHOLD = 8 * 1024 * 1024;
28
+ /** Destroys a stream if it can be destroyed. */
29
+ export function destroyStream(stream, reason) {
30
+ const destroyable = stream;
31
+ destroyable.destroy?.(reason instanceof Error ? reason : undefined);
32
+ }
33
+ /**
34
+ * Reads a stream to its end, handing each chunk to `onChunk` in order. An abort destroys
35
+ * the stream, which rejects the read, and an abort that fires as the stream ends rejects
36
+ * too. The chunk may be reused by the stream, so `onChunk` copies anything it keeps.
37
+ */
38
+ export async function consumeStream(stream, options) {
39
+ const { signal, onChunk } = options;
40
+ if (signal?.aborted) {
41
+ // An already-aborted signal never fires 'abort'; release the stream here.
42
+ // Destroy without an error: nothing listens to this stream yet, so an
43
+ // error argument would surface as an unhandled 'error' event.
44
+ destroyStream(stream);
45
+ signal.throwIfAborted();
46
+ }
47
+ const onAbort = () => destroyStream(stream, signal?.reason);
48
+ signal?.addEventListener('abort', onAbort, { once: true });
49
+ try {
50
+ for await (const chunk of stream) {
51
+ await onChunk(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
52
+ }
53
+ }
54
+ finally {
55
+ signal?.removeEventListener('abort', onAbort);
56
+ }
57
+ // Destroying a stream that has already ended does not reject the iteration above.
58
+ signal?.throwIfAborted();
59
+ }
60
+ function preconditionReason(error) {
61
+ const candidate = error;
62
+ if (candidate?.code !== ErrorCodes.STORAGE_PRECONDITION)
63
+ return null;
64
+ const reason = candidate.details?.reason;
65
+ // An unrecognised reason means the expectation could not be checked.
66
+ return reason === 'changed' || reason === 'unsupported-kind' || reason === 'missing'
67
+ ? reason
68
+ : 'unestablishable';
69
+ }
70
+ /** Holds bytes in memory up to a threshold, then in a private file. */
71
+ class BodySink {
72
+ memoryThreshold;
73
+ chunks = [];
74
+ handle;
75
+ path;
76
+ size = 0;
77
+ constructor(memoryThreshold) {
78
+ this.memoryThreshold = memoryThreshold;
79
+ }
80
+ async write(chunk) {
81
+ this.size += chunk.length;
82
+ if (this.handle === undefined && this.size <= this.memoryThreshold) {
83
+ this.chunks.push(chunk);
84
+ return;
85
+ }
86
+ if (this.handle === undefined) {
87
+ this.path = join(tmpdir(), `${VERIFIED_SOURCE_SPOOL_PREFIX}${randomBytes(16).toString('hex')}`);
88
+ // `wx` refuses an existing path; the mode keeps the bytes private.
89
+ this.handle = await open(this.path, 'wx', 0o600);
90
+ for (const buffered of this.chunks)
91
+ await this.handle.writeFile(buffered);
92
+ this.chunks = [];
93
+ }
94
+ await this.handle.writeFile(chunk);
95
+ }
96
+ async finish() {
97
+ if (this.handle !== undefined) {
98
+ await this.handle.close();
99
+ this.handle = undefined;
100
+ }
101
+ return this.path === undefined
102
+ ? memoryBody(Buffer.concat(this.chunks))
103
+ : spoolBody(this.path, this.size);
104
+ }
105
+ /** Removes everything. Never throws: the read's own failure is what matters. */
106
+ async discard() {
107
+ this.chunks = [];
108
+ await this.handle?.close().catch(() => { });
109
+ this.handle = undefined;
110
+ if (this.path !== undefined)
111
+ await rm(this.path, { force: true }).catch(() => { });
112
+ }
113
+ }
114
+ function assertNotDisposed(disposed) {
115
+ if (disposed)
116
+ throw new Error('This verified source body has been disposed.');
117
+ }
118
+ function memoryBody(buffer) {
119
+ let disposed = false;
120
+ return {
121
+ size: buffer.length,
122
+ stream() {
123
+ assertNotDisposed(disposed);
124
+ const copy = new Uint8Array(buffer);
125
+ return new ReadableStream({
126
+ start(controller) {
127
+ if (copy.length > 0)
128
+ controller.enqueue(copy);
129
+ controller.close();
130
+ },
131
+ });
132
+ },
133
+ async bytes() {
134
+ assertNotDisposed(disposed);
135
+ return new Uint8Array(buffer);
136
+ },
137
+ async dispose() {
138
+ disposed = true;
139
+ },
140
+ };
141
+ }
142
+ function spoolBody(path, size) {
143
+ let disposal;
144
+ return {
145
+ size,
146
+ stream() {
147
+ assertNotDisposed(disposal !== undefined);
148
+ return Readable.toWeb(createReadStream(path));
149
+ },
150
+ async bytes() {
151
+ assertNotDisposed(disposal !== undefined);
152
+ return new Uint8Array(await readFile(path));
153
+ },
154
+ dispose() {
155
+ disposal ??= rm(path, { force: true });
156
+ return disposal;
157
+ },
158
+ };
159
+ }
160
+ /**
161
+ * Reads a stored file and verifies it against the reference's `fileHash`. The body is
162
+ * read to its end and hashed before any caller sees a byte, so only verified bytes can
163
+ * reach an extractor.
164
+ *
165
+ * - Makes at most two reads. A `changed`, `unsupported-kind` or `unestablishable`
166
+ * precondition leads to exactly one unconditional read, because a changed token does
167
+ * not prove changed content and the hash decides.
168
+ * - The evidence validator is the one returned by this read, never one from a probe or
169
+ * from the saved value.
170
+ * - Never writes to storage, never changes the saved value, and never accepts different
171
+ * bytes. Force is a work decision, not a verification bypass.
172
+ *
173
+ * Throws only for a malformed `memoryThreshold`, which is a caller bug.
174
+ *
175
+ * @provisional
176
+ */
177
+ export async function readVerifiedSource(storage, source, options) {
178
+ const memoryThreshold = options?.memoryThreshold ?? DEFAULT_MEMORY_THRESHOLD;
179
+ if (!Number.isSafeInteger(memoryThreshold) || memoryThreshold < 0) {
180
+ throw new TypeError('memoryThreshold must be a non-negative integer');
181
+ }
182
+ const { storagePath, fileHash } = source;
183
+ const signal = options?.signal;
184
+ if (fileHash === undefined || fileHash === null || fileHash === '') {
185
+ return { status: 'identity-problem', problem: 'missing-hash', storagePath };
186
+ }
187
+ if (source.storageProvider !== storage.providerName) {
188
+ return { status: 'unavailable', reason: 'provider-mismatch' };
189
+ }
190
+ if (storage.download === undefined) {
191
+ return { status: 'unavailable', reason: 'download-unsupported' };
192
+ }
193
+ const failure = (cause) => ({
194
+ status: 'unavailable',
195
+ reason: signal?.aborted === true ? 'aborted' : 'read-failed',
196
+ cause,
197
+ });
198
+ if (signal?.aborted)
199
+ return failure(signal.reason);
200
+ const expect = options?.knownChanged === true ? undefined : (source.storageValidator ?? undefined);
201
+ let fallback;
202
+ const missingObject = () => ({
203
+ status: 'identity-problem',
204
+ problem: 'missing-object',
205
+ storagePath,
206
+ expectedHash: fileHash,
207
+ ...(fallback !== undefined && { preconditionFallback: fallback }),
208
+ });
209
+ let read;
210
+ try {
211
+ read = await storage.download(storagePath, {
212
+ ...(expect !== undefined && { expect }),
213
+ ...(signal !== undefined && { signal }),
214
+ });
215
+ }
216
+ catch (error) {
217
+ const reason = preconditionReason(error);
218
+ if (reason === null)
219
+ return failure(error);
220
+ if (reason === 'missing')
221
+ return missingObject();
222
+ fallback = reason;
223
+ try {
224
+ read = await storage.download(storagePath, signal !== undefined ? { signal } : {});
225
+ }
226
+ catch (fallbackError) {
227
+ // There is never a third read. Only `missing` keeps a specific meaning here.
228
+ return preconditionReason(fallbackError) === 'missing'
229
+ ? missingObject()
230
+ : failure(fallbackError);
231
+ }
232
+ }
233
+ if (read === null)
234
+ return missingObject();
235
+ const sink = new BodySink(memoryThreshold);
236
+ const hash = createHash('sha256');
237
+ try {
238
+ await consumeStream(read.body, {
239
+ ...(signal !== undefined && { signal }),
240
+ onChunk: async (chunk) => {
241
+ // A stream may reuse its buffer, so the body must own the bytes it hashed.
242
+ const owned = Buffer.from(chunk);
243
+ hash.update(owned);
244
+ await sink.write(owned);
245
+ },
246
+ });
247
+ if (read.size !== undefined && read.size !== sink.size) {
248
+ throw ERR_STORAGE({
249
+ message: `The body ended at ${sink.size} bytes, but the provider reported ${read.size}.`,
250
+ details: { reason: 'size-mismatch', actualSize: sink.size, declaredSize: read.size },
251
+ });
252
+ }
253
+ }
254
+ catch (error) {
255
+ destroyStream(read.body);
256
+ await sink.discard();
257
+ return failure(error);
258
+ }
259
+ const observedHash = hash.digest('hex');
260
+ if (observedHash !== fileHash) {
261
+ await sink.discard();
262
+ return {
263
+ status: 'identity-problem',
264
+ problem: 'hash-mismatch',
265
+ storagePath,
266
+ expectedHash: fileHash,
267
+ observedHash,
268
+ observedValidator: read.validator,
269
+ ...(fallback !== undefined && { preconditionFallback: fallback }),
270
+ };
271
+ }
272
+ const evidence = {
273
+ expectedHash: fileHash,
274
+ contentHash: observedHash,
275
+ validator: read.validator,
276
+ verifiedAt: new Date().toISOString(),
277
+ size: sink.size,
278
+ readBasis: fallback !== undefined ? 'fallback' : expect !== undefined ? 'expected' : 'unconditional',
279
+ ...(fallback !== undefined && { preconditionFallback: fallback }),
280
+ };
281
+ if (!isSourceVerificationEvidence(evidence, { expectedHash: fileHash })) {
282
+ await sink.discard();
283
+ return failure(ERR_STORAGE({
284
+ message: `Storage provider '${storage.providerName}' returned an unusable validator.`,
285
+ details: { reason: 'validator-unavailable', storagePath },
286
+ }));
287
+ }
288
+ try {
289
+ const body = await sink.finish();
290
+ if (signal?.aborted) {
291
+ await body.dispose();
292
+ return failure(signal.reason);
293
+ }
294
+ return { status: 'verified', evidence, body };
295
+ }
296
+ catch (error) {
297
+ await sink.discard();
298
+ return failure(error);
299
+ }
300
+ }
301
+ /**
302
+ * Compares the stored object's current validator with the **evidence's** validator,
303
+ * never the reference's saved one, so a matching copied value cannot stand in for
304
+ * verification. A different validator, or one of a different kind, is `changed`: the
305
+ * caller reads with `knownChanged`, and the hash decides.
306
+ *
307
+ * An unchanged probe does not establish a first baseline, and does not prove the object
308
+ * cannot change afterwards.
309
+ *
310
+ * @provisional
311
+ */
312
+ export async function probeVerifiedSource(storage, storagePath, evidence) {
313
+ if (!isSourceVerificationEvidence(evidence))
314
+ return { status: 'unprobed', reason: 'no-evidence' };
315
+ if (storage.head === undefined)
316
+ return { status: 'unprobed', reason: 'head-unsupported' };
317
+ try {
318
+ const head = await storage.head(storagePath);
319
+ if (head === null)
320
+ return { status: 'missing-object' };
321
+ return isSameStorageValidator(head.validator, evidence.validator)
322
+ ? { status: 'unchanged', validator: head.validator }
323
+ : { status: 'changed', validator: head.validator };
324
+ }
325
+ catch (cause) {
326
+ return { status: 'unavailable', cause };
327
+ }
328
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};