@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.
- package/changelog.md +67 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/appstore/catalog.d.ts +300 -0
- package/dist_ts/appstore/catalog.js +544 -0
- package/dist_ts/appstore/index.d.ts +2 -0
- package/dist_ts/appstore/index.js +3 -1
- package/dist_ts/appstore/types.d.ts +9 -0
- package/dist_ts/appstore/types.js +1 -1
- package/dist_ts/appstore/versionconfig.d.ts +21 -0
- package/dist_ts/appstore/versionconfig.js +685 -0
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/data/networkapplication.d.ts +84 -0
- package/dist_ts/data/networkapplication.js +128 -0
- package/dist_ts/data/networkprojection.d.ts +5 -3
- package/dist_ts/data/networkprojection.js +6 -4
- package/dist_ts/private/keylist.d.ts +5 -0
- package/dist_ts/private/keylist.js +2 -0
- package/dist_ts/requests/appstore.d.ts +30 -1
- package/dist_ts/requests/runtimesession.d.ts +11 -0
- package/dist_ts/runtime.appstorecatalog.d.ts +46 -0
- package/dist_ts/runtime.appstorecatalog.js +142 -0
- package/dist_ts/runtime.d.ts +1 -0
- package/dist_ts/runtime.js +2 -1
- package/package.json +1 -1
- package/readme.md +127 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/appstore/catalog.ts +851 -0
- package/ts/appstore/index.ts +2 -0
- package/ts/appstore/types.ts +9 -0
- package/ts/appstore/versionconfig.ts +803 -0
- package/ts/data/index.ts +1 -0
- package/ts/data/networkapplication.ts +188 -0
- package/ts/data/networkprojection.ts +5 -3
- package/ts/private/keylist.ts +6 -0
- package/ts/requests/appstore.ts +38 -0
- package/ts/requests/runtimesession.ts +14 -0
- package/ts/runtime.appstorecatalog.ts +195 -0
- 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
|
+
};
|