@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.
Files changed (51) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +175 -67
  3. package/dist/access.d.ts +63 -0
  4. package/dist/access.js +78 -0
  5. package/dist/addon-index.d.ts +5 -0
  6. package/dist/addon-index.js +5 -0
  7. package/dist/addon.d.ts +181 -57
  8. package/dist/addon.js +2 -2
  9. package/dist/assets.d.ts +165 -0
  10. package/dist/assets.js +1 -0
  11. package/dist/capabilities.d.ts +175 -33
  12. package/dist/capabilities.js +2 -2
  13. package/dist/catalog.d.ts +104 -0
  14. package/dist/catalog.js +116 -0
  15. package/dist/cms.d.ts +120 -0
  16. package/dist/cms.js +270 -0
  17. package/dist/core.d.ts +52 -6
  18. package/dist/core.js +55 -8
  19. package/dist/http.d.ts +33 -0
  20. package/dist/http.js +33 -0
  21. package/dist/locale.d.ts +14 -0
  22. package/dist/locale.js +14 -0
  23. package/dist/logging.d.ts +49 -0
  24. package/dist/logging.js +17 -0
  25. package/dist/media-storage.d.ts +371 -0
  26. package/dist/media-storage.js +84 -0
  27. package/dist/media.d.ts +272 -0
  28. package/dist/media.js +421 -0
  29. package/dist/queries.d.ts +280 -48
  30. package/dist/queries.js +10 -1
  31. package/dist/resource-links.d.ts +94 -0
  32. package/dist/resource-links.js +27 -0
  33. package/dist/schema.d.ts +160 -26
  34. package/dist/schema.js +11 -2
  35. package/dist/server-capabilities.d.ts +37 -0
  36. package/dist/server-capabilities.js +17 -0
  37. package/dist/signals.d.ts +58 -0
  38. package/dist/signals.js +118 -0
  39. package/dist/site-groups.d.ts +53 -0
  40. package/dist/site-groups.js +54 -0
  41. package/dist/support.d.ts +390 -0
  42. package/dist/support.js +148 -0
  43. package/dist/threads.d.ts +363 -0
  44. package/dist/threads.js +146 -0
  45. package/dist/translation.d.ts +125 -0
  46. package/dist/translation.js +15 -0
  47. package/dist/user-state.d.ts +132 -0
  48. package/dist/user-state.js +92 -0
  49. package/package.json +64 -4
  50. package/dist/index.d.ts +0 -6
  51. 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
+ }
@@ -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,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
  *
@@ -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
+ }