@serve.zone/appstore 32.27.0 → 33.1.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 (125) hide show
  1. package/.smartconfig.json +4 -1
  2. package/changelog.md +70 -0
  3. package/dist_catalog/catalog.digest +1 -0
  4. package/dist_catalog/catalog.envelope.json +7617 -0
  5. package/{dist_ts_client/data.generated.js → dist_catalog/catalog.json} +6912 -6908
  6. package/dist_ts_client/catalog.d.ts +16 -0
  7. package/dist_ts_client/catalog.js +12 -0
  8. package/dist_ts_client/index.d.ts +3 -1
  9. package/dist_ts_client/index.js +4 -2
  10. package/dist_ts_client/loader.d.ts +69 -0
  11. package/dist_ts_client/loader.js +361 -0
  12. package/dist_ts_client/plugins.d.ts +5 -0
  13. package/dist_ts_client/plugins.js +8 -0
  14. package/dist_ts_client/projection.d.ts +59 -0
  15. package/dist_ts_client/projection.js +83 -0
  16. package/dist_ts_client/storage.d.ts +15 -9
  17. package/dist_ts_client/storage.js +81 -657
  18. package/dist_ts_client/types.d.ts +86 -41
  19. package/dist_ts_client/types.js +1 -1
  20. package/dist_ts_testing/fixtures.d.ts +83 -0
  21. package/dist_ts_testing/fixtures.js +146 -0
  22. package/dist_ts_testing/index.d.ts +1 -0
  23. package/dist_ts_testing/index.js +2 -0
  24. package/dist_ts_testing/plugins.d.ts +7 -0
  25. package/dist_ts_testing/plugins.js +11 -0
  26. package/package.json +16 -11
  27. package/readme.md +162 -93
  28. package/ts_client/catalog.ts +26 -0
  29. package/ts_client/index.ts +3 -1
  30. package/ts_client/loader.ts +414 -0
  31. package/ts_client/plugins.ts +10 -0
  32. package/ts_client/projection.ts +148 -0
  33. package/ts_client/readme.md +17 -27
  34. package/ts_client/storage.ts +93 -882
  35. package/ts_client/types.ts +102 -42
  36. package/ts_testing/fixtures.ts +230 -0
  37. package/ts_testing/index.ts +1 -0
  38. package/ts_testing/plugins.ts +15 -0
  39. package/ts_testing/tspublish.json +3 -0
  40. package/apps/adminer/app.json +0 -10
  41. package/apps/adminer/versions/1.0.0/config.json +0 -6
  42. package/apps/cloudly/app.json +0 -14
  43. package/apps/cloudly/versions/1.0.0/config.json +0 -120
  44. package/apps/cloudly/versions/1.1.0/config.json +0 -120
  45. package/apps/cloudly/versions/1.1.1/config.json +0 -120
  46. package/apps/cloudly/versions/1.1.10/config.json +0 -173
  47. package/apps/cloudly/versions/1.1.2/config.json +0 -130
  48. package/apps/cloudly/versions/1.1.3/config.json +0 -130
  49. package/apps/cloudly/versions/1.1.4/config.json +0 -130
  50. package/apps/cloudly/versions/1.1.5/config.json +0 -149
  51. package/apps/cloudly/versions/1.1.6/config.json +0 -149
  52. package/apps/cloudly/versions/1.1.7/config.json +0 -149
  53. package/apps/cloudly/versions/1.1.8/config.json +0 -173
  54. package/apps/cloudly/versions/1.1.9/config.json +0 -173
  55. package/apps/cloudly/versions/1.10.0/config.json +0 -173
  56. package/apps/cloudly/versions/1.11.0/config.json +0 -173
  57. package/apps/cloudly/versions/1.12.0/config.json +0 -173
  58. package/apps/cloudly/versions/1.13.0/config.json +0 -173
  59. package/apps/cloudly/versions/1.14.0/config.json +0 -173
  60. package/apps/cloudly/versions/1.15.0/config.json +0 -173
  61. package/apps/cloudly/versions/1.16.0/config.json +0 -173
  62. package/apps/cloudly/versions/1.17.0/config.json +0 -173
  63. package/apps/cloudly/versions/1.18.0/config.json +0 -173
  64. package/apps/cloudly/versions/1.19.0/config.json +0 -173
  65. package/apps/cloudly/versions/1.2.0/config.json +0 -173
  66. package/apps/cloudly/versions/1.2.1/config.json +0 -173
  67. package/apps/cloudly/versions/1.20.0/config.json +0 -173
  68. package/apps/cloudly/versions/1.21.0/config.json +0 -173
  69. package/apps/cloudly/versions/1.22.0/config.json +0 -173
  70. package/apps/cloudly/versions/1.23.0/config.json +0 -173
  71. package/apps/cloudly/versions/1.24.0/config.json +0 -173
  72. package/apps/cloudly/versions/1.25.0/config.json +0 -173
  73. package/apps/cloudly/versions/1.26.0/config.json +0 -173
  74. package/apps/cloudly/versions/1.27.0/config.json +0 -173
  75. package/apps/cloudly/versions/1.3.0/config.json +0 -173
  76. package/apps/cloudly/versions/1.4.0/config.json +0 -173
  77. package/apps/cloudly/versions/1.5.0/config.json +0 -173
  78. package/apps/cloudly/versions/1.6.0/config.json +0 -173
  79. package/apps/cloudly/versions/1.7.0/config.json +0 -173
  80. package/apps/cloudly/versions/1.8.0/config.json +0 -173
  81. package/apps/cloudly/versions/1.9.0/config.json +0 -173
  82. package/apps/ghost/app.json +0 -10
  83. package/apps/ghost/versions/1.0.0/config.json +0 -18
  84. package/apps/gitea/app.json +0 -10
  85. package/apps/gitea/versions/1.0.0/config.json +0 -16
  86. package/apps/gitops/app.json +0 -14
  87. package/apps/gitops/versions/1.0.0/config.json +0 -28
  88. package/apps/grafana/app.json +0 -10
  89. package/apps/grafana/versions/1.0.0/config.json +0 -9
  90. package/apps/mariadb/app.json +0 -10
  91. package/apps/mariadb/versions/1.0.0/config.json +0 -9
  92. package/apps/mattermost/app.json +0 -10
  93. package/apps/mattermost/versions/1.0.0/config.json +0 -6
  94. package/apps/n8n/app.json +0 -10
  95. package/apps/n8n/versions/1.0.0/config.json +0 -6
  96. package/apps/nextcloud/app.json +0 -10
  97. package/apps/nextcloud/versions/1.0.0/config.json +0 -16
  98. package/apps/nginx/app.json +0 -10
  99. package/apps/nginx/versions/1.0.0/config.json +0 -6
  100. package/apps/openbao/app.json +0 -16
  101. package/apps/openbao/versions/1.0.0/config.json +0 -52
  102. package/apps/plausible/app.json +0 -10
  103. package/apps/plausible/versions/1.0.0/config.json +0 -9
  104. package/apps/portainer/app.json +0 -10
  105. package/apps/portainer/versions/1.0.0/config.json +0 -6
  106. package/apps/postgres/app.json +0 -10
  107. package/apps/postgres/versions/1.0.0/config.json +0 -11
  108. package/apps/rustdesk-server/app.json +0 -14
  109. package/apps/rustdesk-server/versions/1.0.0/config.json +0 -19
  110. package/apps/siprouter/app.json +0 -14
  111. package/apps/siprouter/versions/1.0.0/config.json +0 -84
  112. package/apps/uptime-kuma/app.json +0 -10
  113. package/apps/uptime-kuma/versions/1.0.0/config.json +0 -6
  114. package/apps/valkey/app.json +0 -10
  115. package/apps/valkey/versions/1.0.0/config.json +0 -6
  116. package/apps/vaultwarden/app.json +0 -10
  117. package/apps/vaultwarden/versions/1.0.0/config.json +0 -6
  118. package/apps/wordpress/app.json +0 -10
  119. package/apps/wordpress/versions/1.0.0/config.json +0 -15
  120. package/appstore.json +0 -194
  121. package/dist_ts_client/classes.appstoreresolver.d.ts +0 -59
  122. package/dist_ts_client/classes.appstoreresolver.js +0 -736
  123. package/dist_ts_client/data.generated.d.ts +0 -2
  124. package/ts_client/classes.appstoreresolver.ts +0 -896
  125. package/ts_client/data.generated.ts +0 -7606
