@phis/contracts 0.3.1 → 0.4.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.
@@ -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,94 @@
1
+ /**
2
+ * References from a Phi record to something in another system: `@phis/server/resource-links:v1`.
3
+ *
4
+ * A ticket that is also a GitHub issue, a task that is also a pull request, a thread that is also a
5
+ * conversation somewhere else. The reference is a row of its own rather than a column on the record,
6
+ * because a record may have several at once, across several systems, and a column per system is a
7
+ * schema change per integration.
8
+ *
9
+ * The provider is the Add-on that wrote the link, and it is the whole of the ownership rule: an Add-on
10
+ * reads and writes its own links and sees no others. Two integrations pointing at one ticket therefore
11
+ * do not have to know about each other, and removing one takes its references with it.
12
+ *
13
+ * The link is also what an Add-on's actorless access is made of. A person who can see a record writes
14
+ * the link; from then on the Add-on may read and append to the conversation that record carries, and
15
+ * nothing beyond it. Unlinking ends that -- which is why `Synced` is stated per link rather than
16
+ * assumed: a reference that is only a pointer should not become a standing permission.
17
+ */
18
+ /** What a link points away from. */
19
+ export type PhisResourceLinkSubject = {
20
+ kind: "thread";
21
+ id: number;
22
+ } | {
23
+ kind: "ticket";
24
+ id: number;
25
+ } | {
26
+ kind: "ticketTask";
27
+ id: number;
28
+ };
29
+ /** What is true of a link, as bit flags. */
30
+ export declare const PhisResourceLinkFlag: {
31
+ /**
32
+ * The two sides are kept in step, and this Add-on may reach the subject's thread without an actor.
33
+ *
34
+ * A link without it is a reference someone recorded: it is shown, it is followed, and it grants
35
+ * nothing.
36
+ */
37
+ readonly Synced: number;
38
+ };
39
+ /**
40
+ * One reference, as Core stores it.
41
+ *
42
+ * `type` and `state` are the provider's own vocabulary -- an issue, a pull request, open, merged -- and
43
+ * Core never interprets either. It stores them, indexes the external id against them, and hands them
44
+ * back; what `21` means is the Add-on's business, exactly as a role name is.
45
+ */
46
+ export type PhisResourceLink = {
47
+ id: number;
48
+ subject: PhisResourceLinkSubject;
49
+ type: number;
50
+ externalId: string;
51
+ url: string | null;
52
+ state: number | null;
53
+ flags: number;
54
+ createdAt: string;
55
+ updatedAt: string;
56
+ };
57
+ /**
58
+ * The resource links capability: `@phis/server/resource-links:v1`.
59
+ *
60
+ * Bounded twice: to this Add-on's own links, and to subjects the acting user may reach. Writing needs
61
+ * an actor -- a link is a person saying these two things belong together, and it is what later lets the
62
+ * Add-on act with no person there. A hook or a job may read the links it wrote and may not make new
63
+ * ones.
64
+ */
65
+ export type PhisResourceLinksCapabilityV1 = {
66
+ /** This Add-on's links on one subject, or none where the subject is out of reach. */
67
+ list(subject: PhisResourceLinkSubject): Promise<PhisResourceLink[]>;
68
+ /**
69
+ * Records a reference, or hands back the one that is already there.
70
+ *
71
+ * Asking twice is how an id is learned, the way a declared group is: `(type, externalId)` is unique
72
+ * within this Add-on and this Site, so a second call answers with the first link instead of failing.
73
+ */
74
+ link(input: {
75
+ subject: PhisResourceLinkSubject;
76
+ type: number;
77
+ externalId: string;
78
+ url?: string | null;
79
+ state?: number | null;
80
+ flags?: number;
81
+ }): Promise<PhisResourceLink>;
82
+ /** Moves a link's provider-local state. */
83
+ setState(input: {
84
+ linkId: number;
85
+ state: number | null;
86
+ }): Promise<boolean>;
87
+ /** Removes a link, and with it whatever actorless reach it granted. */
88
+ unlink(linkId: number): Promise<boolean>;
89
+ /** One link of this Add-on by the external id it recorded, or null. */
90
+ findByExternalId(input: {
91
+ type: number;
92
+ externalId: string;
93
+ }): Promise<PhisResourceLink | null>;
94
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * References from a Phi record to something in another system: `@phis/server/resource-links:v1`.
3
+ *
4
+ * A ticket that is also a GitHub issue, a task that is also a pull request, a thread that is also a
5
+ * conversation somewhere else. The reference is a row of its own rather than a column on the record,
6
+ * because a record may have several at once, across several systems, and a column per system is a
7
+ * schema change per integration.
8
+ *
9
+ * The provider is the Add-on that wrote the link, and it is the whole of the ownership rule: an Add-on
10
+ * reads and writes its own links and sees no others. Two integrations pointing at one ticket therefore
11
+ * do not have to know about each other, and removing one takes its references with it.
12
+ *
13
+ * The link is also what an Add-on's actorless access is made of. A person who can see a record writes
14
+ * the link; from then on the Add-on may read and append to the conversation that record carries, and
15
+ * nothing beyond it. Unlinking ends that -- which is why `Synced` is stated per link rather than
16
+ * assumed: a reference that is only a pointer should not become a standing permission.
17
+ */
18
+ /** What is true of a link, as bit flags. */
19
+ export const PhisResourceLinkFlag = {
20
+ /**
21
+ * The two sides are kept in step, and this Add-on may reach the subject's thread without an actor.
22
+ *
23
+ * A link without it is a reference someone recorded: it is shown, it is followed, and it grants
24
+ * nothing.
25
+ */
26
+ Synced: 1 << 0,
27
+ };
@@ -36,3 +36,18 @@ export declare const PHI_GROUP_MEMBERSHIP_LEVELS: readonly [1, number, number, n
36
36
  * Reading downwards is what makes an unknown higher bit fail closed rather than round up.
37
37
  */
38
38
  export declare function readPhiGroupMembershipLevel(value: unknown): PhiGroupMembershipFlagValue | null;
39
+ /**
40
+ * A group a Module needs the Site to have, named rather than created.
41
+ *
42
+ * It travels in a Module's `seed` and is written when the Area carrying that Module is published. The
43
+ * key is the Module's own and never changes: it is what makes a second publish find the group it made
44
+ * the first time instead of making another. `provider_id` is the Module's id and is supplied by the
45
+ * materialization, not by the declaration, so a Module cannot claim a group belonging to another.
46
+ *
47
+ * The name is a starting value. Once the row exists it is the Site's, and administration renames it
48
+ * without the next publish undoing that.
49
+ */
50
+ export type PhisDeclaredSiteGroup = {
51
+ readonly key: string;
52
+ readonly name: string;
53
+ };