@phis/contracts 0.3.1 → 0.5.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 (46) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +159 -151
  3. package/dist/access.d.ts +13 -0
  4. package/dist/access.js +13 -0
  5. package/dist/addon-index.d.ts +4 -0
  6. package/dist/addon-index.js +4 -0
  7. package/dist/addon.d.ts +53 -2
  8. package/dist/capabilities.d.ts +54 -87
  9. package/dist/catalog.d.ts +14 -0
  10. package/dist/catalog.js +14 -2
  11. package/dist/cms.d.ts +11 -0
  12. package/dist/cms.js +20 -0
  13. package/dist/conditions.d.ts +96 -0
  14. package/dist/conditions.js +163 -0
  15. package/dist/controls.d.ts +83 -0
  16. package/dist/controls.js +133 -0
  17. package/dist/core.d.ts +38 -0
  18. package/dist/core.js +39 -0
  19. package/dist/forms.d.ts +496 -0
  20. package/dist/forms.js +508 -0
  21. package/dist/http.d.ts +10 -1
  22. package/dist/http.js +10 -1
  23. package/dist/layout.d.ts +22 -0
  24. package/dist/layout.js +30 -0
  25. package/dist/media-storage.d.ts +256 -14
  26. package/dist/media-storage.js +30 -2
  27. package/dist/media.d.ts +47 -3
  28. package/dist/media.js +59 -0
  29. package/dist/record.d.ts +8 -0
  30. package/dist/record.js +24 -0
  31. package/dist/references.d.ts +130 -0
  32. package/dist/references.js +208 -0
  33. package/dist/resource-links.d.ts +94 -0
  34. package/dist/resource-links.js +27 -0
  35. package/dist/signals.d.ts +1 -0
  36. package/dist/signals.js +3 -0
  37. package/dist/site-groups.d.ts +15 -0
  38. package/dist/support.d.ts +390 -0
  39. package/dist/support.js +148 -0
  40. package/dist/threads.d.ts +363 -0
  41. package/dist/threads.js +146 -0
  42. package/dist/translation.d.ts +125 -0
  43. package/dist/translation.js +15 -0
  44. package/dist/user-state.d.ts +132 -0
  45. package/dist/user-state.js +92 -0
  46. package/package.json +47 -2
@@ -33,6 +33,20 @@ export type PhisMediaObjectStreamInput = {
33
33
  */
34
34
  signal?: AbortSignal;
35
35
  };
36
+ /** Which bytes of an object somebody wants: the first one, and how many. */
37
+ export type PhisMediaObjectRange = {
38
+ /** The first byte, counted from zero and inside the object. */
39
+ offset: number;
40
+ /** How many bytes are wanted. An implementation may serve fewer, and then says so. */
41
+ length: number;
42
+ };
43
+ export type PhisMediaObjectRangeStream = {
44
+ body: ReadableStream<Uint8Array>;
45
+ /** What was served, which is what a `Content-Range` may claim -- never merely what was asked for. */
46
+ range: PhisMediaObjectRange;
47
+ /** The object's whole size, so a `Content-Range` can name the total it is a part of. */
48
+ byteSize: number;
49
+ };
36
50
  export type PhisMediaObjectHead = {
37
51
  /**
38
52
  * What this Provider can attest about the object's content, if anything.
@@ -82,6 +96,34 @@ export type PhiMediaUploadPlan = {
82
96
  method: "PUT";
83
97
  headers: Record<string, string>;
84
98
  expiresAt: string;
99
+ } | {
100
+ /**
101
+ * The body in several requests, assembled by the Provider.
102
+ *
103
+ * What this buys is not a larger ceiling but a resumable one. A single request that fails at ninety
104
+ * per cent has to be sent again from the first byte, and above a few hundred megabytes on an
105
+ * ordinary connection that is the usual outcome rather than the unlucky one. Each part here fails
106
+ * and repeats on its own.
107
+ *
108
+ * The parts are in order and each covers `partSizeBytes`, except the last, which covers what is
109
+ * left. That is the whole addressing scheme: part *n* is the bytes from `(n - 1) * partSizeBytes`,
110
+ * and a Client that can slice a file needs nothing else.
111
+ *
112
+ * `uploadId` travels out and comes back. The Client returns it, with each part's entity tag, as the
113
+ * `completion` that `completeUpload` assembles from -- and Core keeps its own copy, because the one
114
+ * thing a Client cannot be asked to do is report an upload it has stopped existing to report.
115
+ */
116
+ kind: "multipart-put";
117
+ method: "PUT";
118
+ uploadId: string;
119
+ partSizeBytes: number;
120
+ parts: readonly {
121
+ /** One-based and contiguous, the way every part API counts. */
122
+ partNumber: number;
123
+ url: string;
124
+ headers?: Record<string, string>;
125
+ }[];
126
+ expiresAt: string;
85
127
  };