@@ -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]);
@@ -1,6 +1,6 @@
1
1
  # @serve.zone/appstore Client
2
2
 
3
- This module exports the TypeScript resolver client for the serve.zone App Store. It reads the App Store data bundled into the published `@serve.zone/appstore` package by default and can use an explicit remote source for tests or controlled runtime integrations.
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 { AppStoreResolver } from '@serve.zone/appstore';
19
-
20
- const resolver = new AppStoreResolver();
21
- const appStore = await resolver.getAppStoreIndex();
22
- const apps = await resolver.getApps();
23
- const cloudlyMeta = await resolver.getAppMeta('cloudly');
24
- const cloudlyConfig = await resolver.getAppVersionConfig('cloudly', cloudlyMeta.latestVersion);
25
- const sourceInfo = resolver.getSourceInfo();
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
- - `AppStoreResolver` resolves App Store indexes, app metadata, version configs, repo manifests, and Docker digest-tracked sources.
44
- - `appStorePackageMetadata` exposes the package name and version embedded into the generated package data.
45
- - `appStoreDefaultRemoteBaseUrl` exposes the default remote base URL for explicit remote consumers.
46
- - `IAppStoreResolverOptions`, `IAppStoreSourceInfo`, and the `TAppStore*` types describe resolver inputs and returned App Store data shapes.
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
- The resolver rejects inconsistent catalogs before returning them: index and app metadata IDs/latest versions must agree, metadata `latestVersion` must occur in `versions`, and an all-semver list must name its highest semantic value as latest. A repo manifest with multiple non-semver versions must declare explicit latest or channel intent.
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