@serve.zone/interfaces 32.48.0 → 32.50.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 (39) hide show
  1. package/changelog.md +67 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/appstore/catalog.d.ts +300 -0
  4. package/dist_ts/appstore/catalog.js +544 -0
  5. package/dist_ts/appstore/index.d.ts +2 -0
  6. package/dist_ts/appstore/index.js +3 -1
  7. package/dist_ts/appstore/types.d.ts +9 -0
  8. package/dist_ts/appstore/types.js +1 -1
  9. package/dist_ts/appstore/versionconfig.d.ts +21 -0
  10. package/dist_ts/appstore/versionconfig.js +685 -0
  11. package/dist_ts/data/index.d.ts +1 -0
  12. package/dist_ts/data/index.js +2 -1
  13. package/dist_ts/data/networkapplication.d.ts +84 -0
  14. package/dist_ts/data/networkapplication.js +128 -0
  15. package/dist_ts/data/networkprojection.d.ts +5 -3
  16. package/dist_ts/data/networkprojection.js +6 -4
  17. package/dist_ts/private/keylist.d.ts +5 -0
  18. package/dist_ts/private/keylist.js +2 -0
  19. package/dist_ts/requests/appstore.d.ts +30 -1
  20. package/dist_ts/requests/runtimesession.d.ts +11 -0
  21. package/dist_ts/runtime.appstorecatalog.d.ts +46 -0
  22. package/dist_ts/runtime.appstorecatalog.js +142 -0
  23. package/dist_ts/runtime.d.ts +1 -0
  24. package/dist_ts/runtime.js +2 -1
  25. package/package.json +1 -1
  26. package/readme.md +127 -4
  27. package/ts/00_commitinfo_data.ts +1 -1
  28. package/ts/appstore/catalog.ts +851 -0
  29. package/ts/appstore/index.ts +2 -0
  30. package/ts/appstore/types.ts +9 -0
  31. package/ts/appstore/versionconfig.ts +803 -0
  32. package/ts/data/index.ts +1 -0
  33. package/ts/data/networkapplication.ts +188 -0
  34. package/ts/data/networkprojection.ts +5 -3
  35. package/ts/private/keylist.ts +6 -0
  36. package/ts/requests/appstore.ts +38 -0
  37. package/ts/requests/runtimesession.ts +14 -0
  38. package/ts/runtime.appstorecatalog.ts +195 -0
  39. package/ts/runtime.ts +1 -0
