@phis/contracts 0.2.0 → 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.
- package/NOTICE +1 -1
- package/README.md +175 -67
- package/dist/access.d.ts +63 -0
- package/dist/access.js +78 -0
- package/dist/addon-index.d.ts +5 -0
- package/dist/addon-index.js +5 -0
- package/dist/addon.d.ts +181 -57
- package/dist/addon.js +2 -2
- package/dist/assets.d.ts +165 -0
- package/dist/assets.js +1 -0
- package/dist/capabilities.d.ts +175 -33
- package/dist/capabilities.js +2 -2
- package/dist/catalog.d.ts +104 -0
- package/dist/catalog.js +116 -0
- package/dist/cms.d.ts +120 -0
- package/dist/cms.js +270 -0
- package/dist/core.d.ts +52 -6
- package/dist/core.js +55 -8
- package/dist/http.d.ts +33 -0
- package/dist/http.js +33 -0
- package/dist/locale.d.ts +14 -0
- package/dist/locale.js +14 -0
- package/dist/logging.d.ts +49 -0
- package/dist/logging.js +17 -0
- package/dist/media-storage.d.ts +371 -0
- package/dist/media-storage.js +84 -0
- package/dist/media.d.ts +272 -0
- package/dist/media.js +421 -0
- package/dist/queries.d.ts +280 -48
- package/dist/queries.js +10 -1
- package/dist/resource-links.d.ts +94 -0
- package/dist/resource-links.js +27 -0
- package/dist/schema.d.ts +160 -26
- package/dist/schema.js +11 -2
- package/dist/server-capabilities.d.ts +37 -0
- package/dist/server-capabilities.js +17 -0
- package/dist/signals.d.ts +58 -0
- package/dist/signals.js +118 -0
- package/dist/site-groups.d.ts +53 -0
- package/dist/site-groups.js +54 -0
- package/dist/support.d.ts +390 -0
- package/dist/support.js +148 -0
- package/dist/threads.d.ts +363 -0
- package/dist/threads.js +146 -0
- package/dist/translation.d.ts +125 -0
- package/dist/translation.js +15 -0
- package/dist/user-state.d.ts +132 -0
- package/dist/user-state.js +92 -0
- package/package.json +64 -4
- package/dist/index.d.ts +0 -6
- package/dist/index.js +0 -6
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
|
+
}
|
package/dist/logging.js
ADDED
|
@@ -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 {};
|
package/dist/media-storage.d.ts
CHANGED
|
@@ -23,8 +23,46 @@ 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;
|
|
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;
|
|
26
49
|
};
|
|
27
50
|
export type PhisMediaObjectHead = {
|
|
51
|
+
/**
|
|
52
|
+
* What this Provider can attest about the object's content, if anything.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately not `etag`. An ETag is an HTTP cache validator and each Provider fills it with what it
|
|
55
|
+
* likes -- a real SHA-256 for Core's own storage, an MD5 for a single-request S3 PUT, and a digest of
|
|
56
|
+
* part digests above that, which hashes no bytes anyone uploaded. Reading one as a content hash is
|
|
57
|
+
* how an MD5 ends up recorded as a SHA-256.
|
|
58
|
+
*
|
|
59
|
+
* Null is the correct answer wherever the Provider cannot say. A missing digest is a fact Core can
|
|
60
|
+
* record; a wrong one invites a comparison that quietly finds nothing.
|
|
61
|
+
*/
|
|
62
|
+
checksum?: {
|
|
63
|
+
algorithm: PhisMediaChecksumAlgorithm;
|
|
64
|
+
value: string;
|
|
65
|
+
} | null;
|
|
28
66
|
storageKey: string;
|
|
29
67
|
byteSize: number;
|
|
30
68
|
contentType: string;
|
|
@@ -58,6 +96,34 @@ export type PhiMediaUploadPlan = {
|
|
|
58
96
|
method: "PUT";
|
|
59
97
|
headers: Record<string, string>;
|
|
60
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;
|
|
61
127
|
};
|
|
62
128
|
export type PhiMediaUploadPlanKindKey = PhiMediaUploadPlan["kind"];
|
|
63
129
|
export type PhiMediaUploadPlanInput = {
|
|
@@ -72,16 +138,279 @@ export type PhiMediaUploadPlanInput = {
|
|
|
72
138
|
* Core owns its route table, so the address is handed to the adapter rather than assembled by it.
|
|
73
139
|
*/
|
|
74
140
|
proxyUploadUrl: string;
|
|
141
|
+
/**
|
|
142
|
+
* What the client says it is about to send, and under which algorithm, as lowercase hex.
|
|
143
|
+
*
|
|
144
|
+
* Present only for a Profile whose probe found the endpoint verifies that algorithm: an adapter that
|
|
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.
|
|
152
|
+
*
|
|
153
|
+
* The algorithm travels with the value because the adapter does not get to pick one. It was settled
|
|
154
|
+
* for this Profile when it was probed, and a plan that quietly used another would produce a digest
|
|
155
|
+
* that is correct and yet incomparable with every digest recorded before it.
|
|
156
|
+
*
|
|
157
|
+
* Absent means the digest will be established some other way, which is Core hashing the body as it
|
|
158
|
+
* streams through. An adapter must never invent one.
|
|
159
|
+
*/
|
|
160
|
+
checksum?: {
|
|
161
|
+
algorithm: PhisMediaChecksumAlgorithm;
|
|
162
|
+
value: string;
|
|
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;
|
|
75
271
|
};
|
|
76
272
|
export type PhiMediaUploadCompletionInput = {
|
|
77
273
|
storageKey: string;
|
|
78
274
|
/** Whatever the plan's issuer asked the Client to bring back. Core passes it through unread. */
|
|
79
275
|
completion?: unknown;
|
|
80
276
|
};
|
|
277
|
+
/**
|
|
278
|
+
* What one endpoint turned out to be able to do, established by trying rather than by asking.
|
|
279
|
+
*
|
|
280
|
+
* A Provider is one adapter over many backends: the S3 adapter speaks to AWS, to MinIO and to Garage,
|
|
281
|
+
* and they do not agree on everything. So the capability belongs to the configured endpoint and not to
|
|
282
|
+
* the code, and no field of the configuration would tell Core which one it is looking at.
|
|
283
|
+
*
|
|
284
|
+
* Establishing it once, when an operator sets a Profile up, also moves a whole class of failure
|
|
285
|
+
* forward: today a wrong endpoint, a missing permission or a bucket that does not exist is discovered
|
|
286
|
+
* by whoever uploads first, and reaches them as their error rather than as a misconfiguration.
|
|
287
|
+
*/
|
|
288
|
+
/**
|
|
289
|
+
* The digests this system knows how to record, named so what is stored says what it is.
|
|
290
|
+
*
|
|
291
|
+
* One column holding "a checksum" is how an MD5 ends up in a field called SHA-256 and nobody notices:
|
|
292
|
+
* an `ETag` is whatever a Provider decided to put there, and Providers disagree. Every recorded digest
|
|
293
|
+
* therefore carries the algorithm that produced it.
|
|
294
|
+
*/
|
|
295
|
+
export declare const PHIS_MEDIA_CHECKSUM_ALGORITHMS: readonly ["sha256", "sha512", "sha256-composite", "xxhash128", "crc64nvme", "crc32c", "crc32", "md5"];
|
|
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";
|
|
298
|
+
/**
|
|
299
|
+
* The digests strong enough to answer "is this the same file", rather than only "did it arrive intact".
|
|
300
|
+
*
|
|
301
|
+
* The distinction is not fussiness. CRC is linear: given a stored object, a second file colliding with
|
|
302
|
+
* it is algebra rather than computation, and MD5 collisions are a download. Both remain perfectly good
|
|
303
|
+
* evidence that a transfer was not corrupted, which is the other thing a checksum is for.
|
|
304
|
+
*
|
|
305
|
+
* What rides on this is duplicate detection, and a collision there is a refused upload rather than a
|
|
306
|
+
* substituted file -- so the cost of trusting a weak digest is that someone who may already write to a
|
|
307
|
+
* Space can stop one file from entering it. Small, but not nothing, and free to avoid.
|
|
308
|
+
*/
|
|
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
|
+
*/
|
|
323
|
+
export declare function isPhisMediaIdentityChecksum(algorithm: string | null | undefined): boolean;
|
|
324
|
+
/**
|
|
325
|
+
* Which of the digests an endpoint verifies a Profile will record.
|
|
326
|
+
*
|
|
327
|
+
* Chosen once, when the Profile is probed, and never per upload: duplicate detection compares digests,
|
|
328
|
+
* and two are only comparable under the same algorithm. Deterministic on purpose -- the same measured
|
|
329
|
+
* endpoint always yields the same answer, so re-probing an unchanged endpoint does not silently start a
|
|
330
|
+
* second cohort of objects that can never be told to match the first.
|
|
331
|
+
*
|
|
332
|
+
* Only identity-grade digests are eligible. A CRC an endpoint also verifies still proves a transfer
|
|
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.
|
|
338
|
+
*/
|
|
339
|
+
export declare function choosePhisMediaChecksumAlgorithm(probe: {
|
|
340
|
+
verifiedAlgorithms: readonly PhisMediaChecksumAlgorithm[];
|
|
341
|
+
} | null | undefined): PhisMediaChecksumAlgorithm | null;
|
|
342
|
+
export type PhisMediaStorageProbe = {
|
|
343
|
+
/** Whether a small object could be written, read back, and removed again. */
|
|
344
|
+
writable: boolean;
|
|
345
|
+
/**
|
|
346
|
+
* Which digests the endpoint was found to check against the bytes it is given.
|
|
347
|
+
*
|
|
348
|
+
* An algorithm is listed only when a correct digest was accepted *and* a deliberately wrong one was
|
|
349
|
+
* refused. Accepting alone proves nothing: an endpoint that ignores the header accepts everything,
|
|
350
|
+
* and a checksum nobody verifies is a claim by the uploader wearing the shape of proof.
|
|
351
|
+
*
|
|
352
|
+
* What it buys is a direct upload that still yields a trustworthy digest -- the bytes go from the
|
|
353
|
+
* client to the storage without passing through Core, and the storage refuses them if they are not
|
|
354
|
+
* what the client said they were. Which of the listed algorithms is used is settled once, per
|
|
355
|
+
* Profile, rather than per upload: what is recorded has to stay comparable with what was recorded
|
|
356
|
+
* before it, and an algorithm that changes underneath splits one set of objects into two that can
|
|
357
|
+
* never be told to be the same.
|
|
358
|
+
*/
|
|
359
|
+
verifiedAlgorithms: PhisMediaChecksumAlgorithm[];
|
|
360
|
+
/** What did not answer, in an operator's terms. Empty when everything did. */
|
|
361
|
+
findings: string[];
|
|
362
|
+
};
|
|
81
363
|
export interface PhisMediaStorageAdapter {
|
|
364
|
+
/**
|
|
365
|
+
* Tries what this endpoint can do, and leaves nothing behind.
|
|
366
|
+
*
|
|
367
|
+
* Runs when a Profile is configured, not per upload. It writes a small object under a key of its own
|
|
368
|
+
* and removes it again; a Provider that cannot be probed answers with `writable: false` and a finding
|
|
369
|
+
* rather than throwing, because a Profile that cannot be reached is an answer too.
|
|
370
|
+
*/
|
|
371
|
+
probeCapabilities(): Promise<PhisMediaStorageProbe>;
|
|
82
372
|
putObject(input: PhisMediaObjectInput): Promise<PhisMediaObjectHead>;
|
|
83
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
|
+
*/
|
|
84
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>;
|
|
85
414
|
/**
|
|
86
415
|
* The first bytes of an object, for deciding what it actually is without moving it.
|
|
87
416
|
*
|
|
@@ -108,6 +437,29 @@ export interface PhisMediaStorageAdapter {
|
|
|
108
437
|
listPrefix(prefix: string): Promise<PhisMediaObjectHead[]>;
|
|
109
438
|
/** States how the Client is to deliver this body. */
|
|
110
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>;
|
|
111
463
|
/**
|
|
112
464
|
* Settles the staged object once the Client says it is done, and answers what actually landed.
|
|
113
465
|
*
|
|
@@ -127,6 +479,25 @@ export interface PhisMediaStorageAdapter {
|
|
|
127
479
|
* to check, because a Provider that cannot say no to a body it never receives has nothing to say.
|
|
128
480
|
*/
|
|
129
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>;
|
|
130
501
|
/**
|
|
131
502
|
* What makes two Storage Profiles the same physical storage.
|
|
132
503
|
*
|
package/dist/media-storage.js
CHANGED
|
@@ -7,3 +7,87 @@
|
|
|
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", "sha256-composite", "xxhash128", "crc64nvme", "crc32c", "crc32", "md5",
|
|
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";
|
|
40
|
+
/**
|
|
41
|
+
* The digests strong enough to answer "is this the same file", rather than only "did it arrive intact".
|
|
42
|
+
*
|
|
43
|
+
* The distinction is not fussiness. CRC is linear: given a stored object, a second file colliding with
|
|
44
|
+
* it is algebra rather than computation, and MD5 collisions are a download. Both remain perfectly good
|
|
45
|
+
* evidence that a transfer was not corrupted, which is the other thing a checksum is for.
|
|
46
|
+
*
|
|
47
|
+
* What rides on this is duplicate detection, and a collision there is a refused upload rather than a
|
|
48
|
+
* substituted file -- so the cost of trusting a weak digest is that someone who may already write to a
|
|
49
|
+
* Space can stop one file from entering it. Small, but not nothing, and free to avoid.
|
|
50
|
+
*/
|
|
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
|
+
*/
|
|
65
|
+
export function isPhisMediaIdentityChecksum(algorithm) {
|
|
66
|
+
return PHIS_MEDIA_IDENTITY_CHECKSUM_ALGORITHMS.includes(algorithm);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Which of the digests an endpoint verifies a Profile will record.
|
|
70
|
+
*
|
|
71
|
+
* Chosen once, when the Profile is probed, and never per upload: duplicate detection compares digests,
|
|
72
|
+
* and two are only comparable under the same algorithm. Deterministic on purpose -- the same measured
|
|
73
|
+
* endpoint always yields the same answer, so re-probing an unchanged endpoint does not silently start a
|
|
74
|
+
* second cohort of objects that can never be told to match the first.
|
|
75
|
+
*
|
|
76
|
+
* Only identity-grade digests are eligible. A CRC an endpoint also verifies still proves a transfer
|
|
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.
|
|
82
|
+
*/
|
|
83
|
+
export function choosePhisMediaChecksumAlgorithm(probe) {
|
|
84
|
+
if (!probe)
|
|
85
|
+
return null;
|
|
86
|
+
for (const candidate of PHIS_MEDIA_IDENTITY_CHECKSUM_ALGORITHMS) {
|
|
87
|
+
if (candidate === "sha256-composite")
|
|
88
|
+
continue;
|
|
89
|
+
if (probe.verifiedAlgorithms.includes(candidate))
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|