@serve.zone/appstore 32.26.0 → 33.0.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/.smartconfig.json +4 -1
- package/changelog.md +72 -0
- package/dist_catalog/catalog.digest +1 -0
- package/dist_catalog/catalog.envelope.json +7440 -0
- package/{dist_ts_client/data.generated.js → dist_catalog/catalog.json} +6742 -6741
- package/dist_ts_client/catalog.d.ts +16 -0
- package/dist_ts_client/catalog.js +12 -0
- package/dist_ts_client/index.d.ts +3 -1
- package/dist_ts_client/index.js +4 -2
- package/dist_ts_client/loader.d.ts +69 -0
- package/dist_ts_client/loader.js +361 -0
- package/dist_ts_client/plugins.d.ts +5 -0
- package/dist_ts_client/plugins.js +8 -0
- package/dist_ts_client/projection.d.ts +59 -0
- package/dist_ts_client/projection.js +83 -0
- package/dist_ts_client/storage.d.ts +15 -9
- package/dist_ts_client/storage.js +81 -657
- package/dist_ts_client/types.d.ts +86 -41
- package/dist_ts_client/types.js +1 -1
- package/dist_ts_testing/fixtures.d.ts +83 -0
- package/dist_ts_testing/fixtures.js +146 -0
- package/dist_ts_testing/index.d.ts +1 -0
- package/dist_ts_testing/index.js +2 -0
- package/dist_ts_testing/plugins.d.ts +7 -0
- package/dist_ts_testing/plugins.js +11 -0
- package/package.json +16 -11
- package/readme.md +162 -93
- package/ts_client/catalog.ts +26 -0
- package/ts_client/index.ts +3 -1
- package/ts_client/loader.ts +414 -0
- package/ts_client/plugins.ts +10 -0
- package/ts_client/projection.ts +148 -0
- package/ts_client/readme.md +17 -27
- package/ts_client/storage.ts +93 -882
- package/ts_client/types.ts +102 -42
- package/ts_testing/fixtures.ts +230 -0
- package/ts_testing/index.ts +1 -0
- package/ts_testing/plugins.ts +15 -0
- package/ts_testing/tspublish.json +3 -0
- package/apps/adminer/app.json +0 -10
- package/apps/adminer/versions/1.0.0/config.json +0 -6
- package/apps/cloudly/app.json +0 -14
- package/apps/cloudly/versions/1.0.0/config.json +0 -120
- package/apps/cloudly/versions/1.1.0/config.json +0 -120
- package/apps/cloudly/versions/1.1.1/config.json +0 -120
- package/apps/cloudly/versions/1.1.10/config.json +0 -173
- package/apps/cloudly/versions/1.1.2/config.json +0 -130
- package/apps/cloudly/versions/1.1.3/config.json +0 -130
- package/apps/cloudly/versions/1.1.4/config.json +0 -130
- package/apps/cloudly/versions/1.1.5/config.json +0 -149
- package/apps/cloudly/versions/1.1.6/config.json +0 -149
- package/apps/cloudly/versions/1.1.7/config.json +0 -149
- package/apps/cloudly/versions/1.1.8/config.json +0 -173
- package/apps/cloudly/versions/1.1.9/config.json +0 -173
- package/apps/cloudly/versions/1.10.0/config.json +0 -173
- package/apps/cloudly/versions/1.11.0/config.json +0 -173
- package/apps/cloudly/versions/1.12.0/config.json +0 -173
- package/apps/cloudly/versions/1.13.0/config.json +0 -173
- package/apps/cloudly/versions/1.14.0/config.json +0 -173
- package/apps/cloudly/versions/1.15.0/config.json +0 -173
- package/apps/cloudly/versions/1.16.0/config.json +0 -173
- package/apps/cloudly/versions/1.17.0/config.json +0 -173
- package/apps/cloudly/versions/1.18.0/config.json +0 -173
- package/apps/cloudly/versions/1.19.0/config.json +0 -173
- package/apps/cloudly/versions/1.2.0/config.json +0 -173
- package/apps/cloudly/versions/1.2.1/config.json +0 -173
- package/apps/cloudly/versions/1.20.0/config.json +0 -173
- package/apps/cloudly/versions/1.21.0/config.json +0 -173
- package/apps/cloudly/versions/1.22.0/config.json +0 -173
- package/apps/cloudly/versions/1.23.0/config.json +0 -173
- package/apps/cloudly/versions/1.24.0/config.json +0 -173
- package/apps/cloudly/versions/1.25.0/config.json +0 -173
- package/apps/cloudly/versions/1.26.0/config.json +0 -173
- package/apps/cloudly/versions/1.3.0/config.json +0 -173
- package/apps/cloudly/versions/1.4.0/config.json +0 -173
- package/apps/cloudly/versions/1.5.0/config.json +0 -173
- package/apps/cloudly/versions/1.6.0/config.json +0 -173
- package/apps/cloudly/versions/1.7.0/config.json +0 -173
- package/apps/cloudly/versions/1.8.0/config.json +0 -173
- package/apps/cloudly/versions/1.9.0/config.json +0 -173
- package/apps/ghost/app.json +0 -10
- package/apps/ghost/versions/1.0.0/config.json +0 -18
- package/apps/gitea/app.json +0 -10
- package/apps/gitea/versions/1.0.0/config.json +0 -16
- package/apps/gitops/app.json +0 -14
- package/apps/gitops/versions/1.0.0/config.json +0 -28
- package/apps/grafana/app.json +0 -10
- package/apps/grafana/versions/1.0.0/config.json +0 -9
- package/apps/mariadb/app.json +0 -10
- package/apps/mariadb/versions/1.0.0/config.json +0 -9
- package/apps/mattermost/app.json +0 -10
- package/apps/mattermost/versions/1.0.0/config.json +0 -6
- package/apps/n8n/app.json +0 -10
- package/apps/n8n/versions/1.0.0/config.json +0 -6
- package/apps/nextcloud/app.json +0 -10
- package/apps/nextcloud/versions/1.0.0/config.json +0 -16
- package/apps/nginx/app.json +0 -10
- package/apps/nginx/versions/1.0.0/config.json +0 -6
- package/apps/openbao/app.json +0 -16
- package/apps/openbao/versions/1.0.0/config.json +0 -52
- package/apps/plausible/app.json +0 -10
- package/apps/plausible/versions/1.0.0/config.json +0 -9
- package/apps/portainer/app.json +0 -10
- package/apps/portainer/versions/1.0.0/config.json +0 -6
- package/apps/postgres/app.json +0 -10
- package/apps/postgres/versions/1.0.0/config.json +0 -11
- package/apps/rustdesk-server/app.json +0 -14
- package/apps/rustdesk-server/versions/1.0.0/config.json +0 -19
- package/apps/siprouter/app.json +0 -14
- package/apps/siprouter/versions/1.0.0/config.json +0 -84
- package/apps/uptime-kuma/app.json +0 -10
- package/apps/uptime-kuma/versions/1.0.0/config.json +0 -6
- package/apps/valkey/app.json +0 -10
- package/apps/valkey/versions/1.0.0/config.json +0 -6
- package/apps/vaultwarden/app.json +0 -10
- package/apps/vaultwarden/versions/1.0.0/config.json +0 -6
- package/apps/wordpress/app.json +0 -10
- package/apps/wordpress/versions/1.0.0/config.json +0 -15
- package/appstore.json +0 -194
- package/dist_ts_client/classes.appstoreresolver.d.ts +0 -59
- package/dist_ts_client/classes.appstoreresolver.js +0 -736
- package/dist_ts_client/data.generated.d.ts +0 -2
- package/ts_client/classes.appstoreresolver.ts +0 -896
- package/ts_client/data.generated.ts +0 -7432
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import {
|
|
3
|
+
appStoreCatalogEnvelopePackagePath,
|
|
4
|
+
appStoreCatalogPackageName,
|
|
5
|
+
} from './catalog.js';
|
|
6
|
+
import type {
|
|
7
|
+
IAppStoreCatalogActivation,
|
|
8
|
+
IAppStoreCatalogFailure,
|
|
9
|
+
IAppStoreCatalogLoadOptions,
|
|
10
|
+
IAppStoreCatalogLoaderOptions,
|
|
11
|
+
IAppStoreCatalogRefused,
|
|
12
|
+
IAppStoreCatalogRegistryReport,
|
|
13
|
+
IAppStoreCatalogStoredRecord,
|
|
14
|
+
TAppStoreCatalogJudgement,
|
|
15
|
+
TAppStoreCatalogLoadResult,
|
|
16
|
+
TAppStoreCatalogRestoreResult,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
|
|
19
|
+
type TEnvelope = plugins.servezoneInterfaces.appstore.IAppStoreCatalogEnvelope;
|
|
20
|
+
type TTrustedKey = plugins.servezoneInterfaces.appstore.IAppStoreCatalogTrustedKey;
|
|
21
|
+
type TOrigin = plugins.servezoneInterfaces.appstore.TAppStoreCatalogOrigin;
|
|
22
|
+
type TRefusal = plugins.servezoneInterfaces.appstore.IAppStoreCatalogRefusal;
|
|
23
|
+
|
|
24
|
+
const defaultRequestTimeoutMs = 30_000;
|
|
25
|
+
const defaultMaximumTarballBytes = 32 * 1024 * 1024;
|
|
26
|
+
const maximumManifestBytes = 1024 * 1024;
|
|
27
|
+
const maximumEnvelopeBytes = 16 * 1024 * 1024;
|
|
28
|
+
const defaultMaximumUnpackedBytes = 256 * 1024 * 1024;
|
|
29
|
+
const envelopeTarballEntryName = `package/${appStoreCatalogEnvelopePackagePath}`;
|
|
30
|
+
|
|
31
|
+
/** A failure of the transport, the integrity check or the package layout: no candidate was read. */
|
|
32
|
+
class AppStoreCatalogTransportError extends Error {}
|
|
33
|
+
|
|
34
|
+
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> =>
|
|
35
|
+
typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
|
36
|
+
|
|
37
|
+
const isPositiveSafeInteger = (valueArg: number): boolean => Number.isSafeInteger(valueArg) && valueArg > 0;
|
|
38
|
+
|
|
39
|
+
const toBase64 = (bytesArg: Uint8Array): string => {
|
|
40
|
+
let binary = '';
|
|
41
|
+
for (const byte of bytesArg) binary += String.fromCharCode(byte);
|
|
42
|
+
return btoa(binary);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The `sha512-<base64>` values of an npm `dist.integrity` (Subresource Integrity syntax). */
|
|
46
|
+
const readSha512Integrity = (integrityArg: string): string[] =>
|
|
47
|
+
integrityArg.split(/\s+/)
|
|
48
|
+
.filter((entryArg) => entryArg.startsWith('sha512-'))
|
|
49
|
+
.map((entryArg) => entryArg.slice('sha512-'.length).replace(/\?.*$/, ''));
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Loads the signed App Store catalog from npm registries and judges it against the consumer's
|
|
53
|
+
* active catalog. It holds the consumer's options only: no timer, no schedule, no catalog.
|
|
54
|
+
*
|
|
55
|
+
* One load asks every configured registry for the package its `latest` dist-tag names, downloads
|
|
56
|
+
* the tarball, checks it against the registry's `dist.integrity`, extracts the envelope, verifies
|
|
57
|
+
* it against the trusted keys (`verifyAppStoreCatalogEnvelope`) and judges its succession
|
|
58
|
+
* (`judgeAppStoreCatalogSuccession`). The first registry whose catalog activates ends the load;
|
|
59
|
+
* otherwise the result is the first `unchanged`, else the first `refused`, else `failed`, and
|
|
60
|
+
* `registries` reports what each registry answered.
|
|
61
|
+
*
|
|
62
|
+
* The loader holds no state, runs no schedule and persists nothing: the consumer's catalog runtime
|
|
63
|
+
* owns the one refresh schedule of its process, passes its active envelope in, stores an activated
|
|
64
|
+
* one as its last-known-good catalog and restores it with `restoreEnvelopeText` at start. Every
|
|
65
|
+
* request is bounded in time and size; a transport, integrity or packaging failure and every
|
|
66
|
+
* refusal are results, never thrown. Aborting `signal` (the consumer stopping) ends the load at
|
|
67
|
+
* once and rejects it with the signal's reason; no attempt is reported for it.
|
|
68
|
+
*/
|
|
69
|
+
export class AppStoreCatalogLoader {
|
|
70
|
+
private readonly trustedKeys: readonly TTrustedKey[];
|
|
71
|
+
private readonly supportedFeatureIds: readonly string[];
|
|
72
|
+
private readonly registries: readonly string[];
|
|
73
|
+
private readonly fetchFunction: typeof fetch;
|
|
74
|
+
private readonly now: () => Date;
|
|
75
|
+
private readonly requestTimeoutMs: number;
|
|
76
|
+
private readonly maximumTarballBytes: number;
|
|
77
|
+
private readonly maximumUnpackedBytes: number;
|
|
78
|
+
|
|
79
|
+
constructor(optionsArg: IAppStoreCatalogLoaderOptions) {
|
|
80
|
+
if (optionsArg.trustedKeys.length === 0) {
|
|
81
|
+
throw new Error('App Store catalog loader needs at least one trusted key');
|
|
82
|
+
}
|
|
83
|
+
this.trustedKeys = optionsArg.trustedKeys.map((keyArg) => ({ ...keyArg }));
|
|
84
|
+
this.supportedFeatureIds = [...new Set(optionsArg.supportedFeatureIds)].sort();
|
|
85
|
+
this.registries = plugins.servezoneInterfaces.appstore.resolveAppStoreCatalogRegistries(optionsArg.registries);
|
|
86
|
+
this.fetchFunction = optionsArg.fetch ?? ((inputArg, initArg) => fetch(inputArg, initArg));
|
|
87
|
+
this.now = optionsArg.now ?? (() => new Date());
|
|
88
|
+
this.requestTimeoutMs = optionsArg.requestTimeoutMs ?? defaultRequestTimeoutMs;
|
|
89
|
+
this.maximumTarballBytes = optionsArg.maximumTarballBytes ?? defaultMaximumTarballBytes;
|
|
90
|
+
this.maximumUnpackedBytes = optionsArg.maximumUnpackedBytes ?? defaultMaximumUnpackedBytes;
|
|
91
|
+
if (!isPositiveSafeInteger(this.requestTimeoutMs)) {
|
|
92
|
+
throw new Error('App Store catalog requestTimeoutMs must be a positive integer');
|
|
93
|
+
}
|
|
94
|
+
if (!isPositiveSafeInteger(this.maximumTarballBytes)) {
|
|
95
|
+
throw new Error('App Store catalog maximumTarballBytes must be a positive integer');
|
|
96
|
+
}
|
|
97
|
+
if (!isPositiveSafeInteger(this.maximumUnpackedBytes)) {
|
|
98
|
+
throw new Error('App Store catalog maximumUnpackedBytes must be a positive integer');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One load from the configured registries; see the class description. */
|
|
103
|
+
public async loadFromRegistries(
|
|
104
|
+
activeArg: TEnvelope | null,
|
|
105
|
+
optionsArg: IAppStoreCatalogLoadOptions = {},
|
|
106
|
+
): Promise<TAppStoreCatalogLoadResult> {
|
|
107
|
+
const { signal } = optionsArg;
|
|
108
|
+
const reports: IAppStoreCatalogRegistryReport[] = [];
|
|
109
|
+
const judgements: TAppStoreCatalogJudgement[] = [];
|
|
110
|
+
for (const registry of this.registries) {
|
|
111
|
+
signal?.throwIfAborted();
|
|
112
|
+
try {
|
|
113
|
+
const { packageVersion, envelopeText } = await this.fetchEnvelopeText(registry, signal);
|
|
114
|
+
const judgement = await this.judgeText(envelopeText, activeArg, 'registry');
|
|
115
|
+
judgements.push(judgement);
|
|
116
|
+
reports.push({
|
|
117
|
+
registry,
|
|
118
|
+
outcome: judgement.outcome,
|
|
119
|
+
packageVersion,
|
|
120
|
+
...(judgement.outcome === 'refused' ? { message: judgement.refused.message } : {}),
|
|
121
|
+
});
|
|
122
|
+
if (judgement.outcome === 'activated') break;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
signal?.throwIfAborted();
|
|
125
|
+
if (!(error instanceof AppStoreCatalogTransportError)) throw error;
|
|
126
|
+
reports.push({ registry, outcome: 'failed', message: error.message });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
signal?.throwIfAborted();
|
|
130
|
+
const chosen = judgements.find((judgementArg) => judgementArg.outcome === 'activated')
|
|
131
|
+
?? judgements.find((judgementArg) => judgementArg.outcome === 'unchanged')
|
|
132
|
+
?? judgements.find((judgementArg) => judgementArg.outcome === 'refused');
|
|
133
|
+
if (chosen !== undefined) return { ...chosen, registries: reports };
|
|
134
|
+
const message = reports.map((reportArg) => `${reportArg.registry}: ${reportArg.message ?? reportArg.outcome}`).join('; ');
|
|
135
|
+
const failure: IAppStoreCatalogFailure = {
|
|
136
|
+
outcome: 'failed',
|
|
137
|
+
message,
|
|
138
|
+
attempt: { at: this.now().toISOString(), outcome: 'failed', message },
|
|
139
|
+
};
|
|
140
|
+
return { ...failure, registries: reports };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Judges an envelope an operator imported (an offline signed bundle, or the envelope a package
|
|
145
|
+
* carries at `./catalog.envelope.json`) exactly as a fetched one: authentication, contract,
|
|
146
|
+
* schema, digests, then succession against the active catalog.
|
|
147
|
+
*/
|
|
148
|
+
public async judgeImportedEnvelope(candidateArg: unknown, activeArg: TEnvelope | null): Promise<TAppStoreCatalogJudgement> {
|
|
149
|
+
return await this.judge(candidateArg, activeArg, 'operatorImport');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** An envelope read as text: bytes that are not JSON are refused as `schemaInvalid`. */
|
|
153
|
+
public async judgeImportedEnvelopeText(textArg: string, activeArg: TEnvelope | null): Promise<TAppStoreCatalogJudgement> {
|
|
154
|
+
return await this.judgeText(textArg, activeArg, 'operatorImport');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Restores the last-known-good catalog a consumer stored, at start: the stored envelope text is
|
|
159
|
+
* verified again against today's trusted keys and supported features, exactly as a fetched one,
|
|
160
|
+
* and answers the status `active` member it activates (`origin` and `loadedAt` as stored, the
|
|
161
|
+
* verifying key as verified now) or the refusal that keeps it inactive, e.g. `signatureInvalid`
|
|
162
|
+
* once its key is no longer trusted.
|
|
163
|
+
*/
|
|
164
|
+
public async restoreEnvelopeText(
|
|
165
|
+
textArg: string,
|
|
166
|
+
recordArg: IAppStoreCatalogStoredRecord,
|
|
167
|
+
): Promise<TAppStoreCatalogRestoreResult> {
|
|
168
|
+
const candidate = this.parseEnvelopeText(textArg);
|
|
169
|
+
const verified = candidate.parsed
|
|
170
|
+
? await this.verify(candidate.value, recordArg.origin)
|
|
171
|
+
: refuseCandidate(undefined, this.now().toISOString(), candidate.refusal);
|
|
172
|
+
if (verified.outcome === 'refused') return verified;
|
|
173
|
+
return {
|
|
174
|
+
outcome: 'restored',
|
|
175
|
+
envelope: verified.envelope,
|
|
176
|
+
active: { ...verified.active, loadedAt: recordArg.loadedAt },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async judgeText(textArg: string, activeArg: TEnvelope | null, originArg: TOrigin): Promise<TAppStoreCatalogJudgement> {
|
|
181
|
+
const candidate = this.parseEnvelopeText(textArg);
|
|
182
|
+
return candidate.parsed
|
|
183
|
+
? await this.judge(candidate.value, activeArg, originArg)
|
|
184
|
+
: refuseCandidate(undefined, this.now().toISOString(), candidate.refusal);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Envelope text as a value; bytes that are not JSON are a `schemaInvalid` refusal. */
|
|
188
|
+
private parseEnvelopeText(textArg: string): { parsed: true; value: unknown } | { parsed: false; refusal: TRefusal } {
|
|
189
|
+
try {
|
|
190
|
+
return { parsed: true, value: JSON.parse(textArg) };
|
|
191
|
+
} catch {
|
|
192
|
+
return { parsed: false, refusal: { reason: 'schemaInvalid', message: 'envelope is not JSON' } };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async judge(candidateArg: unknown, activeArg: TEnvelope | null, originArg: TOrigin): Promise<TAppStoreCatalogJudgement> {
|
|
197
|
+
const verified = await this.verify(candidateArg, originArg);
|
|
198
|
+
if (verified.outcome === 'refused') return verified;
|
|
199
|
+
const succession = plugins.servezoneInterfaces.appstore.judgeAppStoreCatalogSuccession(activeArg, verified.envelope);
|
|
200
|
+
if (succession.outcome === 'refused') return refuseCandidate(candidateArg, verified.attempt.at, succession.refusal);
|
|
201
|
+
if (succession.outcome === 'unchanged') {
|
|
202
|
+
return { outcome: 'unchanged', attempt: { at: verified.attempt.at, outcome: 'unchanged' } };
|
|
203
|
+
}
|
|
204
|
+
return verified;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Authenticates and reads a candidate (`verifyAppStoreCatalogEnvelope`), before and without
|
|
209
|
+
* succession. The verifier answers every refusal as a result; should it throw, the candidate is
|
|
210
|
+
* refused as `schemaInvalid` with the cause, so a load never fails on an input it could not read.
|
|
211
|
+
*/
|
|
212
|
+
private async verify(candidateArg: unknown, originArg: TOrigin): Promise<IAppStoreCatalogActivation | IAppStoreCatalogRefused> {
|
|
213
|
+
const at = this.now().toISOString();
|
|
214
|
+
let verification: plugins.servezoneInterfacesRuntime.TAppStoreCatalogVerification;
|
|
215
|
+
try {
|
|
216
|
+
verification = await plugins.servezoneInterfacesRuntime.verifyAppStoreCatalogEnvelope(candidateArg, {
|
|
217
|
+
trustedKeys: this.trustedKeys,
|
|
218
|
+
supportedFeatureIds: this.supportedFeatureIds,
|
|
219
|
+
});
|
|
220
|
+
} catch (error) {
|
|
221
|
+
return refuseCandidate(candidateArg, at, {
|
|
222
|
+
reason: 'schemaInvalid',
|
|
223
|
+
message: `envelope could not be verified: ${error instanceof Error ? error.message : String(error)}`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (!verification.accepted) return refuseCandidate(candidateArg, at, verification.refusal);
|
|
227
|
+
const { envelope, keyId } = verification;
|
|
228
|
+
const { catalog, catalogDigest } = envelope;
|
|
229
|
+
return {
|
|
230
|
+
outcome: 'activated',
|
|
231
|
+
envelope,
|
|
232
|
+
active: {
|
|
233
|
+
contract: { ...catalog.contract },
|
|
234
|
+
revision: catalog.revision,
|
|
235
|
+
catalogDigest,
|
|
236
|
+
publishedAt: catalog.publishedAt,
|
|
237
|
+
sourceRevision: catalog.sourceRevision,
|
|
238
|
+
loadedAt: at,
|
|
239
|
+
keyId,
|
|
240
|
+
origin: originArg,
|
|
241
|
+
},
|
|
242
|
+
attempt: { at, outcome: 'activated' },
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The envelope text the package the registry's `latest` dist-tag names carries. */
|
|
247
|
+
private async fetchEnvelopeText(
|
|
248
|
+
registryArg: string,
|
|
249
|
+
signalArg: AbortSignal | undefined,
|
|
250
|
+
): Promise<{ packageVersion: string; envelopeText: string }> {
|
|
251
|
+
const manifestUrl = `${registryArg}/${appStoreCatalogPackageName.replace('/', '%2f')}/latest`;
|
|
252
|
+
const manifestBytes = await this.fetchBytes(manifestUrl, maximumManifestBytes, signalArg);
|
|
253
|
+
let manifest: unknown;
|
|
254
|
+
try {
|
|
255
|
+
manifest = JSON.parse(new TextDecoder().decode(manifestBytes));
|
|
256
|
+
} catch {
|
|
257
|
+
throw new AppStoreCatalogTransportError(`${manifestUrl} did not answer JSON`);
|
|
258
|
+
}
|
|
259
|
+
const dist = isRecord(manifest) ? manifest.dist : undefined;
|
|
260
|
+
if (!isRecord(manifest) || manifest.name !== appStoreCatalogPackageName || typeof manifest.version !== 'string'
|
|
261
|
+
|| !isRecord(dist) || typeof dist.tarball !== 'string' || typeof dist.integrity !== 'string') {
|
|
262
|
+
throw new AppStoreCatalogTransportError(`${manifestUrl} is not the ${appStoreCatalogPackageName} version manifest`);
|
|
263
|
+
}
|
|
264
|
+
const packageVersion = manifest.version;
|
|
265
|
+
const expectedSha512 = readSha512Integrity(dist.integrity);
|
|
266
|
+
if (expectedSha512.length === 0) {
|
|
267
|
+
throw new AppStoreCatalogTransportError(`${appStoreCatalogPackageName}@${packageVersion} has no sha512 dist.integrity`);
|
|
268
|
+
}
|
|
269
|
+
let tarballUrl: URL;
|
|
270
|
+
try {
|
|
271
|
+
tarballUrl = new URL(dist.tarball);
|
|
272
|
+
} catch {
|
|
273
|
+
throw new AppStoreCatalogTransportError(`${appStoreCatalogPackageName}@${packageVersion} names no tarball URL`);
|
|
274
|
+
}
|
|
275
|
+
if (tarballUrl.protocol !== 'https:' || tarballUrl.username !== '' || tarballUrl.password !== '') {
|
|
276
|
+
throw new AppStoreCatalogTransportError(`${appStoreCatalogPackageName}@${packageVersion} tarball URL must be https without credentials`);
|
|
277
|
+
}
|
|
278
|
+
const tarball = await this.fetchBytes(tarballUrl.toString(), this.maximumTarballBytes, signalArg);
|
|
279
|
+
const actualSha512 = toBase64(new Uint8Array(await crypto.subtle.digest('SHA-512', tarball)));
|
|
280
|
+
if (!expectedSha512.includes(actualSha512)) {
|
|
281
|
+
throw new AppStoreCatalogTransportError(
|
|
282
|
+
`${appStoreCatalogPackageName}@${packageVersion} tarball does not match its dist.integrity`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const envelopeText = await readTarballEntry(tarball, envelopeTarballEntryName, packageVersion, this.maximumUnpackedBytes);
|
|
286
|
+
return { packageVersion, envelopeText };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* GET with the request bound covering the response body and a byte bound on the body. The
|
|
291
|
+
* caller's `signalArg` aborts it too; that abort is rethrown as the signal's reason. A redirect is
|
|
292
|
+
* never followed: a registry that answers with one fails, so a request never leaves https.
|
|
293
|
+
*/
|
|
294
|
+
private async fetchBytes(
|
|
295
|
+
urlArg: string,
|
|
296
|
+
maximumBytesArg: number,
|
|
297
|
+
signalArg: AbortSignal | undefined,
|
|
298
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
299
|
+
const controller = new AbortController();
|
|
300
|
+
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
|
|
301
|
+
const signal = signalArg === undefined ? controller.signal : AbortSignal.any([controller.signal, signalArg]);
|
|
302
|
+
try {
|
|
303
|
+
const response = await this.fetchFunction(urlArg, {
|
|
304
|
+
signal,
|
|
305
|
+
redirect: 'error',
|
|
306
|
+
headers: { accept: 'application/json, application/octet-stream' },
|
|
307
|
+
});
|
|
308
|
+
if (!response.ok) {
|
|
309
|
+
await response.body?.cancel();
|
|
310
|
+
throw new AppStoreCatalogTransportError(`HTTP ${response.status} for ${urlArg}`);
|
|
311
|
+
}
|
|
312
|
+
const declaredLength = Number(response.headers.get('content-length') ?? '0');
|
|
313
|
+
if (declaredLength > maximumBytesArg) {
|
|
314
|
+
await response.body?.cancel();
|
|
315
|
+
throw new AppStoreCatalogTransportError(`${urlArg} exceeds ${maximumBytesArg} bytes`);
|
|
316
|
+
}
|
|
317
|
+
if (response.body === null) throw new AppStoreCatalogTransportError(`${urlArg} answered no body`);
|
|
318
|
+
return await readBounded(response.body, maximumBytesArg, urlArg);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
signalArg?.throwIfAborted();
|
|
321
|
+
if (controller.signal.aborted) {
|
|
322
|
+
throw new AppStoreCatalogTransportError(`${urlArg} timed out after ${this.requestTimeoutMs} ms`);
|
|
323
|
+
}
|
|
324
|
+
if (error instanceof AppStoreCatalogTransportError) throw error;
|
|
325
|
+
throw new AppStoreCatalogTransportError(
|
|
326
|
+
`${urlArg} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
327
|
+
);
|
|
328
|
+
} finally {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The refusal record and attempt of a candidate, with its revision when it can be read. */
|
|
335
|
+
const refuseCandidate = (candidateArg: unknown, atArg: string, refusalArg: TRefusal): IAppStoreCatalogRefused => {
|
|
336
|
+
const revision = readCandidateRevision(candidateArg);
|
|
337
|
+
return {
|
|
338
|
+
outcome: 'refused',
|
|
339
|
+
refused: { ...refusalArg, at: atArg, ...(revision === undefined ? {} : { revision }) },
|
|
340
|
+
attempt: { at: atArg, outcome: 'refused', message: `${refusalArg.reason}: ${refusalArg.message}` },
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const readCandidateRevision = (candidateArg: unknown): number | undefined => {
|
|
345
|
+
const catalog = isRecord(candidateArg) ? candidateArg.catalog : undefined;
|
|
346
|
+
const revision = isRecord(catalog) ? catalog.revision : undefined;
|
|
347
|
+
return typeof revision === 'number' && Number.isSafeInteger(revision) ? revision : undefined;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const readBounded = async (
|
|
351
|
+
streamArg: ReadableStream<Uint8Array>,
|
|
352
|
+
maximumBytesArg: number,
|
|
353
|
+
labelArg: string,
|
|
354
|
+
): Promise<Uint8Array<ArrayBuffer>> => {
|
|
355
|
+
const reader = streamArg.getReader();
|
|
356
|
+
const chunks: Uint8Array[] = [];
|
|
357
|
+
let total = 0;
|
|
358
|
+
try {
|
|
359
|
+
for (;;) {
|
|
360
|
+
const { done, value } = await reader.read();
|
|
361
|
+
if (done) break;
|
|
362
|
+
total += value.byteLength;
|
|
363
|
+
if (total > maximumBytesArg) {
|
|
364
|
+
await reader.cancel();
|
|
365
|
+
throw new AppStoreCatalogTransportError(`${labelArg} exceeds ${maximumBytesArg} bytes`);
|
|
366
|
+
}
|
|
367
|
+
chunks.push(value);
|
|
368
|
+
}
|
|
369
|
+
} finally {
|
|
370
|
+
reader.releaseLock();
|
|
371
|
+
}
|
|
372
|
+
const bytes = new Uint8Array(total);
|
|
373
|
+
let offset = 0;
|
|
374
|
+
for (const chunk of chunks) {
|
|
375
|
+
bytes.set(chunk, offset);
|
|
376
|
+
offset += chunk.byteLength;
|
|
377
|
+
}
|
|
378
|
+
return bytes;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The text of one regular file in a gzip-compressed npm tarball. The archive is decompressed in
|
|
383
|
+
* full (bounded) before it is decoded: decoding straight from the decompression stream stalls on
|
|
384
|
+
* entries larger than one stream chunk.
|
|
385
|
+
*/
|
|
386
|
+
const readTarballEntry = async (
|
|
387
|
+
tarballArg: Uint8Array<ArrayBuffer>,
|
|
388
|
+
entryNameArg: string,
|
|
389
|
+
packageVersionArg: string,
|
|
390
|
+
maximumUnpackedBytesArg: number,
|
|
391
|
+
): Promise<string> => {
|
|
392
|
+
const label = `${appStoreCatalogPackageName}@${packageVersionArg}`;
|
|
393
|
+
try {
|
|
394
|
+
const archive = await readBounded(
|
|
395
|
+
new Blob([tarballArg]).stream().pipeThrough(new DecompressionStream('gzip')),
|
|
396
|
+
maximumUnpackedBytesArg,
|
|
397
|
+
`${label} unpacked tarball`,
|
|
398
|
+
);
|
|
399
|
+
const entry = (await plugins.modernTar.unpackTar(archive, { strict: true }))
|
|
400
|
+
.find((entryArg) => entryArg.header.name === entryNameArg && (entryArg.header.type ?? 'file') === 'file');
|
|
401
|
+
if (entry?.data === undefined) {
|
|
402
|
+
throw new AppStoreCatalogTransportError(`${label} carries no ${appStoreCatalogEnvelopePackagePath}`);
|
|
403
|
+
}
|
|
404
|
+
if (entry.data.byteLength > maximumEnvelopeBytes) {
|
|
405
|
+
throw new AppStoreCatalogTransportError(`${label} ${appStoreCatalogEnvelopePackagePath} exceeds ${maximumEnvelopeBytes} bytes`);
|
|
406
|
+
}
|
|
407
|
+
return new TextDecoder().decode(entry.data);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (error instanceof AppStoreCatalogTransportError) throw error;
|
|
410
|
+
throw new AppStoreCatalogTransportError(
|
|
411
|
+
`${label} tarball could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @serve.zone scope
|
|
2
|
+
import * as servezoneInterfaces from '@serve.zone/interfaces';
|
|
3
|
+
import * as servezoneInterfacesRuntime from '@serve.zone/interfaces/runtime';
|
|
4
|
+
|
|
5
|
+
export { servezoneInterfaces, servezoneInterfacesRuntime };
|
|
6
|
+
|
|
7
|
+
// third party
|
|
8
|
+
import * as modernTar from 'modern-tar';
|
|
9
|
+
|
|
10
|
+
export { modernTar };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
|
|
3
|
+
type TCatalog = plugins.servezoneInterfaces.appstore.IAppStoreCatalog;
|
|
4
|
+
type TCatalogApp = plugins.servezoneInterfaces.appstore.IAppStoreCatalogApp;
|
|
5
|
+
type TAppStoreApp = plugins.servezoneInterfaces.appstore.IAppStoreApp;
|
|
6
|
+
type TAppStoreAppMeta = plugins.servezoneInterfaces.appstore.IAppStoreAppMeta;
|
|
7
|
+
|
|
8
|
+
type TVersionState = plugins.servezoneInterfaces.appstore.IAppStoreAppVersionState;
|
|
9
|
+
|
|
10
|
+
/** The consumer a projection is for: the feature ids it fulfils, and which host release it is. */
|
|
11
|
+
export interface IAppStoreCatalogProjectionOptions {
|
|
12
|
+
/** The `requiresFeatures` ids this consumer fulfils, e.g. `appStoreStorageFeatureIds` values. */
|
|
13
|
+
supportedFeatureIds: Iterable<string>;
|
|
14
|
+
/** The host and its release, judged against each version's `minOneboxVersion` / `minCloudlyVersion`. */
|
|
15
|
+
consumer: plugins.servezoneInterfaces.appstore.IAppStoreCatalogConsumer;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The App Store shapes Onebox and Cloudly carry in their views and requests for one app. */
|
|
19
|
+
export interface IAppStoreCatalogAppDisplay {
|
|
20
|
+
/** The listing entry (`getAppStoreTemplates`). */
|
|
21
|
+
app: TAppStoreApp;
|
|
22
|
+
/** The app metadata (`getAppStoreConfig`, the upgrade preview). */
|
|
23
|
+
meta: TAppStoreAppMeta;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One catalog app as a consumer with its supported feature ids sees it. */
|
|
27
|
+
export interface IAppStoreCatalogAppProjection {
|
|
28
|
+
appId: string;
|
|
29
|
+
/** The catalog's `latestVersion`, the publisher's latest, whether or not this consumer may install it. */
|
|
30
|
+
catalogLatestVersion: string;
|
|
31
|
+
/**
|
|
32
|
+
* Every listed version in catalog order, withdrawn, feature-gated and newer-release-gated ones
|
|
33
|
+
* included, each with `installRefusal` as `appstore.judgeAppStoreCatalogInstallTarget` answers it.
|
|
34
|
+
*/
|
|
35
|
+
versions: TVersionState[];
|
|
36
|
+
/**
|
|
37
|
+
* The newest version this consumer may install, or `null` when none is: every version withdrawn,
|
|
38
|
+
* gated on a feature it does not fulfil or requiring a newer release of it. Under `semver` the highest install target; under
|
|
39
|
+
* `branch` the catalog's `latestVersion` when it is one, since branch tokens have no order.
|
|
40
|
+
*/
|
|
41
|
+
latestInstallableVersion: string | null;
|
|
42
|
+
/**
|
|
43
|
+
* The display shapes, or `null` when `latestInstallableVersion` is: their `versions` list only
|
|
44
|
+
* install targets and their `latestVersion` names `latestInstallableVersion`, so a version
|
|
45
|
+
* picker built from them never offers a version the consumer refuses; their `versionStates`
|
|
46
|
+
* state every listed version with its refusal.
|
|
47
|
+
*/
|
|
48
|
+
display: IAppStoreCatalogAppDisplay | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const selectLatestInstallableVersion = (appArg: TCatalogApp, installableArg: readonly string[]): string | null => {
|
|
52
|
+
if (appArg.upgradeStrategy === 'branch') {
|
|
53
|
+
return installableArg.includes(appArg.latestVersion) ? appArg.latestVersion : null;
|
|
54
|
+
}
|
|
55
|
+
const { data } = plugins.servezoneInterfaces;
|
|
56
|
+
let latest: { version: string; parsed: plugins.servezoneInterfaces.data.IReleaseVersion } | null = null;
|
|
57
|
+
for (const version of installableArg) {
|
|
58
|
+
const parsed = data.parseReleaseVersion(version);
|
|
59
|
+
if (parsed === null) {
|
|
60
|
+
throw new Error(`App Store catalog app '${appArg.info.id}' lists '${version}', which is not a release version`);
|
|
61
|
+
}
|
|
62
|
+
if (latest === null || data.compareReleaseVersions(parsed, latest.parsed) > 0) latest = { version, parsed };
|
|
63
|
+
}
|
|
64
|
+
return latest === null ? null : latest.version;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Projects one catalog app for a consumer: every listed version with its install refusal, the
|
|
69
|
+
* newest install target for this consumer, and the `IAppStoreApp` / `IAppStoreAppMeta` shapes its App Store views
|
|
70
|
+
* show. Pure: it reads the catalog app and never changes it.
|
|
71
|
+
*/
|
|
72
|
+
export const projectAppStoreCatalogApp = (
|
|
73
|
+
appArg: TCatalogApp,
|
|
74
|
+
optionsArg: IAppStoreCatalogProjectionOptions,
|
|
75
|
+
): IAppStoreCatalogAppProjection => {
|
|
76
|
+
const supportedFeatureIds = [...optionsArg.supportedFeatureIds];
|
|
77
|
+
const versions = appArg.versions.map((versionArg): TVersionState => ({
|
|
78
|
+
version: versionArg.version,
|
|
79
|
+
installRefusal: plugins.servezoneInterfaces.appstore.judgeAppStoreCatalogInstallTarget(
|
|
80
|
+
versionArg,
|
|
81
|
+
supportedFeatureIds,
|
|
82
|
+
optionsArg.consumer,
|
|
83
|
+
),
|
|
84
|
+
}));
|
|
85
|
+
const installable = versions
|
|
86
|
+
.filter((versionArg) => versionArg.installRefusal === null)
|
|
87
|
+
.map((versionArg) => versionArg.version);
|
|
88
|
+
const latestInstallableVersion = selectLatestInstallableVersion(appArg, installable);
|
|
89
|
+
const { info } = appArg;
|
|
90
|
+
const display: IAppStoreCatalogAppDisplay | null = latestInstallableVersion === null
|
|
91
|
+
? null
|
|
92
|
+
: {
|
|
93
|
+
app: {
|
|
94
|
+
id: info.id,
|
|
95
|
+
name: info.name,
|
|
96
|
+
description: info.description,
|
|
97
|
+
category: info.category,
|
|
98
|
+
...(info.iconName === undefined ? {} : { iconName: info.iconName }),
|
|
99
|
+
...(info.iconUrl === undefined ? {} : { iconUrl: info.iconUrl }),
|
|
100
|
+
latestVersion: latestInstallableVersion,
|
|
101
|
+
versions: [...installable],
|
|
102
|
+
versionStates: structuredClone(versions),
|
|
103
|
+
...(info.tags === undefined ? {} : { tags: [...info.tags] }),
|
|
104
|
+
...(appArg.upgradeStrategy === undefined ? {} : { upgradeStrategy: appArg.upgradeStrategy }),
|
|
105
|
+
},
|
|
106
|
+
meta: {
|
|
107
|
+
id: info.id,
|
|
108
|
+
name: info.name,
|
|
109
|
+
description: info.description,
|
|
110
|
+
category: info.category,
|
|
111
|
+
...(info.iconName === undefined ? {} : { iconName: info.iconName }),
|
|
112
|
+
latestVersion: latestInstallableVersion,
|
|
113
|
+
versions: [...installable],
|
|
114
|
+
versionStates: structuredClone(versions),
|
|
115
|
+
...(info.maintainer === undefined ? {} : { maintainer: info.maintainer }),
|
|
116
|
+
...(info.links === undefined ? {} : { links: { ...info.links } }),
|
|
117
|
+
...(info.tags === undefined ? {} : { tags: [...info.tags] }),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
appId: info.id,
|
|
122
|
+
catalogLatestVersion: appArg.latestVersion,
|
|
123
|
+
versions,
|
|
124
|
+
latestInstallableVersion,
|
|
125
|
+
display,
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Projects every catalog app, in catalog order (by id). */
|
|
130
|
+
export const projectAppStoreCatalog = (
|
|
131
|
+
catalogArg: TCatalog,
|
|
132
|
+
optionsArg: IAppStoreCatalogProjectionOptions,
|
|
133
|
+
): IAppStoreCatalogAppProjection[] => {
|
|
134
|
+
const options = { supportedFeatureIds: [...optionsArg.supportedFeatureIds], consumer: optionsArg.consumer };
|
|
135
|
+
return catalogArg.apps.map((appArg) => projectAppStoreCatalogApp(appArg, options));
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The App Store listing a consumer serves (`getAppStoreTemplates`): one `IAppStoreApp` per app
|
|
140
|
+
* that has an install target for it, in catalog order. An app without one is left out of the
|
|
141
|
+
* listing; its projection (`projectAppStoreCatalogApp`) still states why.
|
|
142
|
+
*/
|
|
143
|
+
export const getAppStoreCatalogListing = (
|
|
144
|
+
catalogArg: TCatalog,
|
|
145
|
+
optionsArg: IAppStoreCatalogProjectionOptions,
|
|
146
|
+
): TAppStoreApp[] =>
|
|
147
|
+
projectAppStoreCatalog(catalogArg, optionsArg).flatMap((projectionArg) =>
|
|
148
|
+
projectionArg.display === null ? [] : [projectionArg.display.app]);
|
package/ts_client/readme.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @serve.zone/appstore Client
|
|
2
2
|
|
|
3
|
-
This module exports the
|
|
3
|
+
This module exports the consumer side of the serve.zone App Store runtime catalog: `AppStoreCatalogLoader`, which loads the signed catalog from npm registries and judges it against the consumer's active catalog, the catalog lookups, and the storage handoff a fulfillment adapter receives for a version config. It bundles no App Store data.
|
|
4
4
|
|
|
5
5
|
## Issue Reporting and Security
|
|
6
6
|
|
|
@@ -15,37 +15,27 @@ pnpm add @serve.zone/appstore
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
import { AppStoreCatalogLoader, findAppStoreCatalogVersion } from '@serve.zone/appstore';
|
|
19
|
+
|
|
20
|
+
const loader = new AppStoreCatalogLoader({ trustedKeys, supportedFeatureIds });
|
|
21
|
+
const restored = await loader.restoreEnvelopeText(storedText, storedRecord); // at start
|
|
22
|
+
const result = await loader.loadFromRegistries(activeEnvelope ?? null, { signal }); // on the consumer's schedule
|
|
23
|
+
if (result.outcome === 'activated') {
|
|
24
|
+
// store result.envelope as the last-known-good catalog
|
|
25
|
+
}
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
## Remote Source
|
|
29
|
-
|
|
30
|
-
```ts
|
|
31
|
-
const resolver = new AppStoreResolver({
|
|
32
|
-
source: 'remote',
|
|
33
|
-
baseUrl: 'https://example.com/appstore',
|
|
34
|
-
fetch,
|
|
35
|
-
requestTimeoutMs: 30_000,
|
|
36
|
-
});
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
Providing `baseUrl` or `fetch` without an explicit `source` also selects remote mode. The default remote base URL is exported as `appStoreDefaultRemoteBaseUrl`. `requestTimeoutMs` bounds each remote catalog, manifest, registry, and response-body operation; it defaults to `30000` and must be a positive integer.
|
|
40
|
-
|
|
41
28
|
## Exports
|
|
42
29
|
|
|
43
|
-
- `
|
|
44
|
-
- `
|
|
45
|
-
- `
|
|
46
|
-
- `
|
|
30
|
+
- `AppStoreCatalogLoader` loads the catalog package from the registry list `appstore.resolveAppStoreCatalogRegistries` (`@serve.zone/interfaces`) answers, npmjs by default, checks the tarball's `dist.integrity`, verifies the envelope against the consumer's trusted keys and supported feature ids, and judges its succession; `signal` aborts a load. `restoreEnvelopeText` verifies a stored last-known-good envelope again at start. `judgeImportedEnvelope` and `judgeImportedEnvelopeText` judge an operator-imported envelope the same way as a fetched one. It holds no state, runs no timer and persists nothing; every request is bounded.
|
|
31
|
+
- `findAppStoreCatalogApp` and `findAppStoreCatalogVersion` look up the active catalog; withdrawn versions stay listed (`appstore.judgeAppStoreCatalogInstallTarget` refuses them as targets).
|
|
32
|
+
- `projectAppStoreCatalogApp`, `projectAppStoreCatalog` and `getAppStoreCatalogListing` project the active catalog for one consumer (its supported feature ids, kind and release): every listed version with its install refusal (`versionWithdrawn`, `featuresUnsupported` or `consumerVersionTooOld`), the newest install target, and the `IAppStoreApp` / `IAppStoreAppMeta` shapes the App Store views carry, listing install targets in `versions` and every version in `versionStates`.
|
|
33
|
+
- `normalizeAppStoreStorageConfig` and `normalizeAppStoreVolumes` produce the storage handoff of a catalog version config after the interfaces contract judged its storage.
|
|
34
|
+
- `appStoreCatalogPackageName` and `appStoreCatalogEnvelopePackagePath` name where the catalog is published.
|
|
35
|
+
|
|
36
|
+
Test fixtures for consumers live in the separate entry `@serve.zone/appstore/testing`.
|
|
47
37
|
|
|
48
|
-
|
|
38
|
+
Validation of catalogs, version configs and registry lists is owned by `@serve.zone/interfaces` (`appstore.validateAppStoreCatalog`, `appstore.validateAppStoreVersionConfig`, `appstore.validateAppStoreCatalogRegistries`).
|
|
49
39
|
|
|
50
40
|
## License and Legal Information
|
|
51
41
|
|