@@ -0,0 +1,851 @@
1
+ /**
2
+ * The App Store catalog contract: the one signed document a consumer loads at runtime.
3
+ *
4
+ * A catalog carries every app, every published version and every version's runtime config, so a
5
+ * consumer that holds one verified catalog holds the whole App Store. What a consumer has to
6
+ * understand is the contract — the shapes below, their digests and signature, and the gates a
7
+ * version declares. Everything else is data: new apps and versions, image digests, changelogs and
8
+ * descriptions arrive with the next catalog revision and need no consumer release.
9
+ *
10
+ * This module is browser-safe: digests use Web Crypto. Ed25519 signing and verification live in
11
+ * the Node-only `./runtime` export (`runtime.appstorecatalog.ts`).
12
+ */
13
+ import type { TSha256Digest } from '../data/immutableimage.js';
14
+ import { getDigestFromPinnedImageReference, isSha256Digest } from '../data/immutableimage.js';
15
+ import {
16
+ canonicalizeStrictJson,
17
+ compareWireTokens,
18
+ createCanonicalJsonSha256Hex,
19
+ } from '../private/canonicaljson.js';
20
+ import { hasExactKeys, readDataElements, readDataProperties } from '../private/dataproperties.js';
21
+ import type { TCompleteKeyList } from '../private/keylist.js';
22
+ import { validateAppStoreVersionConfig } from './versionconfig.js';
23
+ import type {
24
+ IAppStoreVersionConfig,
25
+ IServezoneAppStoreAppInfo,
26
+ TAppStoreUpgradeStrategy,
27
+ } from './types.js';
28
+
29
+ /**
30
+ * The contract identity, digest domains and bounds every catalog producer and consumer shares.
31
+ *
32
+ * `major` changes when a catalog written against it cannot be read by a consumer of the previous
33
+ * major. `minor` changes when catalog-level structure is added; a consumer reads every catalog of
34
+ * its own major whose minor is not newer than its own, and refuses the rest as
35
+ * `contractUnsupported`. New version-config capabilities do not need a minor: they are gated per
36
+ * version through `requiresFeatures` (see `IAppStoreVersionConfig.requiresFeatures`).
37
+ */
38
+ export const appStoreCatalogContract = Object.freeze({
39
+ id: 'serve.zone/appstore-catalog',
40
+ major: 1,
41
+ minor: 0,
42
+ digestAlgorithm: 'sha256',
43
+ signatureAlgorithm: 'ed25519',
44
+ configDigestDomain: 'serve.zone/appstore-catalog/version-config',
45
+ catalogDigestDomain: 'serve.zone/appstore-catalog/catalog',
46
+ signatureDomain: 'serve.zone/appstore-catalog/signature',
47
+ signingKeyIdDomain: 'serve.zone/appstore-catalog/signing-key',
48
+ maximumApps: 1024,
49
+ maximumVersionsPerApp: 1024,
50
+ maximumSignatures: 8,
51
+ maximumTags: 32,
52
+ maximumLinks: 32,
53
+ maximumConfigListEntries: 256,
54
+ maximumRequiredFeatures: 64,
55
+ } as const);
56
+
57
+ /** Which contract a catalog was written against. */
58
+ export interface IAppStoreCatalogContractIdentity {
59
+ id: typeof appStoreCatalogContract.id;
60
+ major: number;
61
+ minor: number;
62
+ }
63
+
64
+ /**
65
+ * A version's runtime config as a catalog carries it.
66
+ *
67
+ * The members that only exist to resolve a live source (`source`, `resolvedSource`,
68
+ * `resolvedImageDigest`), the per-version `upgradeStrategy` and the `appStoreVersion` override
69
+ * are not part of a signed catalog: the catalog is self-contained, the image is digest-pinned,
70
+ * the app states its upgrade strategy once and the version entry names its own version.
71
+ */
72
+ export type TAppStoreCatalogVersionConfig = Omit<
73
+ IAppStoreVersionConfig,
74
+ 'source' | 'resolvedSource' | 'resolvedImageDigest' | 'upgradeStrategy' | 'appStoreVersion'
75
+ >;
76
+
77
+ const versionConfigKeyList = [
78
+ 'image',
79
+ 'port',
80
+ 'containerArgs',
81
+ 'targetPorts',
82
+ 'domains',
83
+ 'publicPortMappings',
84
+ 'envVars',
85
+ 'volumes',
86
+ 'storageClasses',
87
+ 'storageRequests',
88
+ 'publishedPorts',
89
+ 'platformRequirements',
90
+ 'platformOidc',
91
+ 'minOneboxVersion',
92
+ 'minCloudlyVersion',
93
+ 'changelog',
94
+ 'breaking',
95
+ 'requiresManualReview',
96
+ 'migrationRequired',
97
+ 'backupBeforeUpgrade',
98
+ 'requiresFeatures',
99
+ 'healthCheck',
100
+ ] as const;
101
+
102
+ /** Every member a catalog version config may carry; any other member is refused. */
103
+ export const appStoreCatalogVersionConfigKeys: TCompleteKeyList<
104
+ TAppStoreCatalogVersionConfig,
105
+ typeof versionConfigKeyList
106
+ > = Object.freeze(versionConfigKeyList);
107
+
108
+ /** Why and when a publisher withdrew a published version. */
109
+ export interface IAppStoreCatalogWithdrawal {
110
+ /** Canonical UTC timestamp, not later than the catalog's `publishedAt`. */
111
+ at: string;
112
+ /** One line an operator reads, e.g. the advisory that caused it. */
113
+ reason: string;
114
+ }
115
+
116
+ /**
117
+ * One published version. Once published, `app@version` stays in every later catalog with the same
118
+ * `configDigest` (`judgeAppStoreCatalogSuccession`).
119
+ */
120
+ export interface IAppStoreCatalogVersion {
121
+ version: string;
122
+ config: TAppStoreCatalogVersionConfig;
123
+ /** `computeAppStoreCatalogConfigDigest(config)`. */
124
+ configDigest: TSha256Digest;
125
+ /**
126
+ * Present once the publisher withdrew the version. A withdrawn version stays listed with its
127
+ * config, so installed services keep reading it, but it is never an install or upgrade target
128
+ * (`judgeAppStoreCatalogInstallTarget`). A withdrawal is final: every later catalog carries the
129
+ * same record.
130
+ */
131
+ withdrawn?: IAppStoreCatalogWithdrawal;
132
+ }
133
+
134
+ /** The upgrade strategies a self-contained catalog can express; live digest tracking is not one. */
135
+ export type TAppStoreCatalogUpgradeStrategy = Exclude<TAppStoreUpgradeStrategy, 'dockerDigest'>;
136
+
137
+ export interface IAppStoreCatalogApp {
138
+ info: IServezoneAppStoreAppInfo;
139
+ /**
140
+ * Names a listed version that is not withdrawn; under `semver` the highest of them. When every
141
+ * version is withdrawn, the app is withdrawn and `latestVersion` names a listed version (under
142
+ * `semver` the highest).
143
+ */
144
+ latestVersion: string;
145
+ /**
146
+ * Absent means `semver`: every version is `MAJOR.MINOR.PATCH` and `latestVersion` is the
147
+ * highest. `branch` versions are opaque tokens and `latestVersion` is the publisher's choice.
148
+ */
149
+ upgradeStrategy?: TAppStoreCatalogUpgradeStrategy;
150
+ versions: IAppStoreCatalogVersion[];
151
+ }
152
+
153
+ export interface IAppStoreCatalog {
154
+ contract: IAppStoreCatalogContractIdentity;
155
+ /**
156
+ * Strictly increasing across every catalog a publisher signs. A consumer activates a catalog
157
+ * only when its revision is greater than the active one (`judgeAppStoreCatalogSuccession`).
158
+ */
159
+ revision: number;
160
+ /** `Date.prototype.toISOString()` form, UTC. */
161
+ publishedAt: string;
162
+ /** The source commit the catalog was generated from (40 or 64 lower-case hex). */
163
+ sourceRevision: string;
164
+ apps: IAppStoreCatalogApp[];
165
+ }
166
+
167
+ /** An Ed25519 signature over `createAppStoreCatalogSignaturePayload(catalogDigest)`. */
168
+ export interface IAppStoreCatalogSignature {
169
+ /** `computeAppStoreCatalogSigningKeyId(publicKey)` of the signing key. */
170
+ keyId: TSha256Digest;
171
+ /** 64-byte Ed25519 signature, lower-case hex. */
172
+ signature: string;
173
+ }
174
+
175
+ /** What a publisher ships and a consumer stores as its last-known-good catalog. */
176
+ export interface IAppStoreCatalogEnvelope {
177
+ catalog: IAppStoreCatalog;
178
+ /** `computeAppStoreCatalogDigest(catalog)`. */
179
+ catalogDigest: TSha256Digest;
180
+ /** At least one; a consumer needs one valid signature by a key it trusts. */
181
+ signatures: IAppStoreCatalogSignature[];
182
+ }
183
+
184
+ /** A signing key a consumer trusts. Shipped with the consumer release, never fetched. */
185
+ export interface IAppStoreCatalogTrustedKey {
186
+ keyId: TSha256Digest;
187
+ /** Raw 32-byte Ed25519 public key, lower-case hex. */
188
+ publicKey: string;
189
+ }
190
+
191
+ /** Whether a consumer holds a verified catalog. Nothing outside the App Store depends on it. */
192
+ export type TAppStoreCatalogAvailability = TAppStoreCatalogStatus['availability'];
193
+
194
+ export type TAppStoreCatalogRefusalReason =
195
+ /** The catalog names another contract id, another major, or a newer minor. */
196
+ | 'contractUnsupported'
197
+ /** No valid signature by a trusted key, or a signature by a trusted key that does not verify. */
198
+ | 'signatureInvalid'
199
+ /** `catalogDigest` or a `configDigest` does not match its content. */
200
+ | 'digestMismatch'
201
+ /** The envelope or catalog does not satisfy this contract's exact schema. */
202
+ | 'schemaInvalid'
203
+ /** The revision is not greater than the active catalog's revision. */
204
+ | 'revisionRegressed'
205
+ /** A version the active catalog published carries a different config digest. */
206
+ | 'publishedVersionMutated'
207
+ /** A version the active catalog published is no longer listed. */
208
+ | 'publishedVersionRemoved'
209
+ /** A withdrawal the active catalog published is revoked or rewritten. */
210
+ | 'publishedWithdrawalChanged';
211
+
212
+ export interface IAppStoreCatalogRefusal {
213
+ reason: TAppStoreCatalogRefusalReason;
214
+ message: string;
215
+ }
216
+
217
+ /** A refusal as a consumer records it in its status. */
218
+ export interface IAppStoreCatalogRefusalRecord extends IAppStoreCatalogRefusal {
219
+ at: string;
220
+ /** The refused catalog's revision, when it could be read. */
221
+ revision?: number;
222
+ }
223
+
224
+ /** Where the active catalog came from. */
225
+ export type TAppStoreCatalogOrigin = 'registry' | 'operatorImport';
226
+
227
+ export interface IAppStoreCatalogActiveState {
228
+ contract: IAppStoreCatalogContractIdentity;
229
+ revision: number;
230
+ catalogDigest: TSha256Digest;
231
+ publishedAt: string;
232
+ sourceRevision: string;
233
+ /** When this consumer activated it. */
234
+ loadedAt: string;
235
+ /** The trusted key whose signature this consumer verified. */
236
+ keyId: TSha256Digest;
237
+ origin: TAppStoreCatalogOrigin;
238
+ }
239
+
240
+ export type TAppStoreCatalogAttemptOutcome = 'activated' | 'unchanged' | 'refused' | 'failed';
241
+
242
+ export interface IAppStoreCatalogAttempt {
243
+ at: string;
244
+ outcome: TAppStoreCatalogAttemptOutcome;
245
+ /** Why a `refused` or `failed` attempt ended; absent for the other outcomes. */
246
+ message?: string;
247
+ }
248
+
249
+ /** What every catalog status states, whatever its availability. */
250
+ export interface IAppStoreCatalogStatusBase {
251
+ /** The contract this consumer reads. */
252
+ supportedContract: IAppStoreCatalogContractIdentity;
253
+ /** The feature ids this consumer fulfils, in canonical order. */
254
+ supportedFeatureIds: string[];
255
+ lastAttempt?: IAppStoreCatalogAttempt;
256
+ nextAttemptAt?: string;
257
+ /** The most recent catalog this consumer refused, kept while the active one stays in place. */
258
+ refused?: IAppStoreCatalogRefusalRecord;
259
+ }
260
+
261
+ /**
262
+ * A verified catalog is active: the last-known-good one restored at start or the latest one
263
+ * accepted since. A later refused or failed attempt leaves it active and the status `ready`.
264
+ */
265
+ export interface IAppStoreCatalogReadyStatus extends IAppStoreCatalogStatusBase {
266
+ availability: 'ready';
267
+ active: IAppStoreCatalogActiveState;
268
+ reason?: never;
269
+ }
270
+
271
+ /** No verified catalog is active, neither a last-known-good one nor a fetched one. */
272
+ export interface IAppStoreCatalogUnavailableStatus extends IAppStoreCatalogStatusBase {
273
+ availability: 'unavailable';
274
+ /** Why no catalog is active, e.g. the last attempt's failure. */
275
+ reason: string;
276
+ active?: never;
277
+ }
278
+
279
+ /**
280
+ * The App Store domain's state as an operator sees it, discriminated by `availability`.
281
+ *
282
+ * Loading a catalog never gates the boot, readiness or health of anything else. While no verified
283
+ * catalog is active, `availability` is `unavailable`, `reason` says why and only App Store
284
+ * install, upgrade and upgrade-preview actions are refused with that reason.
285
+ */
286
+ export type TAppStoreCatalogStatus = IAppStoreCatalogReadyStatus | IAppStoreCatalogUnavailableStatus;
287
+
288
+ /** The consumer side of `requiresFeatures`. */
289
+ export interface IAppStoreCatalogValidationOptions {
290
+ /** Feature ids this consumer fulfils, e.g. `appStoreStorageFeatureIds` values. */
291
+ supportedFeatureIds: Iterable<string>;
292
+ }
293
+
294
+ const withdrawalReasonMaximumLength = 512;
295
+ const semverPattern = /^(0|[1-9][0-9]{0,14})\.(0|[1-9][0-9]{0,14})\.(0|[1-9][0-9]{0,14})$/;
296
+ const versionTokenPattern = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
297
+ const appIdPattern = /^[a-z0-9][a-z0-9-]{0,62}$/;
298
+ const iconNamePattern = /^[a-z0-9][a-z0-9-]{0,63}$/;
299
+ const tagPattern = /^[a-z0-9][a-z0-9.-]{0,63}$/;
300
+ const linkLabelPattern = /^[A-Za-z][A-Za-z0-9 _-]{0,63}$/;
301
+ const featureIdPattern = /^(?=.{1,128}$)[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/;
302
+ const sourceRevisionPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
303
+ const publicKeyPattern = /^[0-9a-f]{64}$/;
304
+ const signaturePattern = /^[0-9a-f]{128}$/;
305
+ const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
306
+ const multilineControlCharacterPattern = /[\u0000-\u0008\u000b-\u001f\u007f]/;
307
+
308
+ const isNonNegativeSafeInteger = (valueArg: unknown): valueArg is number =>
309
+ typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && valueArg >= 0;
310
+
311
+ const isPort = (valueArg: unknown): valueArg is number =>
312
+ typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && valueArg >= 1 && valueArg <= 65535;
313
+
314
+ const isLine = (valueArg: unknown, maximumLengthArg: number): valueArg is string =>
315
+ typeof valueArg === 'string'
316
+ && valueArg.length > 0
317
+ && valueArg.length <= maximumLengthArg
318
+ && valueArg.trim() === valueArg
319
+ && !controlCharacterPattern.test(valueArg);
320
+
321
+ const isText = (valueArg: unknown, maximumLengthArg: number): valueArg is string =>
322
+ typeof valueArg === 'string'
323
+ && valueArg.length > 0
324
+ && valueArg.length <= maximumLengthArg
325
+ && !multilineControlCharacterPattern.test(valueArg);
326
+
327
+ const isHttpsUrl = (valueArg: unknown): valueArg is string => {
328
+ if (typeof valueArg !== 'string' || valueArg.length > 2048) return false;
329
+ try {
330
+ const url = new URL(valueArg);
331
+ return url.protocol === 'https:' && url.username === '' && url.password === '';
332
+ } catch {
333
+ return false;
334
+ }
335
+ };
336
+
337
+ const isCanonicalTimestamp = (valueArg: unknown): valueArg is string => {
338
+ if (typeof valueArg !== 'string') return false;
339
+ const time = Date.parse(valueArg);
340
+ return Number.isFinite(time) && new Date(time).toISOString() === valueArg;
341
+ };
342
+
343
+ const compareSemver = (leftArg: string, rightArg: string): number => {
344
+ const left = leftArg.split('.').map(Number);
345
+ const right = rightArg.split('.').map(Number);
346
+ for (let index = 0; index < 3; index++) {
347
+ if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1;
348
+ }
349
+ return 0;
350
+ };
351
+
352
+ /** True when a value is a canonical feature-id list: valid ids, strictly increasing. */
353
+ const isCanonicalFeatureIdList = (valueArg: unknown): valueArg is string[] => {
354
+ const entries = readDataElements(valueArg, appStoreCatalogContract.maximumRequiredFeatures);
355
+ if (entries === null) return false;
356
+ let previous: string | null = null;
357
+ for (const entry of entries) {
358
+ if (typeof entry !== 'string' || !featureIdPattern.test(entry)) return false;
359
+ if (previous !== null && compareWireTokens(previous, entry) >= 0) return false;
360
+ previous = entry;
361
+ }
362
+ return true;
363
+ };
364
+
365
+ /**
366
+ * The feature ids a version config requires that the consumer does not fulfil.
367
+ *
368
+ * Empty when the version is installable by this consumer. A malformed `requiresFeatures` is a
369
+ * schema error that `validateAppStoreCatalog` reports; here it yields no missing ids.
370
+ */
371
+ export const getAppStoreCatalogMissingFeatureIds = (
372
+ configArg: unknown,
373
+ supportedFeatureIdsArg: Iterable<string>,
374
+ ): string[] => {
375
+ const config = readDataProperties(configArg);
376
+ if (config === null || !isCanonicalFeatureIdList(config.requiresFeatures)) return [];
377
+ const supported = new Set(supportedFeatureIdsArg);
378
+ return config.requiresFeatures.filter((featureIdArg) => !supported.has(featureIdArg));
379
+ };
380
+
381
+ /**
382
+ * Whether a catalog is written against a contract this consumer reads.
383
+ *
384
+ * `null` when it is. A malformed or absent `contract` member is `schemaInvalid`; a well-formed
385
+ * identity naming another id, another major or a newer minor is `contractUnsupported`.
386
+ */
387
+ export const judgeAppStoreCatalogContract = (catalogArg: unknown): IAppStoreCatalogRefusal | null => {
388
+ const catalog = readDataProperties(catalogArg);
389
+ const identity = catalog === null ? null : readDataProperties(catalog.contract);
390
+ if (identity === null || !hasExactKeys(identity, ['id', 'major', 'minor'])
391
+ || typeof identity.id !== 'string'
392
+ || !isNonNegativeSafeInteger(identity.major) || !isNonNegativeSafeInteger(identity.minor)) {
393
+ return {
394
+ reason: 'schemaInvalid',
395
+ message: 'catalog.contract must be exactly { id, major, minor } with non-negative integers',
396
+ };
397
+ }
398
+ const supported = `${appStoreCatalogContract.id} ${appStoreCatalogContract.major}.${appStoreCatalogContract.minor}`;
399
+ if (identity.id !== appStoreCatalogContract.id
400
+ || identity.major !== appStoreCatalogContract.major
401
+ || identity.minor > appStoreCatalogContract.minor) {
402
+ return {
403
+ reason: 'contractUnsupported',
404
+ message: `catalog contract ${identity.id} ${identity.major}.${identity.minor} is not readable by this consumer (reads ${supported})`,
405
+ };
406
+ }
407
+ return null;
408
+ };
409
+
410
+ const validateHealthCheck = (valueArg: unknown, labelArg: string, errorsArg: string[]): void => {
411
+ const healthCheck = readDataProperties(valueArg);
412
+ if (healthCheck === null || !hasExactKeys(healthCheck, [], ['path', 'port', 'expectedStatus'])) {
413
+ errorsArg.push(`${labelArg} must be an object with only path, port and expectedStatus`);
414
+ return;
415
+ }
416
+ if (healthCheck.path !== undefined
417
+ && (!isLine(healthCheck.path, 2048) || !healthCheck.path.startsWith('/'))) {
418
+ errorsArg.push(`${labelArg}.path must be an absolute request path`);
419
+ }
420
+ if (healthCheck.port !== undefined && !isPort(healthCheck.port)) {
421
+ errorsArg.push(`${labelArg}.port must be a port number`);
422
+ }
423
+ if (healthCheck.expectedStatus !== undefined
424
+ && !(isNonNegativeSafeInteger(healthCheck.expectedStatus)
425
+ && healthCheck.expectedStatus >= 100 && healthCheck.expectedStatus <= 599)) {
426
+ errorsArg.push(`${labelArg}.expectedStatus must be an HTTP status code`);
427
+ }
428
+ };
429
+
430
+ /**
431
+ * The contract's judgement of one version config: the catalog member set, image pinning, the
432
+ * gates and upgrade flags, and every member rule `validateAppStoreVersionConfig` applies.
433
+ */
434
+ const validateCatalogVersionConfig = (
435
+ configArg: Record<string, unknown>,
436
+ labelArg: string,
437
+ errorsArg: string[],
438
+ ): void => {
439
+ if (!hasExactKeys(configArg, ['image'], appStoreCatalogVersionConfigKeys)) {
440
+ const unknown = Object.keys(configArg)
441
+ .filter((keyArg) => !appStoreCatalogVersionConfigKeys.some((knownArg) => knownArg === keyArg));
442
+ errorsArg.push(unknown.length > 0
443
+ ? `${labelArg} has unknown field(s) ${unknown.sort().join(', ')}`
444
+ : `${labelArg}.image is required`);
445
+ return;
446
+ }
447
+ if (typeof configArg.image !== 'string' || getDigestFromPinnedImageReference(configArg.image) === undefined) {
448
+ errorsArg.push(`${labelArg}.image must be a digest-pinned image reference (name@sha256:...)`);
449
+ }
450
+ for (const key of ['minOneboxVersion', 'minCloudlyVersion'] as const) {
451
+ const value = configArg[key];
452
+ if (value !== undefined && (typeof value !== 'string' || !semverPattern.test(value))) {
453
+ errorsArg.push(`${labelArg}.${key} must be MAJOR.MINOR.PATCH`);
454
+ }
455
+ }
456
+ if (configArg.changelog !== undefined && !isText(configArg.changelog, 65536)) {
457
+ errorsArg.push(`${labelArg}.changelog must be bounded text`);
458
+ }
459
+ for (const key of ['breaking', 'requiresManualReview', 'migrationRequired', 'backupBeforeUpgrade'] as const) {
460
+ if (configArg[key] !== undefined && typeof configArg[key] !== 'boolean') {
461
+ errorsArg.push(`${labelArg}.${key} must be a boolean`);
462
+ }
463
+ }
464
+ if (configArg.healthCheck !== undefined) {
465
+ validateHealthCheck(configArg.healthCheck, `${labelArg}.healthCheck`, errorsArg);
466
+ }
467
+ errorsArg.push(...validateAppStoreVersionConfig(configArg, labelArg));
468
+ };
469
+
470
+ const validateAppInfo = (valueArg: unknown, labelArg: string, errorsArg: string[]): string | null => {
471
+ const info = readDataProperties(valueArg);
472
+ if (info === null
473
+ || !hasExactKeys(
474
+ info,
475
+ ['id', 'name', 'description', 'category'],
476
+ ['iconName', 'iconUrl', 'tags', 'maintainer', 'links'],
477
+ )) {
478
+ errorsArg.push(`${labelArg} must be exactly { id, name, description, category } plus optional iconName, iconUrl, tags, maintainer, links`);
479
+ return null;
480
+ }
481
+ const id = typeof info.id === 'string' && appIdPattern.test(info.id) ? info.id : null;
482
+ if (id === null) errorsArg.push(`${labelArg}.id must be a lower-case app id`);
483
+ if (!isLine(info.name, 128)) errorsArg.push(`${labelArg}.name must be a single line`);
484
+ if (!isText(info.description, 4096)) errorsArg.push(`${labelArg}.description must be bounded text`);
485
+ if (!isLine(info.category, 64)) errorsArg.push(`${labelArg}.category must be a single line`);
486
+ if (info.iconName !== undefined && !(typeof info.iconName === 'string' && iconNamePattern.test(info.iconName))) {
487
+ errorsArg.push(`${labelArg}.iconName must be a lower-case icon name`);
488
+ }
489
+ if (info.iconUrl !== undefined && !isHttpsUrl(info.iconUrl)) {
490
+ errorsArg.push(`${labelArg}.iconUrl must be an https URL`);
491
+ }
492
+ if (info.maintainer !== undefined && !isLine(info.maintainer, 128)) {
493
+ errorsArg.push(`${labelArg}.maintainer must be a single line`);
494
+ }
495
+ if (info.tags !== undefined) {
496
+ const tags = readDataElements(info.tags, appStoreCatalogContract.maximumTags);
497
+ if (tags === null
498
+ || tags.some((tagArg) => typeof tagArg !== 'string' || !tagPattern.test(tagArg))
499
+ || new Set(tags).size !== tags.length) {
500
+ errorsArg.push(`${labelArg}.tags must be a bounded list of unique lower-case tags`);
501
+ }
502
+ }
503
+ if (info.links !== undefined) {
504
+ const links = readDataProperties(info.links);
505
+ if (links === null
506
+ || Object.keys(links).length > appStoreCatalogContract.maximumLinks
507
+ || Object.entries(links).some(([labelKeyArg, urlArg]) => !linkLabelPattern.test(labelKeyArg) || !isHttpsUrl(urlArg))) {
508
+ errorsArg.push(`${labelArg}.links must map short labels to https URLs`);
509
+ }
510
+ }
511
+ return id;
512
+ };
513
+
514
+ const validateWithdrawal = (
515
+ valueArg: unknown,
516
+ labelArg: string,
517
+ publishedAtArg: string | null,
518
+ errorsArg: string[],
519
+ ): void => {
520
+ const withdrawal = readDataProperties(valueArg);
521
+ if (withdrawal === null || !hasExactKeys(withdrawal, ['at', 'reason'])) {
522
+ errorsArg.push(`${labelArg} must be exactly { at, reason }`);
523
+ return;
524
+ }
525
+ if (!isCanonicalTimestamp(withdrawal.at)) {
526
+ errorsArg.push(`${labelArg}.at must be a canonical UTC timestamp`);
527
+ } else if (publishedAtArg !== null && Date.parse(withdrawal.at) > Date.parse(publishedAtArg)) {
528
+ errorsArg.push(`${labelArg}.at must not be later than catalog.publishedAt`);
529
+ }
530
+ if (!isLine(withdrawal.reason, withdrawalReasonMaximumLength)) {
531
+ errorsArg.push(`${labelArg}.reason must be a single line`);
532
+ }
533
+ };
534
+
535
+ const validateCatalogApp = (
536
+ valueArg: unknown,
537
+ labelArg: string,
538
+ supportedFeatureIdsArg: ReadonlySet<string>,
539
+ publishedAtArg: string | null,
540
+ errorsArg: string[],
541
+ ): string | null => {
542
+ const app = readDataProperties(valueArg);
543
+ if (app === null || !hasExactKeys(app, ['info', 'latestVersion', 'versions'], ['upgradeStrategy'])) {
544
+ errorsArg.push(`${labelArg} must be exactly { info, latestVersion, versions } plus optional upgradeStrategy`);
545
+ return null;
546
+ }
547
+ const id = validateAppInfo(app.info, `${labelArg}.info`, errorsArg);
548
+ const strategy = app.upgradeStrategy ?? 'semver';
549
+ if (strategy !== 'semver' && strategy !== 'branch') {
550
+ errorsArg.push(`${labelArg}.upgradeStrategy must be semver or branch`);
551
+ return id;
552
+ }
553
+ const versionPattern = strategy === 'semver' ? semverPattern : versionTokenPattern;
554
+ const entries = readDataElements(app.versions, appStoreCatalogContract.maximumVersionsPerApp);
555
+ if (entries === null || entries.length === 0) {
556
+ errorsArg.push(`${labelArg}.versions must be a non-empty bounded array`);
557
+ return id;
558
+ }
559
+ const versions: string[] = [];
560
+ const withdrawnVersions = new Set<string>();
561
+ entries.forEach((entryArg, indexArg) => {
562
+ const versionLabel = `${labelArg}.versions[${indexArg}]`;
563
+ const entry = readDataProperties(entryArg);
564
+ if (entry === null || !hasExactKeys(entry, ['version', 'config', 'configDigest'], ['withdrawn'])) {
565
+ errorsArg.push(`${versionLabel} must be exactly { version, config, configDigest } plus optional withdrawn`);
566
+ return;
567
+ }
568
+ if (typeof entry.version !== 'string' || !versionPattern.test(entry.version)) {
569
+ errorsArg.push(`${versionLabel}.version must be ${strategy === 'semver' ? 'MAJOR.MINOR.PATCH' : 'a version token'}`);
570
+ } else if (versions.includes(entry.version)) {
571
+ errorsArg.push(`${versionLabel}.version '${entry.version}' is listed twice`);
572
+ } else {
573
+ versions.push(entry.version);
574
+ if (entry.withdrawn !== undefined) withdrawnVersions.add(entry.version);
575
+ }
576
+ if (entry.withdrawn !== undefined) {
577
+ validateWithdrawal(entry.withdrawn, `${versionLabel}.withdrawn`, publishedAtArg, errorsArg);
578
+ }
579
+ if (typeof entry.configDigest !== 'string' || !isSha256Digest(entry.configDigest)) {
580
+ errorsArg.push(`${versionLabel}.configDigest must be a canonical sha256 digest`);
581
+ }
582
+ const config = readDataProperties(entry.config);
583
+ if (config === null) {
584
+ errorsArg.push(`${versionLabel}.config must be an object`);
585
+ return;
586
+ }
587
+ if (config.requiresFeatures !== undefined && !isCanonicalFeatureIdList(config.requiresFeatures)) {
588
+ errorsArg.push(`${versionLabel}.config.requiresFeatures must be a strictly increasing list of feature ids`);
589
+ return;
590
+ }
591
+ const missing = isCanonicalFeatureIdList(config.requiresFeatures)
592
+ ? config.requiresFeatures.filter((featureIdArg) => !supportedFeatureIdsArg.has(featureIdArg))
593
+ : [];
594
+ // A version gated on a feature this consumer lacks may use members this consumer does not
595
+ // know. It stays listed, is never installed here, and its config is not interpreted.
596
+ if (missing.length === 0) validateCatalogVersionConfig(config, `${versionLabel}.config`, errorsArg);
597
+ });
598
+ // While any version is installable, latestVersion names one of those; a fully withdrawn app
599
+ // still names a listed version.
600
+ const availableVersions = versions.filter((versionArg) => !withdrawnVersions.has(versionArg));
601
+ const latestCandidates = availableVersions.length > 0 ? availableVersions : versions;
602
+ const latestVersion = typeof app.latestVersion === 'string' ? app.latestVersion : null;
603
+ if (latestVersion === null || !versions.includes(latestVersion)) {
604
+ errorsArg.push(`${labelArg}.latestVersion must name a listed version`);
605
+ } else if (!latestCandidates.includes(latestVersion)) {
606
+ errorsArg.push(`${labelArg}.latestVersion must not name a withdrawn version while another is not withdrawn`);
607
+ } else if (strategy === 'semver'
608
+ && latestCandidates.some((versionArg) => compareSemver(versionArg, latestVersion) > 0)) {
609
+ errorsArg.push(`${labelArg}.latestVersion must be the highest listed version that is not withdrawn`);
610
+ }
611
+ return id;
612
+ };
613
+
614
+ /**
615
+ * Every reason a value is not a catalog this consumer can read; empty when it is.
616
+ *
617
+ * Exact keys at every level: a member this contract does not define is refused, because a
618
+ * consumer that silently ignored it could not be trusted to honour it. The one exception is the
619
+ * config of a version whose `requiresFeatures` names a feature the consumer does not fulfil.
620
+ */
621
+ export const validateAppStoreCatalog = (
622
+ catalogArg: unknown,
623
+ optionsArg: IAppStoreCatalogValidationOptions,
624
+ ): string[] => {
625
+ const catalog = readDataProperties(catalogArg);
626
+ if (catalog === null
627
+ || !hasExactKeys(catalog, ['contract', 'revision', 'publishedAt', 'sourceRevision', 'apps'])) {
628
+ return ['catalog must be exactly { contract, revision, publishedAt, sourceRevision, apps }'];
629
+ }
630
+ const errors: string[] = [];
631
+ const contractRefusal = judgeAppStoreCatalogContract(catalog);
632
+ if (contractRefusal !== null) errors.push(contractRefusal.message);
633
+ if (!isNonNegativeSafeInteger(catalog.revision) || catalog.revision < 1) {
634
+ errors.push('catalog.revision must be a positive integer');
635
+ }
636
+ if (!isCanonicalTimestamp(catalog.publishedAt)) {
637
+ errors.push('catalog.publishedAt must be a canonical UTC timestamp');
638
+ }
639
+ if (typeof catalog.sourceRevision !== 'string' || !sourceRevisionPattern.test(catalog.sourceRevision)) {
640
+ errors.push('catalog.sourceRevision must be a lower-case hex commit id');
641
+ }
642
+ const apps = readDataElements(catalog.apps, appStoreCatalogContract.maximumApps);
643
+ if (apps === null) {
644
+ errors.push('catalog.apps must be a bounded array');
645
+ return errors;
646
+ }
647
+ const supportedFeatureIds = new Set(optionsArg.supportedFeatureIds);
648
+ const publishedAt = isCanonicalTimestamp(catalog.publishedAt) ? catalog.publishedAt : null;
649
+ const ids: string[] = [];
650
+ apps.forEach((appArg, indexArg) => {
651
+ const id = validateCatalogApp(appArg, `catalog.apps[${indexArg}]`, supportedFeatureIds, publishedAt, errors);
652
+ if (id === null) return;
653
+ if (ids.includes(id)) errors.push(`catalog.apps[${indexArg}].info.id '${id}' is listed twice`);
654
+ else ids.push(id);
655
+ });
656
+ return errors;
657
+ };
658
+
659
+ /** The envelope's own members; the catalog inside is judged by `validateAppStoreCatalog`. */
660
+ export const validateAppStoreCatalogEnvelopeShape = (envelopeArg: unknown): string[] => {
661
+ const envelope = readDataProperties(envelopeArg);
662
+ if (envelope === null || !hasExactKeys(envelope, ['catalog', 'catalogDigest', 'signatures'])) {
663
+ return ['envelope must be exactly { catalog, catalogDigest, signatures }'];
664
+ }
665
+ const errors: string[] = [];
666
+ if (readDataProperties(envelope.catalog) === null) errors.push('envelope.catalog must be an object');
667
+ if (typeof envelope.catalogDigest !== 'string' || !isSha256Digest(envelope.catalogDigest)) {
668
+ errors.push('envelope.catalogDigest must be a canonical sha256 digest');
669
+ }
670
+ const signatures = readDataElements(envelope.signatures, appStoreCatalogContract.maximumSignatures);
671
+ if (signatures === null || signatures.length === 0) {
672
+ errors.push(`envelope.signatures must hold 1 to ${appStoreCatalogContract.maximumSignatures} signatures`);
673
+ return errors;
674
+ }
675
+ const keyIds: string[] = [];
676
+ signatures.forEach((signatureArg, indexArg) => {
677
+ const signature = readDataProperties(signatureArg);
678
+ if (signature === null || !hasExactKeys(signature, ['keyId', 'signature'])
679
+ || typeof signature.keyId !== 'string' || !isSha256Digest(signature.keyId)
680
+ || typeof signature.signature !== 'string' || !signaturePattern.test(signature.signature)) {
681
+ errors.push(`envelope.signatures[${indexArg}] must be { keyId: sha256 digest, signature: 128 lower-case hex }`);
682
+ return;
683
+ }
684
+ if (keyIds.includes(signature.keyId)) errors.push(`envelope.signatures[${indexArg}].keyId is listed twice`);
685
+ else keyIds.push(signature.keyId);
686
+ });
687
+ return errors;
688
+ };
689
+
690
+ /** Every reason a value is not an envelope whose catalog this consumer can read. */
691
+ export const validateAppStoreCatalogEnvelope = (
692
+ envelopeArg: unknown,
693
+ optionsArg: IAppStoreCatalogValidationOptions,
694
+ ): string[] => {
695
+ const shapeErrors = validateAppStoreCatalogEnvelopeShape(envelopeArg);
696
+ if (shapeErrors.length > 0) return shapeErrors;
697
+ const envelope = readDataProperties(envelopeArg);
698
+ return envelope === null
699
+ ? ['envelope must be an object']
700
+ : validateAppStoreCatalog(envelope.catalog, optionsArg);
701
+ };
702
+
703
+ /** Narrows a value `validateAppStoreCatalogEnvelope` accepts. */
704
+ export const isAppStoreCatalogEnvelope = (
705
+ envelopeArg: unknown,
706
+ optionsArg: IAppStoreCatalogValidationOptions,
707
+ ): envelopeArg is IAppStoreCatalogEnvelope => validateAppStoreCatalogEnvelope(envelopeArg, optionsArg).length === 0;
708
+
709
+ const failDigestInput = (reasonArg: string): never => {
710
+ throw new Error(`App Store catalog digest input rejected: ${reasonArg}`);
711
+ };
712
+
713
+ /**
714
+ * SHA-256 over the strict canonical JSON of a domain-separated value: object keys in UTF-16 code
715
+ * unit order, no whitespace, safe integers only. A value canonical JSON cannot represent — a
716
+ * fractional number, an accessor, a cycle — throws.
717
+ */
718
+ const computeDomainDigest = async (valueArg: Record<string, unknown>): Promise<TSha256Digest> => {
719
+ const hex = await createCanonicalJsonSha256Hex(
720
+ canonicalizeStrictJson(valueArg, failDigestInput),
721
+ failDigestInput,
722
+ );
723
+ return `sha256:${hex}`;
724
+ };
725
+
726
+ /** The digest a catalog version entry carries as `configDigest`. */
727
+ export const computeAppStoreCatalogConfigDigest = (configArg: unknown): Promise<TSha256Digest> =>
728
+ computeDomainDigest({ domain: appStoreCatalogContract.configDigestDomain, config: configArg });
729
+
730
+ /** The digest an envelope carries as `catalogDigest`. */
731
+ export const computeAppStoreCatalogDigest = (catalogArg: unknown): Promise<TSha256Digest> =>
732
+ computeDomainDigest({ domain: appStoreCatalogContract.catalogDigestDomain, catalog: catalogArg });
733
+
734
+ /** The key id of a raw Ed25519 public key (32 bytes, lower-case hex). */
735
+ export const computeAppStoreCatalogSigningKeyId = async (publicKeyArg: string): Promise<TSha256Digest> => {
736
+ if (!publicKeyPattern.test(publicKeyArg)) {
737
+ throw new Error('App Store catalog signing key must be 32 bytes of lower-case hex');
738
+ }
739
+ return computeDomainDigest({ domain: appStoreCatalogContract.signingKeyIdDomain, publicKey: publicKeyArg });
740
+ };
741
+
742
+ /** The exact bytes a catalog signature covers. */
743
+ export const createAppStoreCatalogSignaturePayload = (catalogDigestArg: TSha256Digest): Uint8Array => {
744
+ if (!isSha256Digest(catalogDigestArg)) {
745
+ throw new Error('App Store catalog digest must be a canonical sha256 digest');
746
+ }
747
+ return new TextEncoder().encode(canonicalizeStrictJson(
748
+ { domain: appStoreCatalogContract.signatureDomain, catalogDigest: catalogDigestArg },
749
+ failDigestInput,
750
+ ));
751
+ };
752
+
753
+ /**
754
+ * The first version entry whose `configDigest` does not match its config, or `null`.
755
+ *
756
+ * Judge the catalog with `validateAppStoreCatalog` first; an entry it would refuse is skipped.
757
+ */
758
+ export const findAppStoreCatalogConfigDigestMismatch = async (
759
+ catalogArg: IAppStoreCatalog,
760
+ ): Promise<string | null> => {
761
+ for (const app of catalogArg.apps) {
762
+ for (const version of app.versions) {
763
+ if (await computeAppStoreCatalogConfigDigest(version.config) !== version.configDigest) {
764
+ return `${app.info.id}@${version.version} configDigest does not match its config`;
765
+ }
766
+ }
767
+ }
768
+ return null;
769
+ };
770
+
771
+ /** Why a listed version is not an install or upgrade target for this consumer. */
772
+ export type TAppStoreCatalogInstallTargetRefusal =
773
+ | { reason: 'versionWithdrawn'; withdrawal: IAppStoreCatalogWithdrawal }
774
+ | { reason: 'featuresUnsupported'; missingFeatureIds: string[] };
775
+
776
+ /**
777
+ * Whether a consumer may install a listed version, or upgrade a service to it; `null` when it may.
778
+ * A withdrawn version and a version requiring a feature the consumer does not fulfil stay listed
779
+ * and are refused here, and nowhere else is a second answer needed.
780
+ */
781
+ export const judgeAppStoreCatalogInstallTarget = (
782
+ versionArg: IAppStoreCatalogVersion,
783
+ supportedFeatureIdsArg: Iterable<string>,
784
+ ): TAppStoreCatalogInstallTargetRefusal | null => {
785
+ if (versionArg.withdrawn !== undefined) return { reason: 'versionWithdrawn', withdrawal: versionArg.withdrawn };
786
+ const missingFeatureIds = getAppStoreCatalogMissingFeatureIds(versionArg.config, supportedFeatureIdsArg);
787
+ return missingFeatureIds.length > 0 ? { reason: 'featuresUnsupported', missingFeatureIds } : null;
788
+ };
789
+
790
+ export type TAppStoreCatalogSuccession =
791
+ | { outcome: 'activate' }
792
+ | { outcome: 'unchanged' }
793
+ | { outcome: 'refused'; refusal: IAppStoreCatalogRefusal };
794
+
795
+ const sameWithdrawal = (
796
+ leftArg: IAppStoreCatalogWithdrawal | undefined,
797
+ rightArg: IAppStoreCatalogWithdrawal | undefined,
798
+ ): boolean => leftArg?.at === rightArg?.at && leftArg?.reason === rightArg?.reason;
799
+
800
+ /**
801
+ * Whether a verified candidate may replace the active verified catalog.
802
+ *
803
+ * The same digest is `unchanged`. Otherwise the revision must be greater, and the candidate must
804
+ * keep the active catalog's full history: every `app@version` it lists stays listed
805
+ * (`publishedVersionRemoved`) with its `configDigest` (`publishedVersionMutated`), and every
806
+ * withdrawal stays exactly as published (`publishedWithdrawalChanged`). A candidate may add apps
807
+ * and versions and withdraw versions; it cannot remove, rewrite or reinstate one.
808
+ */
809
+ export const judgeAppStoreCatalogSuccession = (
810
+ activeArg: IAppStoreCatalogEnvelope | null,
811
+ candidateArg: IAppStoreCatalogEnvelope,
812
+ ): TAppStoreCatalogSuccession => {
813
+ if (activeArg === null) return { outcome: 'activate' };
814
+ if (activeArg.catalogDigest === candidateArg.catalogDigest) return { outcome: 'unchanged' };
815
+ const refuse = (reasonArg: TAppStoreCatalogRefusalReason, messageArg: string): TAppStoreCatalogSuccession =>
816
+ ({ outcome: 'refused', refusal: { reason: reasonArg, message: messageArg } });
817
+ if (candidateArg.catalog.revision <= activeArg.catalog.revision) {
818
+ return refuse(
819
+ 'revisionRegressed',
820
+ `catalog revision ${candidateArg.catalog.revision} is not greater than active revision ${activeArg.catalog.revision}`,
821
+ );
822
+ }
823
+ const candidateVersions = new Map<string, IAppStoreCatalogVersion>();
824
+ for (const app of candidateArg.catalog.apps) {
825
+ for (const version of app.versions) candidateVersions.set(`${app.info.id}@${version.version}`, version);
826
+ }
827
+ for (const app of activeArg.catalog.apps) {
828
+ for (const published of app.versions) {
829
+ const key = `${app.info.id}@${published.version}`;
830
+ const candidate = candidateVersions.get(key);
831
+ if (candidate === undefined) {
832
+ return refuse('publishedVersionRemoved', `${key} was published and is no longer listed`);
833
+ }
834
+ if (candidate.configDigest !== published.configDigest) {
835
+ return refuse(
836
+ 'publishedVersionMutated',
837
+ `${key} was published with ${published.configDigest} and now carries ${candidate.configDigest}`,
838
+ );
839
+ }
840
+ if (published.withdrawn !== undefined && !sameWithdrawal(published.withdrawn, candidate.withdrawn)) {
841
+ return refuse(
842
+ 'publishedWithdrawalChanged',
843
+ candidate.withdrawn === undefined
844
+ ? `${key} was withdrawn and a withdrawal is final`
845
+ : `${key} was withdrawn at ${published.withdrawn.at} and its withdrawal record changed`,
846
+ );
847
+ }
848
+ }
849
+ }
850
+ return { outcome: 'activate' };
851
+ };