@phis/contracts 0.2.0 → 0.3.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.
package/dist/http.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The request headers phis and the site UI must spell the same way.
3
+ *
4
+ * These five names are a wire protocol between processes that deploy independently. phis reads them in
5
+ * its guards and route handlers; the site UI writes them in its proxies, gateways and browser widgets.
6
+ * A disagreement is not a type error and not a failing test -- it is a 403 from `guardApiV1` or a 400
7
+ * from `requireSiteFromExplicitKeyRequest`, at runtime, in whichever direction the mismatch happens to
8
+ * point.
9
+ *
10
+ * They were string literals in both repositories before this file existed, eighty-odd of them, and the
11
+ * one place that had bothered to name a constant named it in only one of the two. Renaming the prefix
12
+ * from `x-phi-` to `x-phis-` is what made that visible; the point of collecting them here is that the
13
+ * next such change is one edit rather than a search.
14
+ */
15
+ /** Carries the public or internal API token on every `/api/v1/*` request. */
16
+ export const PHIS_TOKEN_HEADER = "x-phis-token";
17
+ /**
18
+ * Names the Site a request is scoped to, and is the only thing that does.
19
+ *
20
+ * There is deliberately no host, origin or referer fallback: those are caller-controlled, so a request
21
+ * without this header could otherwise claim any Site whose hostname it spoofed.
22
+ */
23
+ export const PHIS_SITE_KEY_HEADER = "x-phis-site-key";
24
+ /** Names the CMS area a media request acts in, which decides what the actor may reach. */
25
+ export const PHIS_AREA_HEADER = "x-phis-area";
26
+ /**
27
+ * Hand the original request path and query from the site proxy to the server helpers behind it.
28
+ *
29
+ * Both are set and read inside the site runtime and never reach phis; they exist because a Server
30
+ * Component cannot otherwise see the URL the proxy rewrote.
31
+ */
32
+ export const PHIS_REQUEST_PATH_HEADER = "x-phis-request-path";
33
+ export const PHIS_REQUEST_SEARCH_HEADER = "x-phis-request-search";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The language this software is written in.
3
+ *
4
+ * Not a default and not a preference: it is the locale every label, every email template and every
5
+ * global message is authored in, and therefore the one a translation starts from. `TRANSLATIONS.md`
6
+ * fixes it -- global messages in `phis.tr_msg` always use English as their canonical source, while a
7
+ * Site's own messages use the immutable `sourceLocale` chosen when the Site was created.
8
+ *
9
+ * It was called DEFAULT_LOCALE on both sides, which is the name of a different thing: a Site's default
10
+ * locale is configuration, stored per Site in `sites.default_locale`, and an installation may set it to
11
+ * anything. Under one name the two were indistinguishable at the call site, and a reader could not tell
12
+ * whether a line stated a fact about the source text or made an assumption about somebody's Site.
13
+ */
14
+ export declare const PHI_CANONICAL_SOURCE_LOCALE = "en";
package/dist/locale.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The language this software is written in.
3
+ *
4
+ * Not a default and not a preference: it is the locale every label, every email template and every
5
+ * global message is authored in, and therefore the one a translation starts from. `TRANSLATIONS.md`
6
+ * fixes it -- global messages in `phis.tr_msg` always use English as their canonical source, while a
7
+ * Site's own messages use the immutable `sourceLocale` chosen when the Site was created.
8
+ *
9
+ * It was called DEFAULT_LOCALE on both sides, which is the name of a different thing: a Site's default
10
+ * locale is configuration, stored per Site in `sites.default_locale`, and an installation may set it to
11
+ * anything. Under one name the two were indistinguishable at the call site, and a reader could not tell
12
+ * whether a line stated a fact about the source text or made an assumption about somebody's Site.
13
+ */
14
+ export const PHI_CANONICAL_SOURCE_LOCALE = "en";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The log vocabulary phis and the site UI must both know.
3
+ *
4
+ * Two processes write these records and one of them reads all of them: a Site runtime and this server
5
+ * both emit structured lines to journald, and the admin log surface in phis parses them back --
6
+ * filtering by service, splitting by level, and projecting exactly the fields named below. The writer
7
+ * and the reader are therefore two ends of one format, and neither can defer to the other.
8
+ *
9
+ * They did not agree before this file existed. `service` was a closed list in both places, and the two
10
+ * lists drifted: phis admitted "phi-ui" while the UI wrote "phi-shared". Nothing failed. The records
11
+ * simply carried a name the reader never offered as a filter, so the events were there and not
12
+ * findable -- the same failure mode, and the same cure, as the role flags in `access.ts`.
13
+ *
14
+ * Only the vocabulary lives here. How a line is serialized is not shared: phis guards against cycles
15
+ * and BigInt because an Add-on hands it arbitrary values, and that is its own concern.
16
+ */
17
+ /** Which process wrote the record. The reader offers exactly these as filters. */
18
+ export type PhiLogService = "phis" | "ui" | "site" | "cli";
19
+ export type PhiLogLevel = "debug" | "info" | "warn" | "error";
20
+ /** What a logger carries across every record it writes. */
21
+ export type PhiLoggerContext = {
22
+ service: PhiLogService;
23
+ siteKey?: string | null;
24
+ area?: string | null;
25
+ requestId?: string | null;
26
+ userId?: number | string | null;
27
+ actorRole?: string | null;
28
+ pluginKey?: string | null;
29
+ method?: string | null;
30
+ path?: string | null;
31
+ targetType?: string | null;
32
+ targetId?: number | string | null;
33
+ };
34
+ /** What one record adds to that context. */
35
+ export type PhiLogEvent = {
36
+ message?: string;
37
+ status?: number | null;
38
+ durationMs?: number | null;
39
+ error?: unknown;
40
+ meta?: Record<string, unknown>;
41
+ };
42
+ export interface PhiLogger {
43
+ readonly context: Readonly<PhiLoggerContext>;
44
+ child(context: Partial<PhiLoggerContext>): PhiLogger;
45
+ debug(event: string, data?: PhiLogEvent): void;
46
+ info(event: string, data?: PhiLogEvent): void;
47
+ warn(event: string, data?: PhiLogEvent): void;
48
+ error(event: string, data?: PhiLogEvent): void;
49
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The log vocabulary phis and the site UI must both know.
3
+ *
4
+ * Two processes write these records and one of them reads all of them: a Site runtime and this server
5
+ * both emit structured lines to journald, and the admin log surface in phis parses them back --
6
+ * filtering by service, splitting by level, and projecting exactly the fields named below. The writer
7
+ * and the reader are therefore two ends of one format, and neither can defer to the other.
8
+ *
9
+ * They did not agree before this file existed. `service` was a closed list in both places, and the two
10
+ * lists drifted: phis admitted "phi-ui" while the UI wrote "phi-shared". Nothing failed. The records
11
+ * simply carried a name the reader never offered as a filter, so the events were there and not
12
+ * findable -- the same failure mode, and the same cure, as the role flags in `access.ts`.
13
+ *
14
+ * Only the vocabulary lives here. How a line is serialized is not shared: phis guards against cycles
15
+ * and BigInt because an Add-on hands it arbitrary values, and that is its own concern.
16
+ */
17
+ export {};
@@ -23,8 +23,32 @@ export type PhisMediaObjectStreamInput = {
23
23
  body: ReadableStream<Uint8Array>;
24
24
  contentType: string;
25
25
  metadata?: Record<string, string>;
26
+ /**
27
+ * Told that nobody is waiting for this write any more.
28
+ *
29
+ * A body that stops arriving does not, on its own, end the write: the Client is gone or silent while
30
+ * the Provider sits waiting for a byte that will never come. Core aborts the read either way, so an
31
+ * Adapter that ignores this still ends -- with a torn object it must clean up. Honouring it is how an
32
+ * Adapter stops paying for the write, and for a remote Provider that is a request in flight.
33
+ */
34
+ signal?: AbortSignal;
26
35
  };
27
36
  export type PhisMediaObjectHead = {
37
+ /**
38
+ * What this Provider can attest about the object's content, if anything.
39
+ *
40
+ * Deliberately not `etag`. An ETag is an HTTP cache validator and each Provider fills it with what it
41
+ * likes -- a real SHA-256 for Core's own storage, an MD5 for a single-request S3 PUT, and a digest of
42
+ * part digests above that, which hashes no bytes anyone uploaded. Reading one as a content hash is
43
+ * how an MD5 ends up recorded as a SHA-256.
44
+ *
45
+ * Null is the correct answer wherever the Provider cannot say. A missing digest is a fact Core can
46
+ * record; a wrong one invites a comparison that quietly finds nothing.
47
+ */
48
+ checksum?: {
49
+ algorithm: PhisMediaChecksumAlgorithm;
50
+ value: string;
51
+ } | null;
28
52
  storageKey: string;
29
53
  byteSize: number;
30
54
  contentType: string;
@@ -72,13 +96,118 @@ export type PhiMediaUploadPlanInput = {
72
96
  * Core owns its route table, so the address is handed to the adapter rather than assembled by it.
73
97
  */
74
98
  proxyUploadUrl: string;
99
+ /**
100
+ * What the client says it is about to send, and under which algorithm, as lowercase hex.
101
+ *
102
+ * 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.
107
+ *
108
+ * The algorithm travels with the value because the adapter does not get to pick one. It was settled
109
+ * for this Profile when it was probed, and a plan that quietly used another would produce a digest
110
+ * that is correct and yet incomparable with every digest recorded before it.
111
+ *
112
+ * Absent means the digest will be established some other way, which is Core hashing the body as it
113
+ * streams through. An adapter must never invent one.
114
+ */
115
+ checksum?: {
116
+ algorithm: PhisMediaChecksumAlgorithm;
117
+ value: string;
118
+ };
75
119
  };
76
120
  export type PhiMediaUploadCompletionInput = {
77
121
  storageKey: string;
78
122
  /** Whatever the plan's issuer asked the Client to bring back. Core passes it through unread. */
79
123
  completion?: unknown;
80
124
  };
125
+ /**
126
+ * What one endpoint turned out to be able to do, established by trying rather than by asking.
127
+ *
128
+ * A Provider is one adapter over many backends: the S3 adapter speaks to AWS, to MinIO and to Garage,
129
+ * and they do not agree on everything. So the capability belongs to the configured endpoint and not to
130
+ * the code, and no field of the configuration would tell Core which one it is looking at.
131
+ *
132
+ * Establishing it once, when an operator sets a Profile up, also moves a whole class of failure
133
+ * forward: today a wrong endpoint, a missing permission or a bucket that does not exist is discovered
134
+ * by whoever uploads first, and reaches them as their error rather than as a misconfiguration.
135
+ */
136
+ /**
137
+ * The digests this system knows how to record, named so what is stored says what it is.
138
+ *
139
+ * One column holding "a checksum" is how an MD5 ends up in a field called SHA-256 and nobody notices:
140
+ * an `ETag` is whatever a Provider decided to put there, and Providers disagree. Every recorded digest
141
+ * therefore carries the algorithm that produced it.
142
+ */
143
+ export declare const PHIS_MEDIA_CHECKSUM_ALGORITHMS: readonly ["sha256", "sha512", "xxhash128", "crc64nvme", "crc32c", "crc32", "md5"];
144
+ export type PhisMediaChecksumAlgorithm = (typeof PHIS_MEDIA_CHECKSUM_ALGORITHMS)[number];
145
+ /**
146
+ * The digests strong enough to answer "is this the same file", rather than only "did it arrive intact".
147
+ *
148
+ * The distinction is not fussiness. CRC is linear: given a stored object, a second file colliding with
149
+ * it is algebra rather than computation, and MD5 collisions are a download. Both remain perfectly good
150
+ * evidence that a transfer was not corrupted, which is the other thing a checksum is for.
151
+ *
152
+ * What rides on this is duplicate detection, and a collision there is a refused upload rather than a
153
+ * substituted file -- so the cost of trusting a weak digest is that someone who may already write to a
154
+ * Space can stop one file from entering it. Small, but not nothing, and free to avoid.
155
+ */
156
+ export declare const PHIS_MEDIA_IDENTITY_CHECKSUM_ALGORITHMS: readonly PhisMediaChecksumAlgorithm[];
157
+ export declare function isPhisMediaIdentityChecksum(algorithm: string | null | undefined): boolean;
158
+ /**
159
+ * Which of the digests an endpoint verifies a Profile will record.
160
+ *
161
+ * Chosen once, when the Profile is probed, and never per upload: duplicate detection compares digests,
162
+ * and two are only comparable under the same algorithm. Deterministic on purpose -- the same measured
163
+ * endpoint always yields the same answer, so re-probing an unchanged endpoint does not silently start a
164
+ * second cohort of objects that can never be told to match the first.
165
+ *
166
+ * Only identity-grade digests are eligible. A CRC an endpoint also verifies still proves a transfer
167
+ * intact, and Core records it when that is what arrived; it is simply not what a Profile asks for.
168
+ */
169
+ export declare function choosePhisMediaChecksumAlgorithm(probe: {
170
+ verifiedAlgorithms: readonly PhisMediaChecksumAlgorithm[];
171
+ } | null | undefined): PhisMediaChecksumAlgorithm | null;
172
+ export type PhisMediaStorageProbe = {
173
+ /** Whether a small object could be written, read back, and removed again. */
174
+ writable: boolean;
175
+ /**
176
+ * Which digests the endpoint was found to check against the bytes it is given.
177
+ *
178
+ * An algorithm is listed only when a correct digest was accepted *and* a deliberately wrong one was
179
+ * refused. Accepting alone proves nothing: an endpoint that ignores the header accepts everything,
180
+ * and a checksum nobody verifies is a claim by the uploader wearing the shape of proof.
181
+ *
182
+ * What it buys is a direct upload that still yields a trustworthy digest -- the bytes go from the
183
+ * client to the storage without passing through Core, and the storage refuses them if they are not
184
+ * what the client said they were. Which of the listed algorithms is used is settled once, per
185
+ * Profile, rather than per upload: what is recorded has to stay comparable with what was recorded
186
+ * before it, and an algorithm that changes underneath splits one set of objects into two that can
187
+ * never be told to be the same.
188
+ */
189
+ 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
+ /** What did not answer, in an operator's terms. Empty when everything did. */
200
+ findings: string[];
201
+ };
81
202
  export interface PhisMediaStorageAdapter {
203
+ /**
204
+ * Tries what this endpoint can do, and leaves nothing behind.
205
+ *
206
+ * Runs when a Profile is configured, not per upload. It writes a small object under a key of its own
207
+ * and removes it again; a Provider that cannot be probed answers with `writable: false` and a finding
208
+ * rather than throwing, because a Profile that cannot be reached is an answer too.
209
+ */
210
+ probeCapabilities(): Promise<PhisMediaStorageProbe>;
82
211
  putObject(input: PhisMediaObjectInput): Promise<PhisMediaObjectHead>;
83
212
  putObjectStream(input: PhisMediaObjectStreamInput): Promise<PhisMediaObjectHead>;
84
213
  getObject(storageKey: string): Promise<Buffer | null>;
@@ -7,3 +7,59 @@
7
7
  * and answers about objects.
8
8
  */
9
9
  export const PHI_LOCAL_MEDIA_STORAGE_PROVIDER_ID = "@phis/server/storage-local";
10
+ /**
11
+ * What one endpoint turned out to be able to do, established by trying rather than by asking.
12
+ *
13
+ * A Provider is one adapter over many backends: the S3 adapter speaks to AWS, to MinIO and to Garage,
14
+ * and they do not agree on everything. So the capability belongs to the configured endpoint and not to
15
+ * the code, and no field of the configuration would tell Core which one it is looking at.
16
+ *
17
+ * Establishing it once, when an operator sets a Profile up, also moves a whole class of failure
18
+ * forward: today a wrong endpoint, a missing permission or a bucket that does not exist is discovered
19
+ * by whoever uploads first, and reaches them as their error rather than as a misconfiguration.
20
+ */
21
+ /**
22
+ * The digests this system knows how to record, named so what is stored says what it is.
23
+ *
24
+ * One column holding "a checksum" is how an MD5 ends up in a field called SHA-256 and nobody notices:
25
+ * an `ETag` is whatever a Provider decided to put there, and Providers disagree. Every recorded digest
26
+ * therefore carries the algorithm that produced it.
27
+ */
28
+ export const PHIS_MEDIA_CHECKSUM_ALGORITHMS = [
29
+ "sha256", "sha512", "xxhash128", "crc64nvme", "crc32c", "crc32", "md5",
30
+ ];
31
+ /**
32
+ * The digests strong enough to answer "is this the same file", rather than only "did it arrive intact".
33
+ *
34
+ * The distinction is not fussiness. CRC is linear: given a stored object, a second file colliding with
35
+ * it is algebra rather than computation, and MD5 collisions are a download. Both remain perfectly good
36
+ * evidence that a transfer was not corrupted, which is the other thing a checksum is for.
37
+ *
38
+ * What rides on this is duplicate detection, and a collision there is a refused upload rather than a
39
+ * substituted file -- so the cost of trusting a weak digest is that someone who may already write to a
40
+ * Space can stop one file from entering it. Small, but not nothing, and free to avoid.
41
+ */
42
+ export const PHIS_MEDIA_IDENTITY_CHECKSUM_ALGORITHMS = ["sha256", "sha512"];
43
+ export function isPhisMediaIdentityChecksum(algorithm) {
44
+ return PHIS_MEDIA_IDENTITY_CHECKSUM_ALGORITHMS.includes(algorithm);
45
+ }
46
+ /**
47
+ * Which of the digests an endpoint verifies a Profile will record.
48
+ *
49
+ * Chosen once, when the Profile is probed, and never per upload: duplicate detection compares digests,
50
+ * and two are only comparable under the same algorithm. Deterministic on purpose -- the same measured
51
+ * endpoint always yields the same answer, so re-probing an unchanged endpoint does not silently start a
52
+ * second cohort of objects that can never be told to match the first.
53
+ *
54
+ * Only identity-grade digests are eligible. A CRC an endpoint also verifies still proves a transfer
55
+ * intact, and Core records it when that is what arrived; it is simply not what a Profile asks for.
56
+ */
57
+ export function choosePhisMediaChecksumAlgorithm(probe) {
58
+ if (!probe)
59
+ return null;
60
+ for (const candidate of PHIS_MEDIA_IDENTITY_CHECKSUM_ALGORITHMS) {
61
+ if (probe.verifiedAlgorithms.includes(candidate))
62
+ return candidate;
63
+ }
64
+ return null;
65
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * How a Media Space names its kind on the wire.
3
+ *
4
+ * Only the name is shared. phis stores the kind as a smallint and that number is its own business --
5
+ * the site UI never sees it, so putting it here would place a storage detail in a contract.
6
+ *
7
+ * What made this worth writing down is that the translation between the two was incomplete: phis knew
8
+ * four kinds and mapped three, falling back to `"site"` for the rest, while the UI declared its own
9
+ * union of the same three. An Add-on Space therefore reached the administration surface calling itself
10
+ * a Site Space, in the one view that shows Add-on Spaces at all. With the names here, the map on the
11
+ * server side is a total function from its own enum onto this one, and a kind that is added and not
12
+ * mapped stops compiling.
13
+ */
14
+ export declare const PHI_MEDIA_SPACE_KINDS: readonly ["site", "user", "group", "addon"];
15
+ export type PhiMediaSpaceKind = (typeof PHI_MEDIA_SPACE_KINDS)[number];
16
+ /**
17
+ * The kinds a Module may declare a need for.
18
+ *
19
+ * The Site Space always exists and is governed by the Core Media role matrix, so declaring it could
20
+ * only repeat what is already true. An Add-on Space belongs to its Add-on and comes with installing
21
+ * it, rather than being asked for by a Module.
22
+ */
23
+ export declare const PHI_DECLARABLE_MEDIA_SPACE_KINDS: readonly ["user", "group"];
24
+ export type PhiDeclarableMediaSpaceKind = (typeof PHI_DECLARABLE_MEDIA_SPACE_KINDS)[number];
25
+ export declare function isPhiDeclarableMediaSpaceKind(value: unknown): value is PhiDeclarableMediaSpaceKind;
26
+ /**
27
+ * The display properties an Asset may carry, as bits of `media_assets.presentation_flags`.
28
+ *
29
+ * Shared because the number itself travels: the site UI sends a mask as a list filter and an array of
30
+ * these values when metadata is saved, and phis both answers the filter with `flags & mask <> 0` and
31
+ * refuses a value it does not know. Two sides that must agree on a number is what this package is for,
32
+ * and the copy that lived on each side had already drifted -- phis still carried `Private`, `Archived`
33
+ * and `Restricted` long after lifecycle and delivery had become columns of their own.
34
+ *
35
+ * Display only. Access is `delivery_policy` and `lifecycle_status`, never a bit in here.
36
+ *
37
+ * Bits 1 and 2 are free, and before v1 a bit may be given a new meaning rather than kept vacant.
38
+ */
39
+ export declare const PhiMediaAssetFlags: {
40
+ /** Offered as a background. What an Asset is for; where it came from is `meta.source`. */
41
+ readonly Background: number;
42
+ readonly Featured: number;
43
+ readonly Locked: number;
44
+ readonly Mask: number;
45
+ };
46
+ export type PhiMediaAssetFlag = (typeof PhiMediaAssetFlags)[keyof typeof PhiMediaAssetFlags];
47
+ /**
48
+ * The rest of the Media vocabulary both sides speak.
49
+ *
50
+ * Kind names travel as `kind` in the list contract and are stored on the Asset; Folder flags travel in
51
+ * the Folder payload; a variant key is a number in a delivery URL and the name phis generates the
52
+ * rendition under. None of it is one side's private business, and all of it stood twice -- once here in
53
+ * spirit, once in each repository in fact -- until a bit value drifted and showed what that costs.
54
+ *
55
+ * The two resolvers come along because a vocabulary without its total function is only half a contract:
56
+ * a content type must land on the same kind in the browser and on the server, or an upload is filed as
57
+ * one thing and listed as another.
58
+ */
59
+ export declare const PhiMediaFolderFlags: {
60
+ readonly Collapsed: number;
61
+ readonly Hidden: number;
62
+ readonly Locked: number;
63
+ };
64
+ export declare const PhiMediaKind: {
65
+ readonly Image: "image";
66
+ readonly Video: "video";
67
+ readonly Audio: "audio";
68
+ readonly Pdf: "pdf";
69
+ readonly Markdown: "markdown";
70
+ readonly Document: "document";
71
+ readonly Archive: "archive";
72
+ readonly Font: "font";
73
+ readonly Binary: "binary";
74
+ readonly Other: "other";
75
+ };
76
+ export declare const PhiImageAssetVariantKey: {
77
+ readonly Thumbnail: 0;
78
+ readonly Preview: 1;
79
+ readonly Banner: 2;
80
+ readonly Header: 3;
81
+ readonly Card: 4;
82
+ readonly Hero: 5;
83
+ readonly Avatar: 6;
84
+ readonly Logo: 7;
85
+ readonly Landscape: 8;
86
+ readonly Portrait: 9;
87
+ };
88
+ export declare const PhiImageAssetVariantKeyName: {
89
+ readonly 0: "thumbnail";
90
+ readonly 1: "preview";
91
+ readonly 2: "banner";
92
+ readonly 3: "header";
93
+ readonly 4: "card";
94
+ readonly 5: "hero";
95
+ readonly 6: "avatar";
96
+ readonly 7: "logo";
97
+ readonly 8: "landscape";
98
+ readonly 9: "portrait";
99
+ };
100
+ export type PhiImageAssetVariantSpec = {
101
+ width: number;
102
+ height: number;
103
+ fit: "cover" | "contain";
104
+ quality: number;
105
+ format: "webp";
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;
110
+ export declare function resolvePhiImageAssetVariantKeyName(key: number | null | undefined): string | null;
111
+ export declare function resolvePhiImageAssetVariantSpec(key: number | null | undefined): PhiImageAssetVariantSpec | null;
112
+ /**
113
+ * The two closed vocabularies that decide whether an Asset may be delivered at all.
114
+ *
115
+ * The numbers themselves reach the site UI: an Asset's detail payload carries `lifecycleStatus` and
116
+ * `deliveryPolicy` as they stand in the row, and the UI decides from them whether `next/image` may be
117
+ * pointed at the original. phis evaluates `delivery_policy` before any physical lookup, so a value that
118
+ * means one thing on one side and another on the other would be a visibility bug, not a display bug.
119
+ *
120
+ * Unlike the Space kind next to them in phis, which stays a storage detail because the wire carries its
121
+ * name instead of its number.
122
+ */
123
+ export declare const PhiMediaLifecycleStatus: {
124
+ readonly Pending: 1;
125
+ readonly Ready: 2;
126
+ readonly Archived: 3;
127
+ readonly Quarantined: 4;
128
+ readonly Failed: 5;
129
+ };
130
+ export declare const PhiMediaDeliveryPolicy: {
131
+ readonly Public: 1;
132
+ readonly Authenticated: 2;
133
+ readonly User: 3;
134
+ readonly Group: 4;
135
+ readonly Internal: 5;
136
+ };
137
+ /**
138
+ * What a typeface says about its own proportions.
139
+ *
140
+ * Shared because the numbers cross: phis reads them out of the uploaded file once and keeps them on the
141
+ * Asset, and the site UI turns them into the `size-adjust` and `*-override` descriptors of a fallback
142
+ * `@font-face`. Without that face a browser sets the page in a local substitute and reflows when the
143
+ * real file lands, which is the one thing a viewer actually sees.
144
+ *
145
+ * Everything is in font units on the `unitsPerEm` grid, so a reader divides rather than assumes 1000.
146
+ */
147
+ export type PhiFontMetrics = {
148
+ /** The container the numbers were read from. */
149
+ format: "sfnt" | "woff" | "woff2";
150
+ familyName: string | null;
151
+ unitsPerEm: number;
152
+ ascent: number;
153
+ descent: number;
154
+ lineGap: number;
155
+ capHeight: number | null;
156
+ xHeight: number | null;
157
+ /**
158
+ * How wide running text sets, as an advance width.
159
+ *
160
+ * `glyphs` is the frequency-weighted advance of the characters prose is made of. `os2` is the font's
161
+ * own `xAvgCharWidth`, which averages every glyph in the file and is therefore wrong for a Latin page
162
+ * in a family that also carries Greek -- the source travels so a reader can tell the two apart.
163
+ */
164
+ xWidthAvg: number | null;
165
+ xWidthAvgSource: "glyphs" | "os2" | null;
166
+ /**
167
+ * What the file classifies itself as, and `null` where it classifies itself as nothing.
168
+ *
169
+ * Plenty of good fonts leave both PANOSE and the `OS/2` family class empty, so the absence is a fact
170
+ * about the file rather than a reason to guess: whoever picks the local substitute decides instead.
171
+ */
172
+ category: "serif" | "sans-serif" | "monospace" | null;
173
+ /**
174
+ * Which unicode cuts the typeface has characters in, read out of its `cmap` at upload.
175
+ *
176
+ * Absent on a font uploaded before cuts existed; such a font is delivered whole.
177
+ */
178
+ coverage?: PhiFontCoverage | null;
179
+ };
180
+ /**
181
+ * The cuts a typeface can be delivered in.
182
+ *
183
+ * The number stands in a delivery URL, so it is shared; the named cuts follow the unicode ranges Google
184
+ * Fonts divides its families along, so a face declared here splits where fonts are commonly split.
185
+ * `Rest` is not a range but everything a typeface has outside all of them -- its own characters, listed
186
+ * per font in `PhiFontCoverage.restRanges`, so a page never loses a glyph and never fetches a cut for a
187
+ * character the font does not have.
188
+ */
189
+ export declare const PhiFontSubsetKey: {
190
+ readonly Latin: 0;
191
+ readonly LatinExt: 1;
192
+ readonly Cyrillic: 2;
193
+ readonly CyrillicExt: 3;
194
+ readonly Greek: 4;
195
+ readonly GreekExt: 5;
196
+ readonly Vietnamese: 6;
197
+ readonly Math: 7;
198
+ readonly Symbols: 8;
199
+ readonly Rest: 15;
200
+ };
201
+ export type PhiFontSubsetKeyValue = (typeof PhiFontSubsetKey)[keyof typeof PhiFontSubsetKey];
202
+ /** An inclusive codepoint range. */
203
+ export type PhiUnicodeRange = readonly [start: number, end: number];
204
+ /**
205
+ * Raised whenever the ranges below or the way a cut is produced changes, so every stored cut is made
206
+ * again rather than served under a rule that no longer describes it.
207
+ */
208
+ export declare const PHI_FONT_SUBSET_VERSION = 1;
209
+ /** The named cuts, as Google Fonts' CSS2 API states them for a Latin, Cyrillic and Greek family. */
210
+ export declare const PHI_FONT_SUBSET_RANGES: Readonly<Record<Exclude<PhiFontSubsetKeyValue, 15>, readonly PhiUnicodeRange[]>>;
211
+ /** What a typeface has characters in: the named cuts, and its own characters outside all of them. */
212
+ export type PhiFontCoverage = {
213
+ subsets: PhiFontSubsetKeyValue[];
214
+ restRanges: PhiUnicodeRange[];
215
+ };
216
+ export declare function isPhiCodepointInRanges(codepoint: number, ranges: readonly PhiUnicodeRange[]): boolean;
217
+ /** A `unicode-range` descriptor value. */
218
+ export declare function formatPhiUnicodeRange(ranges: readonly PhiUnicodeRange[]): string;
219
+ /** The ranges a cut covers for one typeface: the shared list for a named cut, the font's own for `Rest`. */
220
+ export declare function resolvePhiFontSubsetRanges(key: PhiFontSubsetKeyValue, coverage: PhiFontCoverage): readonly PhiUnicodeRange[];
221
+ export declare function isPhiFontSubsetKey(value: unknown): value is PhiFontSubsetKeyValue;
222
+ /**
223
+ * The cuts to preload for a page in `locale`, `Latin` first, restricted to what the font covers.
224
+ *
225
+ * `locale` is a BCP 47 tag; its script subtag overrides the language's usual one, so `sr-Latn` preloads
226
+ * `LatinExt` where `sr` preloads `Cyrillic`.
227
+ */
228
+ export declare function resolvePhiFontPreloadSubsetKeys(locale: string | null | undefined, coverage: PhiFontCoverage): PhiFontSubsetKeyValue[];