86
128
  export type PhiMediaUploadPlanKindKey = PhiMediaUploadPlan["kind"];
87
129
  export type PhiMediaUploadPlanInput = {
@@ -100,10 +142,13 @@ export type PhiMediaUploadPlanInput = {
100
142
  * What the client says it is about to send, and under which algorithm, as lowercase hex.
101
143
  *
102
144
  * Present only for a Profile whose probe found the endpoint verifies that algorithm: an adapter that
103
- * receives it must sign it into the request and name the header the client has to send, so a body
104
- * that does not match is refused there and never becomes an object. That refusal is what makes the
105
- * figure trustworthy -- Core records the client's number because a wrong one would not have got this
106
- * far, not because the client is believed.
145
+ * receives it must sign it into the request, so a body that does not match is refused there and never
146
+ * becomes an object. That refusal is what makes the figure trustworthy -- Core records the client's
147
+ * number because a wrong one would not have got this far, not because the client is believed.
148
+ *
149
+ * Signed, and not also named as a header for the Client to repeat. Signing a request that has no body
150
+ * yet puts the digest in the query string, where the signature covers it; a checksum header outside the
151
+ * signed set is refused by a strict endpoint, so naming it costs the upload instead of securing it.
107
152
  *
108
153
  * The algorithm travels with the value because the adapter does not get to pick one. It was settled
109
154
  * for this Profile when it was probed, and a plan that quietly used another would produce a digest
@@ -116,6 +161,113 @@ export type PhiMediaUploadPlanInput = {
116
161
  algorithm: PhisMediaChecksumAlgorithm;
117
162
  value: string;
118
163
  };
164
+ /**
165
+ * One digest per part, in part order, as lowercase hex, for a body that is to arrive in parts.
166
+ *
167
+ * The same bargain as `checksum` and for the same reason, one level down: each part's digest is signed
168
+ * into that part's request, so a part whose bytes do not match is refused and never joins the object.
169
+ * Present only together with `mayAssembleFromParts`, and only where the Profile's base algorithm is
170
+ * SHA-256 -- these are SHA-256 digests and an adapter must not offer them under another name.
171
+ *
172
+ * The divisions are `resolvePhiMediaUploadPartSizeBytes` over the same `sizeBytes`, which is how the
173
+ * Client could compute them before this plan existed. An adapter that receives a count not matching that
174
+ * rule must refuse the plan rather than address the parts it was given: the mismatch means the two sides
175
+ * divided the body differently, and every digest after the first would be signed against other bytes.
176
+ *
177
+ * `checksum` alongside this carries the composite these add up to, which is what Core records. The
178
+ * adapter does not need it and must not recompute it.
179
+ */
180
+ partChecksums?: readonly string[];
181
+ /**
182
+ * Whether this Profile may answer with an object assembled from parts.
183
+ *
184
+ * Core's decision and not the Provider's, for the same reason `checksum` above is: a Provider does not
185
+ * get to grade its own endpoint. What Core weighs is the size -- a single request that fails at ninety
186
+ * per cent is sent again from the first byte, and above a few hundred megabytes that is the usual
187
+ * outcome -- against what the object will be able to attest afterwards.
188
+ *
189
+ * It is a permission and not a capability: the Provider may be perfectly able and still not be asked.
190
+ *
191
+ * Absent means no. A Provider that cannot assemble parts ignores it either way.
192
+ */
193
+ mayAssembleFromParts?: boolean;
194
+ };
195
+ /**
196
+ * What is needed to carry on delivering an assembly that was already opened.
197
+ *
198
+ * The case is a page that went away mid-upload -- a reload, a crash, a tab somebody closed. The parts that
199
+ * did arrive are at the Provider, paid for and perfectly good, and until now the only thing that could
200
+ * happen to them was to be aborted: the plan was gone with the page, and asking for a new one opens a
201
+ * second assembly the first one's parts do not belong to. Which is why this is not `createUploadPlan` with
202
+ * an extra field. `createUploadPlan` *opens* an upload; this one joins the one that is open.
203
+ *
204
+ * It is deliberately not "list the parts and let Core sign what is missing": which parts an assembly holds
205
+ * and which addresses those parts have are the same Provider's business, and splitting them would put Core
206
+ * in the position of deciding what is missing from a listing it cannot interpret.
207
+ *
208
+ * `partChecksums` carries **every** part's digest, in part order, exactly as `createUploadPlan` received it
209
+ * -- the implementation signs the ones it is about to ask for. Anything else would make Core work out the
210
+ * gap before it knows what the gap is.
211
+ */
212
+ export type PhiMediaUploadResumeInput = {
213
+ storageKey: string;
214
+ /** The Provider's own name for the assembly, which the session kept because the Client could not. */
215
+ uploadId: string;
216
+ contentType: string;
217
+ sizeBytes: number;
218
+ expiresAt: string;
219
+ /** One digest per part, in part order, as lowercase hex. The same bargain as on a fresh plan. */
220
+ partChecksums?: readonly string[];
221
+ };
222
+ /**
223
+ * An assembly as it stands: what is still to be sent, and what is already there.
224
+ *
225
+ * The two halves travel together because a completion has to name every part. The Client brings back the
226
+ * tags of the parts it sent, and these are the tags of the parts an earlier page sent -- nobody else has
227
+ * them, and the Client that had them is the thing that went away.
228
+ */
229
+ export type PhiMediaUploadResumption = {
230
+ /** Addresses for the parts the storage does not hold yet. Empty where the body is complete. */
231
+ plan: Extract<PhiMediaUploadPlan, {
232
+ kind: "multipart-put";
233
+ }>;
234
+ /** The parts it does hold, in part order. */
235
+ uploaded: readonly {
236
+ partNumber: number;
237
+ /** As the storage reports it, quoting and all: a completion has to name it exactly. */
238
+ eTag: string;
239
+ sizeBytes: number;
240
+ }[];
241
+ };
242
+ /**
243
+ * What a storage is asked to clean up on its own, and how long it must wait first.
244
+ *
245
+ * Both figures follow from how long an upload session may live, which is Core's to know. They are days
246
+ * because that is the only unit an object lifecycle rule takes, and they are more than one day because such
247
+ * a rule is evaluated asynchronously -- a rule of "one day" can fire on an upload that still has hours to
248
+ * run, and aborting a body somebody is in the middle of sending is worse than paying for it a while longer.
249
+ *
250
+ * `stagingKeyPrefixes` are Core's key space, without whatever prefix a Provider adds of its own; the
251
+ * Provider prepends that the same way it does for an object key.
252
+ *
253
+ * **An empty prefix list means no expiry rule at all, and an implementation must treat it that way.** An
254
+ * expiry rule matching everything would delete every object in the storage after two days, which is the one
255
+ * mistake in this area that cannot be undone -- so "expire nothing" and "expire everything" must never be
256
+ * the same argument.
257
+ */
258
+ export type PhisMediaLifecyclePolicy = {
259
+ /**
260
+ * Days after an upload was started before the storage may abort what was never completed.
261
+ *
262
+ * This is the whole reason the rule exists. A Client that reports its own failure is handled without any
263
+ * of this, and one that crashes, loses its connection or has its tab killed reports nothing -- and what it
264
+ * leaves behind is parts that are paid for and cannot be listed.
265
+ */
266
+ abortIncompleteMultipartUploadAfterDays: number;
267
+ /** Key prefixes, in Core's key space, under which a body is staged before it becomes an Asset. */
268
+ stagingKeyPrefixes: readonly string[];
269
+ /** Days a staged object may sit under those prefixes before the storage removes it. */
270
+ expireStagedObjectsAfterDays: number;
119
271
  };
120
272
  export type PhiMediaUploadCompletionInput = {
121
273
  storageKey: string;
@@ -140,8 +292,9 @@ export type PhiMediaUploadCompletionInput = {
140
292
  * an `ETag` is whatever a Provider decided to put there, and Providers disagree. Every recorded digest
141
293
  * therefore carries the algorithm that produced it.
142
294
  */
143
- export declare const PHIS_MEDIA_CHECKSUM_ALGORITHMS: readonly ["sha256", "sha512", "xxhash128", "crc64nvme", "crc32c", "crc32", "md5"];
295
+ export declare const PHIS_MEDIA_CHECKSUM_ALGORITHMS: readonly ["sha256", "sha512", "sha256-composite", "xxhash128", "crc64nvme", "crc32c", "crc32", "md5"];
144
296
  export type PhisMediaChecksumAlgorithm = (typeof PHIS_MEDIA_CHECKSUM_ALGORITHMS)[number];
297
+ export { PHIS_MEDIA_UPLOAD_MAX_PART_COUNT, PHIS_MEDIA_UPLOAD_PART_SIZE_BYTES, formatPhiMediaCompositeChecksum, parsePhiMediaCompositeChecksum, resolvePhiMediaUploadPartCount, resolvePhiMediaUploadPartSizeBytes, } from "./media.js";
145
298
  /**
146
299
  * The digests strong enough to answer "is this the same file", rather than only "did it arrive intact".
147
300
  *
@@ -154,6 +307,19 @@ export type PhisMediaChecksumAlgorithm = (typeof PHIS_MEDIA_CHECKSUM_ALGORITHMS)
154
307
  * Space can stop one file from entering it. Small, but not nothing, and free to avoid.
155
308
  */
156
309
  export declare const PHIS_MEDIA_IDENTITY_CHECKSUM_ALGORITHMS: readonly PhisMediaChecksumAlgorithm[];
310
+ /**
311
+ * Why `sha256-composite` is identity-grade although it hashes digests rather than bytes.
312
+ *
313
+ * It is `SHA256` over the concatenated part digests of a body divided by a fixed rule. For the question
314
+ * duplicate detection asks -- are these the same bytes -- it is as hard to forge as SHA-256 itself: a
315
+ * second file matching one requires a SHA-256 collision in some part. What it cannot do is compare across
316
+ * divisions, which is why the division is written into the value and why the rule above is frozen.
317
+ *
318
+ * It exists because a browser cannot produce the other kind for a large file. Web Crypto has no
319
+ * incremental digest, so a whole-object SHA-256 means holding the entire body in memory; a part digest is
320
+ * one part, and the composite over those is a few kilobytes. The choice is not between this and a
321
+ * whole-object digest, it is between this and no digest at all.
322
+ */
157
323
  export declare function isPhisMediaIdentityChecksum(algorithm: string | null | undefined): boolean;
158
324
  /**
159
325
  * Which of the digests an endpoint verifies a Profile will record.
@@ -165,6 +331,10 @@ export declare function isPhisMediaIdentityChecksum(algorithm: string | null | u
165
331
  *
166
332
  * Only identity-grade digests are eligible. A CRC an endpoint also verifies still proves a transfer
167
333
  * intact, and Core records it when that is what arrived; it is simply not what a Profile asks for.
334
+ *
335
+ * `sha256-composite` is not eligible either, for a different reason: it is not something an endpoint
336
+ * verifies, it is the form a SHA-256 takes when a body arrived in parts. A Profile records a base
337
+ * algorithm, and an object assembled from parts under a `sha256` Profile carries the composite of it.
168
338
  */
169
339
  export declare function choosePhisMediaChecksumAlgorithm(probe: {
170
340
  verifiedAlgorithms: readonly PhisMediaChecksumAlgorithm[];
@@ -187,15 +357,6 @@ export type PhisMediaStorageProbe = {
187
357
  * never be told to be the same.
188
358
  */
189
359
  verifiedAlgorithms: PhisMediaChecksumAlgorithm[];
190
- /**
191
- * Whether a digest survives an object arriving in parts.
192
- *
193
- * A checksum signed into one PUT covers that PUT. Above the single-request limit an object is
194
- * assembled from parts, and the usual answer is a digest of the part digests -- a number that hashes
195
- * no bytes anyone uploaded. An endpoint that computes over the whole assembled object instead keeps
196
- * the guarantee at any size; one that does not confines it to what fits in one request.
197
- */
198
- verifiesWholeMultipartObject: boolean;
199
360
  /** What did not answer, in an operator's terms. Empty when everything did. */
200
361
  findings: string[];
201
362
  };
@@ -210,7 +371,46 @@ export interface PhisMediaStorageAdapter {
210
371
  probeCapabilities(): Promise<PhisMediaStorageProbe>;
211
372
  putObject(input: PhisMediaObjectInput): Promise<PhisMediaObjectHead>;
212
373
  putObjectStream(input: PhisMediaObjectStreamInput): Promise<PhisMediaObjectHead>;
374
+ /**
375
+ * The whole object at once, for code that has to look at every byte.
376
+ *
377
+ * Image processing, font subsetting and content sniffing are what this is for: they hold the bytes
378
+ * because they read them, and no stream would change that. Serving an object to somebody is not one
379
+ * of those cases -- see `getObjectStream`.
380
+ */
213
381
  getObject(storageKey: string): Promise<Buffer | null>;
382
+ /**
383
+ * The object as it arrives, for code that only passes it on.
384
+ *
385
+ * Delivery reads nothing: it copies bytes from the Provider to whoever asked, and holding the whole
386
+ * object to do that makes the largest object a Site accepts the amount of memory one download costs.
387
+ * A Provider that stores remotely hands back its own response body, and the Local adapter a file read
388
+ * stream, so nothing between the two ever holds more than what is in flight.
389
+ *
390
+ * `null` means the object is not there, the same answer `getObject` gives.
391
+ */
392
+ getObjectStream(storageKey: string): Promise<ReadableStream<Uint8Array> | null>;
393
+ /**
394
+ * Part of an object as it arrives, for serving a `Range` request.
395
+ *
396
+ * What this is for is seeking. A browser asked for a video cannot start in the middle without it, and
397
+ * Safari commonly declines to play at all where a server answers `200` to a `Range` -- so delivery
398
+ * without this is delivery of files people download, not of media people scrub through.
399
+ *
400
+ * It reports the range it **actually** produced rather than echoing the one it was given, and that is
401
+ * the point of the shape. A `Content-Range` is a claim about bytes; a Provider that quietly ignored the
402
+ * request and returned the whole object would otherwise turn that claim into a lie, and the caller
403
+ * would frame a complete body as a partial one. Comparing what was asked with what came back is what
404
+ * lets delivery answer `200` in that case instead.
405
+ *
406
+ * `offset` is the first byte, `length` is how many are wanted. An implementation clamps `length` to the
407
+ * end of the object and says so in what it returns; the caller has already established that the offset
408
+ * is inside the object, because it knows the size and a range beyond it is a `416` rather than a read.
409
+ * A `length` of zero -- asked for, or left after clamping -- is an empty body and not an error.
410
+ *
411
+ * `null` means the object is not there, the same answer `getObjectStream` gives.
412
+ */
413
+ getObjectRangeStream(storageKey: string, range: PhisMediaObjectRange): Promise<PhisMediaObjectRangeStream | null>;
214
414
  /**
215
415
  * The first bytes of an object, for deciding what it actually is without moving it.
216
416
  *
@@ -237,6 +437,29 @@ export interface PhisMediaStorageAdapter {
237
437
  listPrefix(prefix: string): Promise<PhisMediaObjectHead[]>;
238
438
  /** States how the Client is to deliver this body. */
239
439
  createUploadPlan(input: PhiMediaUploadPlanInput): Promise<PhiMediaUploadPlan>;
440
+ /**
441
+ * Says how what is left of an already opened assembly is to be delivered.
442
+ *
443
+ * This is what makes a large upload survive the page it was started from. A part that fails repeats
444
+ * alone already, but a reload used to cost every part that had arrived -- the plan lived in the tab and
445
+ * the parts were aborted the moment the page said it was leaving. The parts are the expensive thing;
446
+ * they are at the Provider and nothing is wrong with them.
447
+ *
448
+ * The Provider answers, not Core, because only the Provider knows what it is holding. A Client's account
449
+ * of how far it got is a claim about a session that has stopped existing, and a Core-side tally would be
450
+ * a second record of the same fact that can disagree with the storage.
451
+ *
452
+ * `null` means there is nothing to carry on: the assembly was aborted, or a lifecycle rule has reached
453
+ * it, or it was never this storage's. It also means that for a Provider that never assembles anything --
454
+ * a no-op answer and not a shortcoming, the same way `applyCorsPolicy` is a no-op where no browser ever
455
+ * addresses the storage.
456
+ *
457
+ * An implementation must refuse -- `null` -- where what it holds was divided differently from what it is
458
+ * being asked to sign: a part of the wrong size means the object would assemble into something other
459
+ * than the body Core recorded a digest for, and finishing it would put a correct digest on the wrong
460
+ * bytes. Starting over is the honest outcome there.
461
+ */
462
+ resumeUploadPlan(input: PhiMediaUploadResumeInput): Promise<PhiMediaUploadResumption | null>;
240
463
  /**
241
464
  * Settles the staged object once the Client says it is done, and answers what actually landed.
242
465
  *
@@ -256,6 +479,25 @@ export interface PhisMediaStorageAdapter {
256
479
  * to check, because a Provider that cannot say no to a body it never receives has nothing to say.
257
480
  */
258
481
  applyCorsPolicy(origins: readonly string[]): Promise<void>;
482
+ /**
483
+ * States what the storage is to clean up by itself, because Core cannot see it and never will.
484
+ *
485
+ * The reason this is not a sweeper is that the two things it names are invisible to the side that would
486
+ * have to sweep them. An unfinished multipart upload is billed from the moment it opens and appears in no
487
+ * object listing at all: only the storage knows it exists, and only the Client that has gone away could
488
+ * have named it. A staged object whose session expired is findable, but reaching it means opening
489
+ * Providers on behalf of sessions a request has no business touching -- which is why expiry deliberately
490
+ * releases the reservation and leaves storage alone.
491
+ *
492
+ * So the rule is written once, when a Profile is written, exactly as the CORS rule is. What Core knows is
493
+ * how long an upload may take and which keys are staging; what the storage knows is how to express "stop
494
+ * paying for this".
495
+ *
496
+ * Replaced whole, never merged, for the same reason CORS is: a rule has to state what is true now rather
497
+ * than accumulate what once was. A Provider with no such notion, such as the Local one, implements it as
498
+ * a no-op -- its files are Core's own and an operator command is what removes them.
499
+ */
500
+ applyLifecyclePolicy(policy: PhisMediaLifecyclePolicy): Promise<void>;
259
501
  /**
260
502
  * What makes two Storage Profiles the same physical storage.
261
503
  *
@@ -26,8 +26,17 @@ export const PHI_LOCAL_MEDIA_STORAGE_PROVIDER_ID = "@phis/server/storage-local";
26
26
  * therefore carries the algorithm that produced it.
27
27
  */
28
28
  export const PHIS_MEDIA_CHECKSUM_ALGORITHMS = [
29
- "sha256", "sha512", "xxhash128", "crc64nvme", "crc32c", "crc32", "md5",
29
+ "sha256", "sha512", "sha256-composite", "xxhash128", "crc64nvme", "crc32c", "crc32", "md5",
30
30
  ];
31
+ /*
32
+ * The division and the composite's recorded form live in `./media.ts`, and are re-exported here.
33
+ *
34
+ * They are the one part of this contract a browser has to compute as well: the Client hashes the parts
35
+ * before a plan exists, because a part digest can only be signed into a part request if it is known when
36
+ * that request is signed. So the rule cannot sit behind the Add-on surface, and it must not be written
37
+ * twice -- two copies of a division are two divisions the day one of them is edited.
38
+ */
39
+ export { PHIS_MEDIA_UPLOAD_MAX_PART_COUNT, PHIS_MEDIA_UPLOAD_PART_SIZE_BYTES, formatPhiMediaCompositeChecksum, parsePhiMediaCompositeChecksum, resolvePhiMediaUploadPartCount, resolvePhiMediaUploadPartSizeBytes, } from "./media.js";
31
40
  /**
32
41
  * The digests strong enough to answer "is this the same file", rather than only "did it arrive intact".
33
42
  *
@@ -39,7 +48,20 @@ export const PHIS_MEDIA_CHECKSUM_ALGORITHMS = [
39
48
  * substituted file -- so the cost of trusting a weak digest is that someone who may already write to a
40
49
  * Space can stop one file from entering it. Small, but not nothing, and free to avoid.
41
50
  */
42
- export const PHIS_MEDIA_IDENTITY_CHECKSUM_ALGORITHMS = ["sha256", "sha512"];
51
+ export const PHIS_MEDIA_IDENTITY_CHECKSUM_ALGORITHMS = ["sha256", "sha512", "sha256-composite"];
52
+ /**
53
+ * Why `sha256-composite` is identity-grade although it hashes digests rather than bytes.
54
+ *
55
+ * It is `SHA256` over the concatenated part digests of a body divided by a fixed rule. For the question
56
+ * duplicate detection asks -- are these the same bytes -- it is as hard to forge as SHA-256 itself: a
57
+ * second file matching one requires a SHA-256 collision in some part. What it cannot do is compare across
58
+ * divisions, which is why the division is written into the value and why the rule above is frozen.
59
+ *
60
+ * It exists because a browser cannot produce the other kind for a large file. Web Crypto has no
61
+ * incremental digest, so a whole-object SHA-256 means holding the entire body in memory; a part digest is
62
+ * one part, and the composite over those is a few kilobytes. The choice is not between this and a
63
+ * whole-object digest, it is between this and no digest at all.
64
+ */
43
65
  export function isPhisMediaIdentityChecksum(algorithm) {
44
66
  return PHIS_MEDIA_IDENTITY_CHECKSUM_ALGORITHMS.includes(algorithm);
45
67
  }
@@ -53,11 +75,17 @@ export function isPhisMediaIdentityChecksum(algorithm) {
53
75
  *
54
76
  * Only identity-grade digests are eligible. A CRC an endpoint also verifies still proves a transfer
55
77
  * intact, and Core records it when that is what arrived; it is simply not what a Profile asks for.
78
+ *
79
+ * `sha256-composite` is not eligible either, for a different reason: it is not something an endpoint
80
+ * verifies, it is the form a SHA-256 takes when a body arrived in parts. A Profile records a base
81
+ * algorithm, and an object assembled from parts under a `sha256` Profile carries the composite of it.
56
82
  */
57
83
  export function choosePhisMediaChecksumAlgorithm(probe) {
58
84
  if (!probe)
59
85
  return null;
60
86
  for (const candidate of PHIS_MEDIA_IDENTITY_CHECKSUM_ALGORITHMS) {
87
+ if (candidate === "sha256-composite")
88
+ continue;
61
89
  if (probe.verifiedAlgorithms.includes(candidate))
62
90
  return candidate;
63
91
  }
package/dist/media.d.ts CHANGED
@@ -104,9 +104,9 @@ export type PhiImageAssetVariantSpec = {
104
104
  quality: number;
105
105
  format: "webp";
106
106
  };
107
- export declare function normalizePhiMediaKind(kind: string | null | undefined): "binary" | "other" | "image" | "video" | "audio" | "pdf" | "markdown" | "document" | "archive" | "font";
108
- export declare function resolvePhiMediaKindFromContentType(contentType: string | null | undefined): "binary" | "other" | "image" | "video" | "audio" | "pdf" | "markdown" | "document" | "archive" | "font";
109
- export declare function normalizePhiImageAssetVariantKey(key: unknown): 0 | 1 | 2 | 3 | 4 | 5 | 6 | 9 | 7 | 8 | null;
107
+ export declare function normalizePhiMediaKind(kind: string | null | undefined): "image" | "video" | "audio" | "pdf" | "markdown" | "document" | "archive" | "font" | "binary" | "other";
108
+ export declare function resolvePhiMediaKindFromContentType(contentType: string | null | undefined): "image" | "video" | "audio" | "pdf" | "markdown" | "document" | "archive" | "font" | "binary" | "other";
109
+ export declare function normalizePhiImageAssetVariantKey(key: unknown): 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 8 | null;
110
110
  export declare function resolvePhiImageAssetVariantKeyName(key: number | null | undefined): string | null;
111
111
  export declare function resolvePhiImageAssetVariantSpec(key: number | null | undefined): PhiImageAssetVariantSpec | null;
112
112
  /**
@@ -226,3 +226,47 @@ export declare function isPhiFontSubsetKey(value: unknown): value is PhiFontSubs
226
226
  * `LatinExt` where `sr` preloads `Cyrillic`.
227
227
  */
228
228
  export declare function resolvePhiFontPreloadSubsetKeys(locale: string | null | undefined, coverage: PhiFontCoverage): PhiFontSubsetKeyValue[];
229
+ /**
230
+ * How a body above one part is divided, as a rule rather than as a Provider's private arithmetic.
231
+ *
232
+ * It lives here because two parties have to agree on it and neither can be told by the other in time. The
233
+ * Provider needs the part size to sign one request per part; the Client needs it *before* the plan exists,
234
+ * because a digest can only be signed into a request if it is known when the request is signed -- so the
235
+ * Client has to hash the same divisions the Provider is about to address. A rule both compute from the
236
+ * byte size is the only arrangement where that holds without an extra round trip.
237
+ *
238
+ * **Frozen once objects exist under it.** A `sha256-composite` value is a digest of part digests, so it
239
+ * answers "the same bytes divided the same way". Change the division and the same file yields a different
240
+ * value, which splits duplicate detection into cohorts that can never match across the change. The value
241
+ * carries the part size for exactly this reason: a changed rule produces non-matches, never false matches.
242
+ * Changing it is therefore a decision about the whole corpus, not a tuning knob.
243
+ *
244
+ * `PHIS_MEDIA_UPLOAD_MAX_PART_COUNT` is well under S3's ten thousand on purpose. Every part is signed up
245
+ * front, so the count is also the size of the plan a Client receives and Core holds in memory.
246
+ */
247
+ export declare const PHIS_MEDIA_UPLOAD_PART_SIZE_BYTES: number;
248
+ export declare const PHIS_MEDIA_UPLOAD_MAX_PART_COUNT = 1000;
249
+ /**
250
+ * The part size for one body, and the same answer wherever it is asked.
251
+ *
252
+ * Sixteen mebibytes until that would need more parts than are allowed, and from there the smallest whole
253
+ * mebibyte that fits the body into the allowance. Whole mebibytes keep the number short where it is
254
+ * recorded and keep the arithmetic exact in a browser, where sizes are doubles.
255
+ */
256
+ export declare function resolvePhiMediaUploadPartSizeBytes(sizeBytes: number): number;
257
+ /** How many parts one body is divided into under `resolvePhiMediaUploadPartSizeBytes`. */
258
+ export declare function resolvePhiMediaUploadPartCount(sizeBytes: number): number;
259
+ /**
260
+ * The recorded form of a `sha256-composite` digest: the digest, the part count, the part size.
261
+ *
262
+ * Duplicate detection compares these values as strings, so everything that decides whether two of them
263
+ * mean the same thing has to be inside the string. The digest alone would not: the same bytes divided
264
+ * differently hash differently, and a value that did not say how it was divided would look like a plain
265
+ * SHA-256 that simply failed to match. Naming the division makes a mismatch legible instead of puzzling.
266
+ */
267
+ export declare function formatPhiMediaCompositeChecksum(digestHex: string, partCount: number, partSizeBytes: number): string;
268
+ export declare function parsePhiMediaCompositeChecksum(value: string): {
269
+ digest: string;
270
+ partCount: number;
271
+ partSizeBytes: number;
272
+ } | null;
package/dist/media.js CHANGED
@@ -360,3 +360,62 @@ export function resolvePhiFontPreloadSubsetKeys(locale, coverage) {
360
360
  : PHI_FONT_SUBSET_PRELOAD_BY_LANGUAGE[language] ?? [];
361
361
  return [PhiFontSubsetKey.Latin, ...extra].filter((key) => coverage.subsets.includes(key));
362
362
  }
363
+ /**
364
+ * How a body above one part is divided, as a rule rather than as a Provider's private arithmetic.
365
+ *
366
+ * It lives here because two parties have to agree on it and neither can be told by the other in time. The
367
+ * Provider needs the part size to sign one request per part; the Client needs it *before* the plan exists,
368
+ * because a digest can only be signed into a request if it is known when the request is signed -- so the
369
+ * Client has to hash the same divisions the Provider is about to address. A rule both compute from the
370
+ * byte size is the only arrangement where that holds without an extra round trip.
371
+ *
372
+ * **Frozen once objects exist under it.** A `sha256-composite` value is a digest of part digests, so it
373
+ * answers "the same bytes divided the same way". Change the division and the same file yields a different
374
+ * value, which splits duplicate detection into cohorts that can never match across the change. The value
375
+ * carries the part size for exactly this reason: a changed rule produces non-matches, never false matches.
376
+ * Changing it is therefore a decision about the whole corpus, not a tuning knob.
377
+ *
378
+ * `PHIS_MEDIA_UPLOAD_MAX_PART_COUNT` is well under S3's ten thousand on purpose. Every part is signed up
379
+ * front, so the count is also the size of the plan a Client receives and Core holds in memory.
380
+ */
381
+ export const PHIS_MEDIA_UPLOAD_PART_SIZE_BYTES = 16 * 1024 * 1024;
382
+ export const PHIS_MEDIA_UPLOAD_MAX_PART_COUNT = 1000;
383
+ /**
384
+ * The part size for one body, and the same answer wherever it is asked.
385
+ *
386
+ * Sixteen mebibytes until that would need more parts than are allowed, and from there the smallest whole
387
+ * mebibyte that fits the body into the allowance. Whole mebibytes keep the number short where it is
388
+ * recorded and keep the arithmetic exact in a browser, where sizes are doubles.
389
+ */
390
+ export function resolvePhiMediaUploadPartSizeBytes(sizeBytes) {
391
+ const mebibyte = 1024 * 1024;
392
+ if (sizeBytes <= PHIS_MEDIA_UPLOAD_PART_SIZE_BYTES * PHIS_MEDIA_UPLOAD_MAX_PART_COUNT) {
393
+ return PHIS_MEDIA_UPLOAD_PART_SIZE_BYTES;
394
+ }
395
+ return Math.ceil(Math.ceil(sizeBytes / PHIS_MEDIA_UPLOAD_MAX_PART_COUNT) / mebibyte) * mebibyte;
396
+ }
397
+ /** How many parts one body is divided into under `resolvePhiMediaUploadPartSizeBytes`. */
398
+ export function resolvePhiMediaUploadPartCount(sizeBytes) {
399
+ return Math.max(1, Math.ceil(sizeBytes / resolvePhiMediaUploadPartSizeBytes(sizeBytes)));
400
+ }
401
+ /**
402
+ * The recorded form of a `sha256-composite` digest: the digest, the part count, the part size.
403
+ *
404
+ * Duplicate detection compares these values as strings, so everything that decides whether two of them
405
+ * mean the same thing has to be inside the string. The digest alone would not: the same bytes divided
406
+ * differently hash differently, and a value that did not say how it was divided would look like a plain
407
+ * SHA-256 that simply failed to match. Naming the division makes a mismatch legible instead of puzzling.
408
+ */
409
+ export function formatPhiMediaCompositeChecksum(digestHex, partCount, partSizeBytes) {
410
+ return `${digestHex.toLowerCase()}-${partCount}-${partSizeBytes}`;
411
+ }
412
+ export function parsePhiMediaCompositeChecksum(value) {
413
+ const match = /^([0-9a-f]{64})-(\d{1,5})-(\d{1,12})$/u.exec(value.trim().toLowerCase());
414
+ if (!match)
415
+ return null;
416
+ const partCount = Number(match[2]);
417
+ const partSizeBytes = Number(match[3]);
418
+ if (partCount < 1 || partSizeBytes < 1)
419
+ return null;
420
+ return { digest: match[1], partCount, partSizeBytes };
421
+ }
@@ -0,0 +1,8 @@
1
+ export declare function isPhiRecord(value: unknown): value is Record<string, unknown>;
2
+ /**
3
+ * The value a dotted path names inside a record: `"author.name"` reads `input.author.name`.
4
+ *
5
+ * Empty segments are skipped, so `""` names the input itself, and only records are walked -- an array
6
+ * or a primitive on the way ends the walk with `undefined` rather than being indexed into.
7
+ */
8
+ export declare function readPhiDotPath(input: unknown, path: string): unknown;
package/dist/record.js ADDED
@@ -0,0 +1,24 @@
1
+ /*
2
+ * Shared by the parsers of this package and not exported from it: each consumer keeps its own, and
3
+ * the one question they all answer is settled the same way -- an array is not a keyed record.
4
+ */
5
+ export function isPhiRecord(value) {
6
+ return value !== null && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+ /**
9
+ * The value a dotted path names inside a record: `"author.name"` reads `input.author.name`.
10
+ *
11
+ * Empty segments are skipped, so `""` names the input itself, and only records are walked -- an array
12
+ * or a primitive on the way ends the walk with `undefined` rather than being indexed into.
13
+ */
14
+ export function readPhiDotPath(input, path) {
15
+ let current = input;
16
+ for (const segment of path.split(".")) {
17
+ if (!segment)
18
+ continue;
19
+ if (!isPhiRecord(current))
20
+ return undefined;
21
+ current = current[segment];
22
+ }
23
+ return current;
24
+ }