@serve.zone/interfaces 32.47.0 → 32.49.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.
@@ -666,7 +666,8 @@ export const validateRuntimeNetworkProjection = async (value: unknown): Promise<
666
666
  }
667
667
  const members = new Map(projection.memberships.map(member => [ownerKey(member), member])), aliases = new Set<string>();
668
668
  for (const member of projection.memberships) {
669
- composeServicePrivateNetworkDnsPolicy(member, member.attachments.map(attachment => networks.get(attachment.networkId)));
669
+ composeServicePrivateNetworkDnsPolicy(member, member.attachments.map(attachment => networks.get(attachment.networkId)),
670
+ current.resolvers);
670
671
  for (const attachment of member.attachments) for (const alias of attachment.aliases) {
671
672
  const name = buildPrivateNetworkFqdn(networks.get(attachment.networkId), alias);
672
673
  requireNetwork(!aliases.has(name)); aliases.add(name);
@@ -756,7 +757,8 @@ export const composeRuntimeNetworkProjectionDnsViews = async (
756
757
  const members = new Map(projection.memberships.map(member => [ownerKey(member), member]));
757
758
  return local.filter(endpoint => selected === null || selected.some(source => networkEqual(networkReferenceOf(endpoint.lease), source))).map(endpoint => {
758
759
  const member = members.get(ownerKey(endpoint.lease))!;
759
- const policy = composeServicePrivateNetworkDnsPolicy(member, member.attachments.map(attachment => networks.get(attachment.networkId)));
760
+ const policy = composeServicePrivateNetworkDnsPolicy(member,
761
+ member.attachments.map(attachment => networks.get(attachment.networkId)), projection.protectedAuthority.resolvers);
760
762
  const records: IRuntimeNetworkDnsView['records'] = [];
761
763
  for (const target of projection.memberships) {
762
764
  if (target.organizationId !== member.organizationId) continue;
@@ -2,6 +2,7 @@ import {
2
2
  readDataElements, readDataProperty, readExactDataRecord,
3
3
  } from '../private/dataproperties.js';
4
4
  import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
5
+ import { runtimeNetworkAuthorityContract } from './networkauthority.js';
5
6
 
6
7
  /** Cloudly owns these definitions. Membership never follows node placement. */
7
8
  export interface IPrivateNetworkPolicy {
@@ -126,6 +127,13 @@ const ipv4 = (value: unknown): string => {
126
127
  requireValue(isPlausibleUnicastIpv4(value));
127
128
  return value;
128
129
  };
130
+ const endpoint = (value: Record<string, unknown>): { address: string; port: number } => {
131
+ const port = counter(value.port);
132
+ requireValue(port <= 65535);
133
+ return { address: ipv4(value.address), port };
134
+ };
135
+ const byEndpoint = (left: { address: string; port: number }, right: { address: string; port: number }): number =>
136
+ left.address < right.address ? -1 : left.address > right.address ? 1 : left.port - right.port;
129
137
  const publicSuffix = (value: unknown): string => {
130
138
  requireValue(typeof value === 'string' && value.length <= 253 && value.endsWith('.'));
131
139
  const labels = value.slice(0, -1).split('.');
@@ -141,12 +149,8 @@ export const snapshotPrivateNetworkPolicy = (value: unknown): IPrivateNetworkPol
141
149
  requireValue(policy.connectivity === 'member-full-mesh');
142
150
  if (policy.externalDns === null) return { connectivity: 'member-full-mesh', externalDns: null };
143
151
  const external = object(policy.externalDns, ['upstreams', 'allowAnyPublicName', 'permittedSuffixes']);
144
- const upstreams = array(external.upstreams, privateNetworkContract.maximumUpstreams, entry => {
145
- const upstream = object(entry, ['address', 'port']);
146
- const port = counter(upstream.port);
147
- requireValue(port <= 65535);
148
- return { address: ipv4(upstream.address), port };
149
- });
152
+ const upstreams = array(external.upstreams, privateNetworkContract.maximumUpstreams,
153
+ entry => endpoint(object(entry, ['address', 'port'])));
150
154
  requireValue(upstreams.length > 0 && new Set(upstreams.map(upstream => `${upstream.address}:${upstream.port}`)).size === upstreams.length);
151
155
  requireValue(typeof external.allowAnyPublicName === 'boolean');
152
156
  const permittedSuffixes = sorted(array(external.permittedSuffixes, privateNetworkContract.maximumPermittedSuffixes, publicSuffix));
@@ -223,6 +227,11 @@ export interface IServicePrivateNetworkDnsPolicy {
223
227
  networkSuffixes: Array<{ networkId: string; networkRevision: number; dnsSuffix: string }>;
224
228
  /** Empty or exactly the selected default network; never an ordered network union. */
225
229
  searchDomains: string[];
230
+ /**
231
+ * The networks' intersection for an attached service. A service attached to no
232
+ * network forwards any public name to the authority's resolvers, or has none
233
+ * when the authority declares no resolver.
234
+ */
226
235
  externalDns: {
227
236
  upstreams: Array<{ address: string; port: number }>;
228
237
  allowAnyPublicName: boolean;
@@ -243,12 +252,23 @@ const minimalSuffixes = (values: string[]): string[] => {
243
252
  * membership authentication, endpoint readiness, a DNS lease or a packet grant.
244
253
  * The caller must fence these exact revisions before issuing runtime authority.
245
254
  * An inactive/missing/extra network is rejected, never silently omitted.
255
+ *
256
+ * `resolvers` is the complete resolver list of the protected authority the
257
+ * membership is projected under. It decides external resolution only for a
258
+ * service attached to no private network: such a service forwards any public
259
+ * name to the first `maximumUpstreams` of those resolvers in address and port
260
+ * order, and an authority declaring none leaves it without external resolution.
261
+ * An attached service is decided by its networks alone, so a network denying
262
+ * external DNS keeps denying it whatever the authority declares.
246
263
  */
247
264
  export const composeServicePrivateNetworkDnsPolicy = (
248
- membershipValue: unknown, networksValue: unknown,
265
+ membershipValue: unknown, networksValue: unknown, resolversValue: unknown,
249
266
  ): IServicePrivateNetworkDnsPolicy => safe(() => {
250
267
  const member = snapshotServicePrivateNetworks(membershipValue);
251
268
  const networks = array(networksValue, privateNetworkContract.maximumAttachments, snapshotPrivateNetwork);
269
+ const resolvers = array(resolversValue, runtimeNetworkAuthorityContract.maximumResolvers,
270
+ entry => endpoint(object(entry, ['id', 'address', 'port']))).sort(byEndpoint);
271
+ requireValue(new Set(resolvers.map(resolver => `${resolver.address}:${resolver.port}`)).size === resolvers.length);
252
272
  requireValue(networks.length === member.attachments.length
253
273
  && new Set(networks.map(network => network.id)).size === networks.length
254
274
  && new Set(networks.map(network => network.networkDnsKey)).size === networks.length
@@ -261,11 +281,15 @@ export const composeServicePrivateNetworkDnsPolicy = (
261
281
  const searchDomains = member.defaultNetworkId === null ? []
262
282
  : [networkSuffixes.find(network => network.networkId === member.defaultNetworkId)!.dnsSuffix];
263
283
  let externalDns: IServicePrivateNetworkDnsPolicy['externalDns'] = null;
264
- if (ordered.length > 0 && ordered.every(network => network.policy.externalDns !== null)) {
265
- const policies = ordered.map(network => network.policy.externalDns!);
284
+ const policies: Array<NonNullable<IPrivateNetworkPolicy['externalDns']>> = [];
285
+ for (const network of ordered) if (network.policy.externalDns !== null) policies.push(network.policy.externalDns);
286
+ if (ordered.length === 0 && resolvers.length > 0) {
287
+ externalDns = { upstreams: resolvers.slice(0, privateNetworkContract.maximumUpstreams),
288
+ allowAnyPublicName: true, permittedSuffixes: [] };
289
+ } else if (ordered.length > 0 && policies.length === ordered.length) {
266
290
  const upstreams = policies[0].upstreams.filter(upstream => policies.every(policy => policy.upstreams.some(
267
291
  candidate => candidate.address === upstream.address && candidate.port === upstream.port,
268
- ))).sort((left, right) => left.address < right.address ? -1 : left.address > right.address ? 1 : left.port - right.port);
292
+ ))).sort(byEndpoint);
269
293
  // null is the unrestricted public-name set during intersection, not denial.
270
294
  let permittedSuffixes: string[] | null = null;
271
295
  for (const policy of policies) {
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A key list that names every key of a record type, so a member added to the type cannot be
3
+ * forgotten by a validator built on the list: an incomplete list does not type-check.
4
+ */
5
+ export type TCompleteKeyList<TRecord, TKeys extends readonly (keyof TRecord)[]> =
6
+ Exclude<keyof TRecord, TKeys[number]> extends never ? TKeys : never;
@@ -4,6 +4,7 @@ import type { IService } from '../data/service.js';
4
4
  import type {
5
5
  IAppStoreApp,
6
6
  IAppStoreAppMeta,
7
+ TAppStoreCatalogStatus,
7
8
  IAppStoreInstallRequest,
8
9
  IAppStoreVersionConfig,
9
10
  IUpgradeableAppStoreService,
@@ -71,6 +72,43 @@ export interface IReq_Any_GetAppStoreTemplates extends plugins.typedrequestInter
71
72
  };
72
73
  response: {
73
74
  apps: IAppStoreApp[];
75
+ /**
76
+ * The catalog state behind `apps`. Optional while consumers still serve a bundled catalog;
77
+ * a consumer that loads the catalog at runtime always answers it.
78
+ */
79
+ catalogStatus?: TAppStoreCatalogStatus;
80
+ };
81
+ }
82
+
83
+ /** The App Store catalog's state: availability, the active catalog, the last attempt and refusal. */
84
+ export interface IReq_Any_GetAppStoreCatalogStatus extends plugins.typedrequestInterfaces.implementsTR<
85
+ plugins.typedrequestInterfaces.ITypedRequest,
86
+ IReq_Any_GetAppStoreCatalogStatus
87
+ > {
88
+ method: 'getAppStoreCatalogStatus';
89
+ request: {
90
+ identity: IIdentity;
91
+ };
92
+ response: {
93
+ status: TAppStoreCatalogStatus;
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Runs one bounded catalog load attempt now, outside the refresh schedule, and answers the state
99
+ * after it. The attempt's outcome is `status.lastAttempt`; a refused or failed attempt keeps the
100
+ * active catalog in place and is not a request error.
101
+ */
102
+ export interface IReq_Any_RefreshAppStoreCatalog extends plugins.typedrequestInterfaces.implementsTR<
103
+ plugins.typedrequestInterfaces.ITypedRequest,
104
+ IReq_Any_RefreshAppStoreCatalog
105
+ > {
106
+ method: 'refreshAppStoreCatalog';
107
+ request: {
108
+ identity: IIdentity;
109
+ };
110
+ response: {
111
+ status: TAppStoreCatalogStatus;
74
112
  };
75
113
  }
76
114
 
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Ed25519 signing and verification of App Store catalog envelopes (Node-only).
3
+ *
4
+ * A consumer authenticates before it interprets: the envelope's digest and signature are checked
5
+ * before the catalog's contract and schema are read.
6
+ */
7
+ import * as plugins from './plugins.runtime.js';
8
+ import type { TSha256Digest } from './data/immutableimage.js';
9
+ import { isSha256Digest } from './data/immutableimage.js';
10
+ import { canonicalizeStrictJson } from './private/canonicaljson.js';
11
+ import { readDataElements, readDataProperties } from './private/dataproperties.js';
12
+ import {
13
+ appStoreCatalogContract,
14
+ computeAppStoreCatalogDigest,
15
+ computeAppStoreCatalogSigningKeyId,
16
+ createAppStoreCatalogSignaturePayload,
17
+ findAppStoreCatalogConfigDigestMismatch,
18
+ isAppStoreCatalogEnvelope,
19
+ judgeAppStoreCatalogContract,
20
+ validateAppStoreCatalog,
21
+ validateAppStoreCatalogEnvelope,
22
+ validateAppStoreCatalogEnvelopeShape,
23
+ } from './appstore/catalog.js';
24
+ import type {
25
+ IAppStoreCatalog,
26
+ IAppStoreCatalogEnvelope,
27
+ IAppStoreCatalogRefusal,
28
+ IAppStoreCatalogSignature,
29
+ IAppStoreCatalogTrustedKey,
30
+ IAppStoreCatalogValidationOptions,
31
+ TAppStoreCatalogRefusalReason,
32
+ } from './appstore/catalog.js';
33
+
34
+ /** A catalog signing key. The private half is process-local; keep it in the publisher's secret store. */
35
+ export interface IAppStoreCatalogSigningKey {
36
+ keyId: TSha256Digest;
37
+ /** Raw 32-byte Ed25519 public key, lower-case hex. */
38
+ publicKey: string;
39
+ privateKey: plugins.crypto.KeyObject;
40
+ }
41
+
42
+ export interface IAppStoreCatalogVerificationOptions extends IAppStoreCatalogValidationOptions {
43
+ /** The keys this consumer trusts, shipped with its release. */
44
+ trustedKeys: readonly IAppStoreCatalogTrustedKey[];
45
+ }
46
+
47
+ export type TAppStoreCatalogVerification =
48
+ | { accepted: true; envelope: IAppStoreCatalogEnvelope; keyId: TSha256Digest }
49
+ | { accepted: false; refusal: IAppStoreCatalogRefusal };
50
+
51
+ const rawPublicKeyOf = (keyArg: plugins.crypto.KeyObject): string => {
52
+ const jwk = keyArg.export({ format: 'jwk' });
53
+ if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || typeof jwk.x !== 'string') {
54
+ throw new Error('App Store catalog signing key must be an Ed25519 key');
55
+ }
56
+ return Buffer.from(jwk.x, 'base64url').toString('hex');
57
+ };
58
+
59
+ const publicKeyObjectOf = (publicKeyArg: string): plugins.crypto.KeyObject =>
60
+ plugins.crypto.createPublicKey({
61
+ format: 'jwk',
62
+ key: { kty: 'OKP', crv: 'Ed25519', x: Buffer.from(publicKeyArg, 'hex').toString('base64url') },
63
+ });
64
+
65
+ /** A fresh Ed25519 catalog signing key with its key id. */
66
+ export const createAppStoreCatalogSigningKey = async (): Promise<IAppStoreCatalogSigningKey> => {
67
+ const pair = plugins.crypto.generateKeyPairSync('ed25519');
68
+ const publicKey = rawPublicKeyOf(pair.publicKey);
69
+ return { keyId: await computeAppStoreCatalogSigningKeyId(publicKey), publicKey, privateKey: pair.privateKey };
70
+ };
71
+
72
+ /**
73
+ * Signs only a catalog this contract accepts, whose every `configDigest` matches its config.
74
+ *
75
+ * `optionsArg.supportedFeatureIds` should name every feature id the catalog uses, so the
76
+ * publisher judges every version config in full.
77
+ */
78
+ export const signAppStoreCatalog = async (
79
+ catalogArg: IAppStoreCatalog,
80
+ signingKeysArg: readonly IAppStoreCatalogSigningKey[],
81
+ optionsArg: IAppStoreCatalogValidationOptions,
82
+ ): Promise<IAppStoreCatalogEnvelope> => {
83
+ const errors = validateAppStoreCatalog(catalogArg, optionsArg);
84
+ if (errors.length > 0) {
85
+ throw new Error(`App Store catalog refused for signing: ${errors.join('; ')}`);
86
+ }
87
+ const mismatch = await findAppStoreCatalogConfigDigestMismatch(catalogArg);
88
+ if (mismatch !== null) throw new Error(`App Store catalog refused for signing: ${mismatch}`);
89
+ if (signingKeysArg.length === 0) throw new Error('App Store catalog needs at least one signing key');
90
+ const catalogDigest = await computeAppStoreCatalogDigest(catalogArg);
91
+ const payload = createAppStoreCatalogSignaturePayload(catalogDigest);
92
+ const signatures: IAppStoreCatalogSignature[] = [];
93
+ for (const key of signingKeysArg) {
94
+ const { privateKey } = key;
95
+ if (!(privateKey instanceof plugins.crypto.KeyObject) || privateKey.type !== 'private'
96
+ || privateKey.asymmetricKeyType !== 'ed25519') {
97
+ throw new Error('App Store catalog signing key must be an Ed25519 private key');
98
+ }
99
+ const publicKey = rawPublicKeyOf(plugins.crypto.createPublicKey(privateKey));
100
+ if (publicKey !== key.publicKey || await computeAppStoreCatalogSigningKeyId(publicKey) !== key.keyId) {
101
+ throw new Error(`App Store catalog signing key ${key.keyId} does not match its private key`);
102
+ }
103
+ signatures.push({
104
+ keyId: key.keyId,
105
+ signature: plugins.crypto.sign(null, payload, privateKey).toString('hex'),
106
+ });
107
+ }
108
+ return { catalog: structuredClone(catalogArg), catalogDigest, signatures };
109
+ };
110
+
111
+ const refuse = (reasonArg: TAppStoreCatalogRefusalReason, messageArg: string): TAppStoreCatalogVerification =>
112
+ ({ accepted: false, refusal: { reason: reasonArg, message: messageArg } });
113
+
114
+ const failCanonicalInput = (reasonArg: string): never => {
115
+ throw new Error(reasonArg);
116
+ };
117
+
118
+ /**
119
+ * Judges an envelope a consumer fetched or an operator imported.
120
+ *
121
+ * In order: exact envelope shape, `catalogDigest` against the catalog, one valid signature by a
122
+ * trusted key (and no failing one), the contract identity, the catalog schema, every
123
+ * `configDigest`. The accepted envelope is an inert copy of the input. Succession against the
124
+ * active catalog is the caller's next step (`judgeAppStoreCatalogSuccession`).
125
+ */
126
+ export const verifyAppStoreCatalogEnvelope = async (
127
+ envelopeArg: unknown,
128
+ optionsArg: IAppStoreCatalogVerificationOptions,
129
+ ): Promise<TAppStoreCatalogVerification> => {
130
+ let inert: unknown;
131
+ try {
132
+ inert = JSON.parse(canonicalizeStrictJson(envelopeArg, failCanonicalInput, 'envelope'));
133
+ } catch (error) {
134
+ return refuse('schemaInvalid', error instanceof Error ? error.message : 'envelope is not canonical JSON');
135
+ }
136
+ const shapeErrors = validateAppStoreCatalogEnvelopeShape(inert);
137
+ const envelope = readDataProperties(inert);
138
+ const signatures = envelope === null
139
+ ? null
140
+ : readDataElements(envelope.signatures, appStoreCatalogContract.maximumSignatures);
141
+ if (shapeErrors.length > 0 || envelope === null || signatures === null
142
+ || typeof envelope.catalogDigest !== 'string' || !isSha256Digest(envelope.catalogDigest)) {
143
+ return refuse('schemaInvalid', shapeErrors.join('; ') || 'envelope must be an object');
144
+ }
145
+
146
+ if (await computeAppStoreCatalogDigest(envelope.catalog) !== envelope.catalogDigest) {
147
+ return refuse('digestMismatch', 'catalogDigest does not match the catalog');
148
+ }
149
+ const trustedKeys = new Map<string, string>();
150
+ for (const trustedKey of optionsArg.trustedKeys) {
151
+ let keyId: TSha256Digest;
152
+ try {
153
+ keyId = await computeAppStoreCatalogSigningKeyId(trustedKey.publicKey);
154
+ } catch {
155
+ return refuse('signatureInvalid', `trusted key ${trustedKey.keyId} is not a raw Ed25519 public key`);
156
+ }
157
+ if (keyId !== trustedKey.keyId) {
158
+ return refuse('signatureInvalid', `trusted key ${trustedKey.keyId} does not match its public key`);
159
+ }
160
+ trustedKeys.set(keyId, trustedKey.publicKey);
161
+ }
162
+ const payload = createAppStoreCatalogSignaturePayload(envelope.catalogDigest);
163
+ let verifiedKeyId: TSha256Digest | null = null;
164
+ for (const signatureArg of signatures) {
165
+ const signature = readDataProperties(signatureArg);
166
+ if (signature === null || typeof signature.keyId !== 'string' || !isSha256Digest(signature.keyId)
167
+ || typeof signature.signature !== 'string') {
168
+ return refuse('schemaInvalid', 'envelope.signatures entries must be objects');
169
+ }
170
+ const publicKey = trustedKeys.get(signature.keyId);
171
+ if (publicKey === undefined) continue;
172
+ const valid = plugins.crypto.verify(
173
+ null,
174
+ payload,
175
+ publicKeyObjectOf(publicKey),
176
+ Buffer.from(signature.signature, 'hex'),
177
+ );
178
+ if (!valid) {
179
+ return refuse('signatureInvalid', `signature by trusted key ${signature.keyId} does not verify`);
180
+ }
181
+ verifiedKeyId = signature.keyId;
182
+ }
183
+ if (verifiedKeyId === null) {
184
+ return refuse('signatureInvalid', 'no signature by a trusted key');
185
+ }
186
+
187
+ const contractRefusal = judgeAppStoreCatalogContract(envelope.catalog);
188
+ if (contractRefusal !== null) return { accepted: false, refusal: contractRefusal };
189
+ if (!isAppStoreCatalogEnvelope(inert, optionsArg)) {
190
+ return refuse('schemaInvalid', validateAppStoreCatalogEnvelope(inert, optionsArg).join('; '));
191
+ }
192
+ const mismatch = await findAppStoreCatalogConfigDigestMismatch(inert.catalog);
193
+ if (mismatch !== null) return refuse('digestMismatch', mismatch);
194
+ return { accepted: true, envelope: inert, keyId: verifiedKeyId };
195
+ };
package/ts/runtime.ts CHANGED
@@ -46,6 +46,7 @@ export * from './runtime.corestore.js';
46
46
  export * from './runtime.corestore.databaseexportpackage.js';
47
47
  export * from './runtime.corestore.relay.js';
48
48
  export * from './runtime.assignmentsecret.js';
49
+ export * from './runtime.appstorecatalog.js';
49
50
 
50
51
  export interface ISealedResolvedSecretMaterialEntry {
51
52
  secretVersionId: string;