@serve.zone/interfaces 32.20.0 → 32.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026-09-25 - 32.21.0
4
+
5
+ ### Features
6
+
7
+ - Add gateway-client hostname records and certificates that need no gateway route.
8
+ - `syncGatewayClientDnsRecord` (`IReq_SyncGatewayClientDnsRecord`) publishes, replaces or
9
+ withdraws the one `A` or `AAAA` record a `IGatewayClientHostnameOwnership` (`appId`,
10
+ `hostname`) holds, at a value the client chooses. The value must lie inside the new
11
+ gateway-client policy field `allowedDnsRecordAddresses`, a list of canonical CIDR prefixes on
12
+ `IGatewayClient`, `IGatewayClientProvisioningSpec` and `IGatewayClientContext`. An absent or
13
+ empty list allows no record.
14
+ - `getGatewayClientCertificate` (`IReq_GetGatewayClientCertificate`) answers the exact
15
+ certificate for the owned hostname as `ready` (`IGatewayClientCertificate` with its PEM key
16
+ and `renewAfter`), `pending` or `refused` (`TGatewayClientCertificateRefusal`).
17
+ `releaseGatewayClientCertificate` (`IReq_ReleaseGatewayClientCertificate`) removes that
18
+ material.
19
+ - The three requests accept gateway-client credentials only and reuse the existing token
20
+ capabilities `syncDnsRecords`, `requestCertificates` and `readCertificates`.
21
+ - Gateways advertise support with the optional capability flags `dns.clientRecords` and
22
+ `certificates.exactIssuance`.
23
+ - `TGatewayClientDnsRecordStatus` reuses the route DNS vocabulary, and
24
+ `gatewayClientHostnameContract` states the TTL, allowance and length bounds.
25
+ - Add the shared validators `validateGatewayClientHostnameOwnership`,
26
+ `validateGatewayClientDnsRecord`, `validateGatewayAllowedDnsRecordAddresses`,
27
+ `isGatewayClientDnsRecordAllowed`, `isGatewayAddressPrefix` and `isGatewayClientHostname`.
28
+ They accept only the canonical spelling of each address: the dotted quad, or RFC 5952 text
29
+ for IPv6.
30
+ - Every addition is optional or new, so no existing shape narrows.
31
+
32
+ ## 2026-09-24 - 32.20.1
33
+
34
+ ### Maintenance
35
+
36
+ - Update `@push.rocks/smartlog` `^3.2.2` → `^4.0.0`. The package exports only the
37
+ `@push.rocks/smartlog/interfaces` types (`ILogPackage`), which are unchanged in 4.0.0, and
38
+ 4.0.0 no longer depends on the deprecated `@push.rocks/smartclickhouse`, so that package leaves
39
+ the production dependency tree of every consumer.
40
+
3
41
  ## 2026-09-24 - 32.20.0
4
42
 
5
43
  ### Features
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.20.0',
6
+ version: '32.21.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  };
9
9
  //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSx3QkFBd0I7SUFDOUIsT0FBTyxFQUFFLFNBQVM7SUFDbEIsV0FBVyxFQUFFLHVGQUF1RjtDQUNyRyxDQUFBIn0=
@@ -90,6 +90,11 @@ export interface IGatewayCapabilities {
90
90
  read: boolean;
91
91
  export: boolean;
92
92
  forceRenew: boolean;
93
+ /**
94
+ * `getGatewayClientCertificate` and `releaseGatewayClientCertificate` are served: an exact
95
+ * certificate for one owned hostname, with no route behind it. Absent means unsupported.
96
+ */
97
+ exactIssuance?: boolean;
93
98
  };
94
99
  email: {
95
100
  domains: boolean;
@@ -102,6 +107,11 @@ export interface IGatewayCapabilities {
102
107
  dns: {
103
108
  authoritative: boolean;
104
109
  providerManaged: boolean;
110
+ /**
111
+ * `syncGatewayClientDnsRecord` is served: an address record at a value the client chooses
112
+ * inside its `allowedDnsRecordAddresses`. Absent means unsupported.
113
+ */
114
+ clientRecords?: boolean;
105
115
  };
106
116
  http3: {
107
117
  enabled: boolean;
@@ -148,6 +158,11 @@ export interface IGatewayClient {
148
158
  /** `*` matches any hostname under a managed zone; `*.zone` matches exactly one label. */
149
159
  hostnamePatterns: string[];
150
160
  allowedRouteTargets: IGatewayRouteTargetAllowance[];
161
+ /**
162
+ * Canonical address prefixes the client's own address records may point into; see
163
+ * `validateGatewayAllowedDnsRecordAddresses`. Absent or empty allows no address record.
164
+ */
165
+ allowedDnsRecordAddresses?: string[];
151
166
  capabilities: IGatewayTokenCapabilities;
152
167
  enabled: boolean;
153
168
  tokenCount?: number;
@@ -166,6 +181,11 @@ export interface IGatewayClientProvisioningSpec {
166
181
  /** `*` matches any hostname under a managed zone; `*.zone` matches exactly one label. */
167
182
  hostnamePatterns: string[];
168
183
  allowedRouteTargets: IGatewayRouteTargetAllowance[];
184
+ /**
185
+ * Canonical address prefixes the client's own address records may point into; see
186
+ * `validateGatewayAllowedDnsRecordAddresses`. Absent or empty allows no address record.
187
+ */
188
+ allowedDnsRecordAddresses?: string[];
169
189
  capabilities: IGatewayTokenCapabilities;
170
190
  enabled?: boolean;
171
191
  }
@@ -194,6 +214,8 @@ interface IGatewayClientContextBase {
194
214
  /** `*` matches any hostname under a managed zone; `*.zone` matches exactly one label. */
195
215
  hostnamePatterns: string[];
196
216
  allowedRouteTargets: IGatewayRouteTargetAllowance[];
217
+ /** Absent on gateways that predate address records; read it as an empty allowance. */
218
+ allowedDnsRecordAddresses?: string[];
197
219
  capabilities: IGatewayTokenCapabilities;
198
220
  }
199
221
  /** Server-authoritative lifecycle of an authenticated gateway-client credential. */
@@ -410,4 +432,151 @@ export interface IGatewayRouteSyncResult {
410
432
  dns?: IGatewayRouteDnsResult;
411
433
  message?: string;
412
434
  }
435
+ /**
436
+ * Bounds of the gateway-client address record and certificate contracts.
437
+ *
438
+ * A gateway applies `defaultTtl` to a record that names none, and refuses a TTL outside
439
+ * `minTtl`..`maxTtl` as it refuses an allowance list longer than `maxAllowedDnsRecordAddresses`.
440
+ */
441
+ export declare const gatewayClientHostnameContract: Readonly<{
442
+ minTtl: 60;
443
+ maxTtl: 86400;
444
+ defaultTtl: 300;
445
+ maxAllowedDnsRecordAddresses: 64;
446
+ maxAppIdLength: 128;
447
+ maxHostnameLength: 253;
448
+ }>;
449
+ /**
450
+ * What one gateway client owns a hostname for: its app and the exact hostname.
451
+ *
452
+ * The gateway client itself is never named here. The gateway resolves it from the authenticating
453
+ * gateway-client credential, so a client cannot act for another one. One ownership holds at most
454
+ * one address record and one certificate for its hostname, and a hostname is held by at most one
455
+ * ownership: a second client or app, a gateway route claim, or a record the gateway does not
456
+ * manage makes the hostname a conflict rather than a shared name.
457
+ */
458
+ export interface IGatewayClientHostnameOwnership {
459
+ /** 1 to 128 characters of letters, digits, `.`, `_`, `:` or `-`, starting with a letter or digit. */
460
+ appId: string;
461
+ /** Lowercase fully qualified hostname without a trailing dot and without a wildcard. */
462
+ hostname: string;
463
+ }
464
+ /** Address record types a gateway client may publish at a value it chooses. */
465
+ export type TGatewayClientDnsRecordType = 'A' | 'AAAA';
466
+ /**
467
+ * One address record a gateway client asks the gateway to publish for its hostname.
468
+ *
469
+ * The value is the canonical literal of the record's family (a dotted quad for `A`, RFC 5952 text
470
+ * for `AAAA`) and must lie inside the client's `allowedDnsRecordAddresses`. The record is never
471
+ * proxied: a client that chooses its own address asks for exactly that address to be served.
472
+ */
473
+ export interface IGatewayClientDnsRecord {
474
+ type: TGatewayClientDnsRecordType;
475
+ value: string;
476
+ /** Seconds, `minTtl`..`maxTtl`; `defaultTtl` when omitted. */
477
+ ttl?: number;
478
+ }
479
+ /**
480
+ * Outcome of one address record synchronization, in the gateway route DNS vocabulary.
481
+ *
482
+ * - `managed-created` / `managed-updated` / `managed-unchanged` / `managed-deleted`: done.
483
+ * - `target-invalid`: the value is outside the client's address allowance.
484
+ * - `zone-unmanaged`: no zone the gateway manages holds the hostname.
485
+ * - `zone-unavailable`: the zone's DNS provider or authority cannot be reached; retryable.
486
+ * - `record-conflict`: another ownership, a gateway route claim, or an unmanaged record holds
487
+ * the hostname. The gateway never overwrites or adopts it.
488
+ * - `mutation-failed`: writing the record failed; retryable.
489
+ */
490
+ export type TGatewayClientDnsRecordStatus = Extract<TGatewayRouteDnsStatus, 'managed-created' | 'managed-updated' | 'managed-unchanged' | 'managed-deleted' | 'target-invalid' | 'zone-unmanaged' | 'zone-unavailable' | 'record-conflict' | 'mutation-failed'>;
491
+ /** Result of `syncGatewayClientDnsRecord`. */
492
+ export interface IGatewayClientDnsRecordSyncResult {
493
+ success: boolean;
494
+ /** True when the same request may succeed later without any change on the client's side. */
495
+ retryable: boolean;
496
+ status: TGatewayClientDnsRecordStatus;
497
+ hostname: string;
498
+ zoneName?: string;
499
+ /** The record this ownership holds after the call; absent after a deletion or a refusal. */
500
+ record?: IGatewayRouteDnsRecord;
501
+ /** The records that hold the hostname, as evidence for `record-conflict`. */
502
+ conflictingRecords?: IGatewayRouteDnsRecord[];
503
+ /** Unix epoch milliseconds at which the gateway checked the DNS state. */
504
+ checkedAt: number;
505
+ /** Unix epoch milliseconds at which authoritative DNS confirmed the desired state. */
506
+ authoritativeVerifiedAt?: number;
507
+ message?: string;
508
+ }
509
+ /**
510
+ * The certificate a gateway issued for one owned hostname, with its private key.
511
+ *
512
+ * Secret-bearing: a receiver keeps the key sealed and never logs or caches the response as is.
513
+ */
514
+ export interface IGatewayClientCertificate {
515
+ hostname: string;
516
+ /** PEM leaf certificate followed by its chain. */
517
+ certificatePem: string;
518
+ /** PEM private key of the leaf certificate. */
519
+ privateKeyPem: string;
520
+ /** Unix epoch milliseconds. */
521
+ validFrom: number;
522
+ /** Unix epoch milliseconds. */
523
+ validUntil: number;
524
+ /** Unix epoch milliseconds after which asking again yields a renewed certificate. */
525
+ renewAfter: number;
526
+ }
527
+ /**
528
+ * Why a gateway does not issue a certificate for an ownership.
529
+ *
530
+ * - `zone-unmanaged`: no zone the gateway manages with verified authority holds the hostname.
531
+ * - `hostname-conflict`: another ownership or a gateway route claim holds the hostname.
532
+ * - `issuance-failed`: the certificate authority refused the order; `retryAfter` says when a new
533
+ * order is placed.
534
+ * - `issuance-unavailable`: the gateway's ACME configuration or DNS authority is not ready.
535
+ */
536
+ export type TGatewayClientCertificateRefusal = 'zone-unmanaged' | 'hostname-conflict' | 'issuance-failed' | 'issuance-unavailable';
537
+ /** Result of `getGatewayClientCertificate`. */
538
+ export type TGatewayClientCertificateResult = {
539
+ status: 'ready';
540
+ certificate: IGatewayClientCertificate;
541
+ } | {
542
+ /** An issuance runs; ask again after `retryAfterMs`. */
543
+ status: 'pending';
544
+ hostname: string;
545
+ retryAfterMs: number;
546
+ } | {
547
+ status: 'refused';
548
+ hostname: string;
549
+ code: TGatewayClientCertificateRefusal;
550
+ retryable: boolean;
551
+ /** Unix epoch milliseconds before which asking again cannot change the answer. */
552
+ retryAfter?: number;
553
+ message?: string;
554
+ };
555
+ /** Result of `releaseGatewayClientCertificate`. */
556
+ export type TGatewayClientCertificateReleaseResult = {
557
+ success: true;
558
+ /** `released` when material or an issuance was held; `unchanged` when nothing was. */
559
+ action: 'released' | 'unchanged';
560
+ } | {
561
+ success: false;
562
+ retryable: boolean;
563
+ message: string;
564
+ };
565
+ /** True for a lowercase fully qualified hostname of two or more labels, without wildcard or dot. */
566
+ export declare const isGatewayClientHostname: (valueArg: unknown) => valueArg is string;
567
+ /** Validates one hostname ownership and names every reason it is refused. */
568
+ export declare const validateGatewayClientHostnameOwnership: (valueArg: unknown, pathArg?: string) => string[];
569
+ /** True for one canonical address prefix of either family whose host bits are zero. */
570
+ export declare const isGatewayAddressPrefix: (valueArg: unknown) => valueArg is string;
571
+ /** Validates a client's address allowance and names every reason it is refused. */
572
+ export declare const validateGatewayAllowedDnsRecordAddresses: (valueArg: unknown, pathArg?: string) => string[];
573
+ /** Validates one requested address record and names every reason it is refused. */
574
+ export declare const validateGatewayClientDnsRecord: (valueArg: unknown, pathArg?: string) => string[];
575
+ /**
576
+ * True when a valid record's value lies inside one of the allowance's prefixes of its family.
577
+ *
578
+ * A prefix that is not canonical allows nothing, so an allowance a gateway would refuse never
579
+ * widens what a record may point at.
580
+ */
581
+ export declare const isGatewayClientDnsRecordAllowed: (recordArg: IGatewayClientDnsRecord, allowedDnsRecordAddressesArg: readonly string[] | undefined) => boolean;
413
582
  export {};
@@ -1,2 +1,115 @@
1
- export {};
2
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZ2F0ZXdheS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3RzL2RhdGEvZ2F0ZXdheS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiIn0=
1
+ import { hasExactKeys, readDataElements, readDataProperties } from '../private/dataproperties.js';
2
+ import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
3
+ import { ipPrefixContains, parseCanonicalIpAddress, parseCanonicalIpPrefix, } from '../private/ipaddress.js';
4
+ /**
5
+ * Bounds of the gateway-client address record and certificate contracts.
6
+ *
7
+ * A gateway applies `defaultTtl` to a record that names none, and refuses a TTL outside
8
+ * `minTtl`..`maxTtl` as it refuses an allowance list longer than `maxAllowedDnsRecordAddresses`.
9
+ */
10
+ export const gatewayClientHostnameContract = Object.freeze({
11
+ minTtl: 60,
12
+ maxTtl: 86_400,
13
+ defaultTtl: 300,
14
+ maxAllowedDnsRecordAddresses: 64,
15
+ maxAppIdLength: 128,
16
+ maxHostnameLength: 253,
17
+ });
18
+ const gatewayHostnameLabel = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
19
+ const gatewayAppId = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
20
+ /** True for a lowercase fully qualified hostname of two or more labels, without wildcard or dot. */
21
+ export const isGatewayClientHostname = (valueArg) => (typeof valueArg === 'string'
22
+ && valueArg.length <= gatewayClientHostnameContract.maxHostnameLength
23
+ && valueArg.split('.').length >= 2
24
+ && valueArg.split('.').every((labelArg) => gatewayHostnameLabel.test(labelArg)));
25
+ /** Validates one hostname ownership and names every reason it is refused. */
26
+ export const validateGatewayClientHostnameOwnership = (valueArg, pathArg = 'ownership') => {
27
+ const ownership = readDataProperties(valueArg);
28
+ if (ownership === null || !hasExactKeys(ownership, ['appId', 'hostname'])) {
29
+ return [`${pathArg} must use its exact schema`];
30
+ }
31
+ const errors = [];
32
+ const appId = ownership.appId;
33
+ if (typeof appId !== 'string' || appId.length > gatewayClientHostnameContract.maxAppIdLength
34
+ || !gatewayAppId.test(appId)) {
35
+ errors.push(`${pathArg} appId must be 1 to ${gatewayClientHostnameContract.maxAppIdLength} `
36
+ + 'characters of letters, digits, dot, underscore, colon or hyphen, starting with a letter or digit');
37
+ }
38
+ if (!isGatewayClientHostname(ownership.hostname)) {
39
+ errors.push(`${pathArg} hostname must be a lowercase fully qualified hostname `
40
+ + 'without a trailing dot or wildcard');
41
+ }
42
+ return errors;
43
+ };
44
+ /** True for one canonical address prefix of either family whose host bits are zero. */
45
+ export const isGatewayAddressPrefix = (valueArg) => parseCanonicalIpPrefix(valueArg) !== null;
46
+ /** Validates a client's address allowance and names every reason it is refused. */
47
+ export const validateGatewayAllowedDnsRecordAddresses = (valueArg, pathArg = 'allowedDnsRecordAddresses') => {
48
+ const maximum = gatewayClientHostnameContract.maxAllowedDnsRecordAddresses;
49
+ const prefixes = readDataElements(valueArg, maximum);
50
+ if (prefixes === null) {
51
+ return [`${pathArg} must be a list of at most ${maximum} address prefixes`];
52
+ }
53
+ const errors = [];
54
+ prefixes.forEach((prefixArg, indexArg) => {
55
+ if (!isGatewayAddressPrefix(prefixArg)) {
56
+ errors.push(`${pathArg}[${indexArg}] must be a canonical address prefix whose host bits are zero`);
57
+ }
58
+ else if (prefixes.indexOf(prefixArg) !== indexArg) {
59
+ errors.push(`${pathArg}[${indexArg}] repeats ${prefixArg}`);
60
+ }
61
+ });
62
+ return errors;
63
+ };
64
+ /** True when the value is a canonical literal of the record type that can address one host. */
65
+ const isGatewayClientDnsRecordAddress = (typeArg, valueArg) => {
66
+ if (typeArg === 'A') {
67
+ return isPlausibleUnicastIpv4(valueArg);
68
+ }
69
+ const address = parseCanonicalIpAddress(valueArg);
70
+ // `::` is unspecified and `ff00::/8` is multicast; neither addresses one host.
71
+ return address?.family === 6 && address.value !== 0n && address.value >> 120n !== 0xffn;
72
+ };
73
+ /** Validates one requested address record and names every reason it is refused. */
74
+ export const validateGatewayClientDnsRecord = (valueArg, pathArg = 'record') => {
75
+ const record = readDataProperties(valueArg);
76
+ if (record === null || !hasExactKeys(record, ['type', 'value'], ['ttl'])) {
77
+ return [`${pathArg} must use its exact schema`];
78
+ }
79
+ const type = record.type;
80
+ if (type !== 'A' && type !== 'AAAA') {
81
+ return [`${pathArg} type must be A or AAAA`];
82
+ }
83
+ const errors = [];
84
+ if (!isGatewayClientDnsRecordAddress(type, record.value)) {
85
+ errors.push(type === 'A'
86
+ ? `${pathArg} value must be a canonical IPv4 host address (not 0.0.0.0/8, multicast or reserved)`
87
+ : `${pathArg} value must be a canonical RFC 5952 IPv6 host address (not unspecified or multicast)`);
88
+ }
89
+ const { minTtl, maxTtl } = gatewayClientHostnameContract;
90
+ if (Object.hasOwn(record, 'ttl') && !(typeof record.ttl === 'number'
91
+ && Number.isSafeInteger(record.ttl) && record.ttl >= minTtl && record.ttl <= maxTtl)) {
92
+ errors.push(`${pathArg} ttl must be an integer from ${minTtl} to ${maxTtl} seconds`);
93
+ }
94
+ return errors;
95
+ };
96
+ /**
97
+ * True when a valid record's value lies inside one of the allowance's prefixes of its family.
98
+ *
99
+ * A prefix that is not canonical allows nothing, so an allowance a gateway would refuse never
100
+ * widens what a record may point at.
101
+ */
102
+ export const isGatewayClientDnsRecordAllowed = (recordArg, allowedDnsRecordAddressesArg) => {
103
+ if (validateGatewayClientDnsRecord(recordArg).length > 0) {
104
+ return false;
105
+ }
106
+ const address = parseCanonicalIpAddress(recordArg.value);
107
+ const allowance = allowedDnsRecordAddressesArg === undefined
108
+ ? []
109
+ : readDataElements(allowedDnsRecordAddressesArg, gatewayClientHostnameContract.maxAllowedDnsRecordAddresses);
110
+ return address !== null && allowance !== null && allowance.some((prefixArg) => {
111
+ const prefix = parseCanonicalIpPrefix(prefixArg);
112
+ return prefix !== null && ipPrefixContains(prefix, address);
113
+ });
114
+ };
115
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZ2F0ZXdheS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3RzL2RhdGEvZ2F0ZXdheS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFDQSxPQUFPLEVBQUUsWUFBWSxFQUFFLGdCQUFnQixFQUFFLGtCQUFrQixFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFDbEcsT0FBTyxFQUFFLHNCQUFzQixFQUFFLE1BQU0sb0JBQW9CLENBQUM7QUFDNUQsT0FBTyxFQUNMLGdCQUFnQixFQUNoQix1QkFBdUIsRUFDdkIsc0JBQXNCLEdBQ3ZCLE1BQU0seUJBQXlCLENBQUM7QUFvZWpDOzs7OztHQUtHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sNkJBQTZCLEdBQUcsTUFBTSxDQUFDLE1BQU0sQ0FBQztJQUN6RCxNQUFNLEVBQUUsRUFBRTtJQUNWLE1BQU0sRUFBRSxNQUFNO0lBQ2QsVUFBVSxFQUFFLEdBQUc7SUFDZiw0QkFBNEIsRUFBRSxFQUFFO0lBQ2hDLGNBQWMsRUFBRSxHQUFHO0lBQ25CLGlCQUFpQixFQUFFLEdBQUc7Q0FDdkIsQ0FBQyxDQUFDO0FBbUpILE1BQU0sb0JBQW9CLEdBQUcsd0NBQXdDLENBQUM7QUFDdEUsTUFBTSxZQUFZLEdBQUcsK0JBQStCLENBQUM7QUFFckQsb0dBQW9HO0FBQ3BHLE1BQU0sQ0FBQyxNQUFNLHVCQUF1QixHQUFHLENBQUMsUUFBaUIsRUFBc0IsRUFBRSxDQUFDLENBQ2hGLE9BQU8sUUFBUSxLQUFLLFFBQVE7T0FDekIsUUFBUSxDQUFDLE1BQU0sSUFBSSw2QkFBNkIsQ0FBQyxpQkFBaUI7T0FDbEUsUUFBUSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsQ0FBQyxNQUFNLElBQUksQ0FBQztPQUMvQixRQUFRLENBQUMsS0FBSyxDQUFDLEdBQUcsQ0FBQyxDQUFDLEtBQUssQ0FBQyxDQUFDLFFBQVEsRUFBRSxFQUFFLENBQUMsb0JBQW9CLENBQUMsSUFBSSxDQUFDLFFBQVEsQ0FBQyxDQUFDLENBQ2hGLENBQUM7QUFF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
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Canonical IP address and prefix text, read into numbers a prefix test can compare.
3
+ *
4
+ * Only the one canonical spelling of each value is accepted: a dotted quad without leading zeros
5
+ * for IPv4, and the RFC 5952 form for IPv6 (lowercase, no leading zeros, the longest run of two or
6
+ * more zero groups compressed, the first run on a tie). A value two peers could spell differently
7
+ * would let the same address pass one comparison and fail another, so a non-canonical spelling is
8
+ * refused rather than normalized. IPv6 text with an embedded dotted quad or a zone index is refused
9
+ * for the same reason.
10
+ */
11
+ export type TIpAddressFamily = 4 | 6;
12
+ export interface IIpAddressValue {
13
+ family: TIpAddressFamily;
14
+ value: bigint;
15
+ }
16
+ export interface IIpPrefixValue {
17
+ family: TIpAddressFamily;
18
+ network: bigint;
19
+ length: number;
20
+ }
21
+ /** One canonical address literal of either family, or `null` for anything else. */
22
+ export declare const parseCanonicalIpAddress: (valueArg: unknown) => IIpAddressValue | null;
23
+ /**
24
+ * One canonical prefix `<address>/<length>` of either family whose host bits are all zero, or
25
+ * `null` for anything else. The length is written without leading zeros.
26
+ */
27
+ export declare const parseCanonicalIpPrefix: (valueArg: unknown) => IIpPrefixValue | null;
28
+ /** True when the address lies inside the prefix; families never contain each other. */
29
+ export declare const ipPrefixContains: (prefixArg: IIpPrefixValue, addressArg: IIpAddressValue) => boolean;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Canonical IP address and prefix text, read into numbers a prefix test can compare.
3
+ *
4
+ * Only the one canonical spelling of each value is accepted: a dotted quad without leading zeros
5
+ * for IPv4, and the RFC 5952 form for IPv6 (lowercase, no leading zeros, the longest run of two or
6
+ * more zero groups compressed, the first run on a tie). A value two peers could spell differently
7
+ * would let the same address pass one comparison and fail another, so a non-canonical spelling is
8
+ * refused rather than normalized. IPv6 text with an embedded dotted quad or a zone index is refused
9
+ * for the same reason.
10
+ */
11
+ const familyBits = (familyArg) => (familyArg === 4 ? 32 : 128);
12
+ const parseIpv4 = (valueArg) => {
13
+ if (valueArg.length > 15) {
14
+ return null;
15
+ }
16
+ const octets = valueArg.split('.');
17
+ if (octets.length !== 4
18
+ || !octets.every((octetArg) => /^(?:0|[1-9][0-9]{0,2})$/.test(octetArg) && Number(octetArg) <= 255)) {
19
+ return null;
20
+ }
21
+ return octets.reduce((sumArg, octetArg) => (sumArg << 8n) | BigInt(Number(octetArg)), 0n);
22
+ };
23
+ const parseIpv6Groups = (valueArg) => {
24
+ if (valueArg.length > 39 || !/^[0-9a-f:]+$/.test(valueArg)) {
25
+ return null;
26
+ }
27
+ const halves = valueArg.split('::');
28
+ if (halves.length > 2) {
29
+ return null;
30
+ }
31
+ const head = halves[0] === '' ? [] : halves[0].split(':');
32
+ if (halves.length === 1) {
33
+ return head;
34
+ }
35
+ const tail = halves[1] === '' ? [] : halves[1].split(':');
36
+ if (head.length + tail.length > 7) {
37
+ return null;
38
+ }
39
+ return [...head, ...new Array(8 - head.length - tail.length).fill('0'), ...tail];
40
+ };
41
+ const parseIpv6 = (valueArg) => {
42
+ const groups = parseIpv6Groups(valueArg);
43
+ if (groups === null || groups.length !== 8
44
+ || !groups.every((groupArg) => /^[0-9a-f]{1,4}$/.test(groupArg))) {
45
+ return null;
46
+ }
47
+ return groups.reduce((sumArg, groupArg) => (sumArg << 16n) | BigInt(parseInt(groupArg, 16)), 0n);
48
+ };
49
+ /** The RFC 5952 text of one IPv6 address. */
50
+ const formatIpv6 = (valueArg) => {
51
+ const groups = [];
52
+ for (let index = 7; index >= 0; index--) {
53
+ groups.push(Number((valueArg >> BigInt(index * 16)) & 0xffffn));
54
+ }
55
+ let bestStart = -1;
56
+ let bestLength = 0;
57
+ for (let start = 0; start < 8;) {
58
+ if (groups[start] !== 0) {
59
+ start++;
60
+ continue;
61
+ }
62
+ let end = start;
63
+ while (end < 8 && groups[end] === 0) {
64
+ end++;
65
+ }
66
+ if (end - start > bestLength) {
67
+ bestStart = start;
68
+ bestLength = end - start;
69
+ }
70
+ start = end;
71
+ }
72
+ const text = groups.map((groupArg) => groupArg.toString(16));
73
+ if (bestLength < 2) {
74
+ return text.join(':');
75
+ }
76
+ return `${text.slice(0, bestStart).join(':')}::${text.slice(bestStart + bestLength).join(':')}`;
77
+ };
78
+ /** One canonical address literal of either family, or `null` for anything else. */
79
+ export const parseCanonicalIpAddress = (valueArg) => {
80
+ if (typeof valueArg !== 'string') {
81
+ return null;
82
+ }
83
+ const ipv4 = parseIpv4(valueArg);
84
+ if (ipv4 !== null) {
85
+ return { family: 4, value: ipv4 };
86
+ }
87
+ const ipv6 = parseIpv6(valueArg);
88
+ return ipv6 !== null && formatIpv6(ipv6) === valueArg ? { family: 6, value: ipv6 } : null;
89
+ };
90
+ /**
91
+ * One canonical prefix `<address>/<length>` of either family whose host bits are all zero, or
92
+ * `null` for anything else. The length is written without leading zeros.
93
+ */
94
+ export const parseCanonicalIpPrefix = (valueArg) => {
95
+ if (typeof valueArg !== 'string') {
96
+ return null;
97
+ }
98
+ const parts = valueArg.split('/');
99
+ if (parts.length !== 2 || !/^(?:0|[1-9][0-9]{0,2})$/.test(parts[1])) {
100
+ return null;
101
+ }
102
+ const address = parseCanonicalIpAddress(parts[0]);
103
+ const length = Number(parts[1]);
104
+ if (address === null || length > familyBits(address.family)) {
105
+ return null;
106
+ }
107
+ const hostBits = BigInt(familyBits(address.family) - length);
108
+ return address.value % (1n << hostBits) === 0n
109
+ ? { family: address.family, network: address.value, length }
110
+ : null;
111
+ };
112
+ /** True when the address lies inside the prefix; families never contain each other. */
113
+ export const ipPrefixContains = (prefixArg, addressArg) => {
114
+ if (prefixArg.family !== addressArg.family) {
115
+ return false;
116
+ }
117
+ const hostBits = BigInt(familyBits(prefixArg.family) - prefixArg.length);
118
+ return addressArg.value >> hostBits === prefixArg.network >> hostBits;
119
+ };
120
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaXBhZGRyZXNzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHMvcHJpdmF0ZS9pcGFkZHJlc3MudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7Ozs7Ozs7OztHQVNHO0FBZUgsTUFBTSxVQUFVLEdBQUcsQ0FBQyxTQUEyQixFQUFVLEVBQUUsQ0FBQyxDQUFDLFNBQVMsS0FBSyxDQUFDLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDLENBQUMsR0FBRyxDQUFDLENBQUM7QUFFekYsTUFBTSxTQUFTLEdBQUcsQ0FBQyxRQUFnQixFQUFpQixFQUFFO0lBQ3BELElBQUksUUFBUSxDQUFDLE1BQU0sR0FBRyxFQUFFLEVBQUUsQ0FBQztRQUN6QixPQUFPLElBQUksQ0FBQztJQUNkLENBQUM7SUFDRCxNQUFNLE1BQU0sR0FBRyxRQUFRLENBQUMsS0FBSyxDQUFDLEdBQUcsQ0FBQyxDQUFDO0lBQ25DLElBQUksTUFBTSxDQUFDLE1BQU0sS0FBSyxDQUFDO1dBQ2xCLENBQUMsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDLFFBQVEsRUFBRSxFQUFFLENBQUMseUJBQXlCLENBQUMsSUFBSSxDQUFDLFFBQVEsQ0FBQyxJQUFJLE1BQU0sQ0FBQyxRQUFRLENBQUMsSUFBSSxHQUFHLENBQUMsRUFBRSxDQUFDO1FBQ3RHLE9BQU8sSUFBSSxDQUFDO0lBQ2QsQ0FBQztJQUNELE9BQU8sTUFBTSxDQUFDLE1BQU0sQ0FBQyxDQUFDLE1BQU0sRUFBRSxRQUFRLEVBQUUsRUFBRSxDQUFDLENBQUMsTUFBTSxJQUFJLEVBQUUsQ0FBQyxHQUFHLE1BQU0sQ0FBQyxNQUFNLENBQUMsUUFBUSxDQUFDLENBQUMsRUFBRSxFQUFFLENBQUMsQ0FBQztBQUM1RixDQUFDLENBQUM7QUFFRixNQUFNLGVBQWUsR0FBRyxDQUFDLFFBQWdCLEVBQW1CLEVBQUU7SUFDNUQsSUFBSSxRQUFRLENBQUMsTUFBTSxHQUFHLEVBQUUsSUFBSSxDQUFDLGNBQWMsQ0FBQyxJQUFJ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
@@ -1,5 +1,5 @@
1
1
  import * as plugins from '../plugins.js';
2
- import type { IGatewayCapabilities, IGatewayClient, IGatewayClientCredential, IGatewayClientProvisioningSpec, IGatewayClientContext, IGatewayClientOwnership, IGatewayClientRoute, IGatewayDnsRecord, IGatewayDomain, IGatewayIdentity, IGatewayRouteConfig, IGatewayRouteSyncResult, TGatewayRouteDnsMode, TGatewayClientProvisioningAction } from '../data/gateway.js';
2
+ import type { IGatewayCapabilities, IGatewayClient, IGatewayClientCredential, IGatewayClientProvisioningSpec, IGatewayClientContext, IGatewayClientDnsRecord, IGatewayClientDnsRecordSyncResult, IGatewayClientHostnameOwnership, IGatewayClientOwnership, IGatewayClientRoute, IGatewayDnsRecord, IGatewayDomain, IGatewayIdentity, IGatewayRouteConfig, IGatewayRouteSyncResult, TGatewayRouteDnsMode, TGatewayClientCertificateReleaseResult, TGatewayClientCertificateResult, TGatewayClientProvisioningAction } from '../data/gateway.js';
3
3
  import type { IMailOverviewDomain, IMailOverviewEmail } from './mail.js';
4
4
  /**
5
5
  * Gateway client contracts: the machine-facing API a gateway (dcrouter)
@@ -172,6 +172,63 @@ export interface IReq_SyncGatewayClientRoute extends plugins.typedrequestInterfa
172
172
  };
173
173
  response: IGatewayRouteSyncResult;
174
174
  }
175
+ /**
176
+ * Publishes, replaces or withdraws the one address record a gateway-client ownership holds.
177
+ *
178
+ * Gateway-client credentials only, with the `syncDnsRecords` capability; the hostname must match
179
+ * the client's `hostnamePatterns`. Idempotent: the same record again answers `managed-unchanged`,
180
+ * and deleting a record the ownership does not hold answers `managed-deleted`. The value must lie
181
+ * inside the client's `allowedDnsRecordAddresses` (`target-invalid` otherwise). A hostname held by
182
+ * anything else is `record-conflict` and stays untouched. Requires `dns.clientRecords`.
183
+ */
184
+ export interface IReq_SyncGatewayClientDnsRecord extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_SyncGatewayClientDnsRecord> {
185
+ method: 'syncGatewayClientDnsRecord';
186
+ request: {
187
+ apiToken: string;
188
+ identity?: never;
189
+ ownership: IGatewayClientHostnameOwnership;
190
+ } & ({
191
+ record: IGatewayClientDnsRecord;
192
+ delete?: never;
193
+ } | {
194
+ delete: true;
195
+ record?: never;
196
+ });
197
+ response: IGatewayClientDnsRecordSyncResult;
198
+ }
199
+ /**
200
+ * Answers the exact certificate for one owned hostname, starting its issuance when none is valid.
201
+ *
202
+ * Gateway-client credentials only, with `requestCertificates` and `readCertificates`; the hostname
203
+ * must match the client's `hostnamePatterns`. No gateway route is needed. The certificate is issued
204
+ * for exactly this one identifier in the client's own namespace, and asking again after
205
+ * `renewAfter` answers a renewed one. The `ready` answer carries the private key. Requires
206
+ * `certificates.exactIssuance`.
207
+ */
208
+ export interface IReq_GetGatewayClientCertificate extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_GetGatewayClientCertificate> {
209
+ method: 'getGatewayClientCertificate';
210
+ request: {
211
+ apiToken: string;
212
+ identity?: never;
213
+ ownership: IGatewayClientHostnameOwnership;
214
+ };
215
+ response: TGatewayClientCertificateResult;
216
+ }
217
+ /**
218
+ * Removes the certificate material and any issuance an ownership holds, releasing its certificate claim; an address record under the same ownership is unaffected.
219
+ *
220
+ * Gateway-client credentials only, with `requestCertificates`. Idempotent: an ownership that holds
221
+ * nothing answers `unchanged`. Requires `certificates.exactIssuance`.
222
+ */
223
+ export interface IReq_ReleaseGatewayClientCertificate extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_ReleaseGatewayClientCertificate> {
224
+ method: 'releaseGatewayClientCertificate';
225
+ request: {
226
+ apiToken: string;
227
+ identity?: never;
228
+ ownership: IGatewayClientHostnameOwnership;
229
+ };
230
+ response: TGatewayClientCertificateReleaseResult;
231
+ }
175
232
  export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_ReprovisionCertificateDomain> {
176
233
  method: 'reprovisionCertificateDomain';
177
234
  request: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/interfaces",
3
- "version": "32.20.0",
3
+ "version": "32.21.0",
4
4
  "private": false,
5
5
  "description": "Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.",
6
6
  "exports": {
@@ -23,7 +23,7 @@
23
23
  "dependencies": {
24
24
  "@api.global/typedrequest-interfaces": "^7.1.0",
25
25
  "@push.rocks/smartcrypto": "^2.2.0",
26
- "@push.rocks/smartlog": "^3.2.2",
26
+ "@push.rocks/smartlog": "^4.0.0",
27
27
  "@tsclass/tsclass": "^9.5.1"
28
28
  },
29
29
  "devDependencies": {
package/readme.md CHANGED
@@ -2684,6 +2684,38 @@ const provisioning: requests.gateway.IReq_ProvisionGatewayClientCredential['requ
2684
2684
 
2685
2685
  `getGatewayClientContext` returns effective live policy. A `gatewayClient` role necessarily includes the credential ID, bound client ID/type, and `policyGeneration`; consumers should reject admin/operator or mismatched contexts rather than falling back to a caller-supplied owner ID. `getGatewayClientMailOverview` provides an owner-scoped domain and recent-message summary. `getGatewayClientMailDomainCount` derives ownership exclusively from the authenticating gateway credential and returns `{ count: number }` for its distinct configured mail domains. Cloudly therefore uses only `dcrouterGatewayApiToken`; the former `dcrouterOpsApiToken` setting is not part of `data.ICloudlySettings`.
2686
2686
 
2687
+ ### Gateway Client Hostname Records and Certificates
2688
+
2689
+ A gateway client can hold an exact hostname without a gateway route: an address record at a value it chooses, and an exact certificate for that one name. Cloudly uses this for its cluster relay names, whose A record must carry the relay node's own address and whose certificate the relay serves itself. All three requests accept only a gateway-client credential (`identity` is `never`). The gateway resolves the client from that credential, and the hostname must match the client's `hostnamePatterns`. `data.IGatewayClientHostnameOwnership` names only `{ appId, hostname }`. One ownership holds at most one address record and one certificate. A hostname held by another ownership, by a gateway route claim, or by a record the gateway does not manage is a conflict. The gateway never overwrites or adopts it.
2690
+
2691
+ - `syncGatewayClientDnsRecord` (`requests.gateway.IReq_SyncGatewayClientDnsRecord`) publishes, replaces or withdraws the ownership's `A` or `AAAA` record. It needs the token capability `syncDnsRecords`, and the gateway advertises it as `capabilities.dns.clientRecords`. The value must be a canonical unicast literal inside the client's policy field `allowedDnsRecordAddresses`, a list of canonical CIDR prefixes. An absent or empty list allows no record. The result reuses the route DNS vocabulary (`data.TGatewayClientDnsRecordStatus`): `target-invalid` for an address outside the allowance, `record-conflict` with `conflictingRecords` as evidence, and `zone-unavailable` or `mutation-failed` when retryable. Records are never proxied.
2692
+ - `getGatewayClientCertificate` (`requests.gateway.IReq_GetGatewayClientCertificate`) answers `ready` with `data.IGatewayClientCertificate` (PEM chain, PEM private key, `validFrom`, `validUntil`, `renewAfter`), `pending` with `retryAfterMs` while an issuance runs, or `refused` with a `data.TGatewayClientCertificateRefusal`. Asking again after `renewAfter` yields a renewed certificate. It needs `requestCertificates` and `readCertificates`, and the gateway advertises it as `capabilities.certificates.exactIssuance`. The `ready` answer carries a private key; keep it sealed and never log it.
2693
+ - `releaseGatewayClientCertificate` (`requests.gateway.IReq_ReleaseGatewayClientCertificate`) removes the ownership's certificate material and any issuance in flight. It is idempotent and answers `released` or `unchanged`.
2694
+
2695
+ Both sides judge values with the same helpers:
2696
+
2697
+ - `data.validateGatewayClientHostnameOwnership`
2698
+ - `data.validateGatewayClientDnsRecord`
2699
+ - `data.validateGatewayAllowedDnsRecordAddresses`
2700
+ - `data.isGatewayClientDnsRecordAllowed`
2701
+ - `data.isGatewayAddressPrefix`
2702
+ - `data.isGatewayClientHostname`
2703
+
2704
+ Only one spelling of each address is accepted: the dotted quad, or RFC 5952 text for IPv6. `data.gatewayClientHostnameContract` states the bounds: TTL 60 to 86400 seconds with a default of 300, at most 64 allowance prefixes, an `appId` of at most 128 characters, and a hostname of at most 253 characters.
2705
+
2706
+ ```typescript
2707
+ import { data, requests } from '@serve.zone/interfaces';
2708
+
2709
+ const record: data.IGatewayClientDnsRecord = { type: 'A', value: '198.51.100.85', ttl: 60 };
2710
+ data.isGatewayClientDnsRecordAllowed(record, ['198.51.100.0/24']); // true
2711
+
2712
+ const request: requests.gateway.IReq_SyncGatewayClientDnsRecord['request'] = {
2713
+ apiToken: gatewayClientToken,
2714
+ ownership: { appId: 'cluster-relay:clusterexample', hostname: 'relay.clusterexample.example.com' },
2715
+ record,
2716
+ };
2717
+ ```
2718
+
2687
2719
  ### CoreMail contracts
2688
2720
 
2689
2721
  `requests.coremail` is the shared contract boundary for authenticated workload
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.20.0',
6
+ version: '32.21.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -1,4 +1,11 @@
1
1
  import type { TDnsRecordType } from './dns.js';
2
+ import { hasExactKeys, readDataElements, readDataProperties } from '../private/dataproperties.js';
3
+ import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
4
+ import {
5
+ ipPrefixContains,
6
+ parseCanonicalIpAddress,
7
+ parseCanonicalIpPrefix,
8
+ } from '../private/ipaddress.js';
2
9
 
3
10
  /**
4
11
  * Type of platform acting as a gateway client against a gateway (dcrouter).
@@ -115,6 +122,11 @@ export interface IGatewayCapabilities {
115
122
  read: boolean;
116
123
  export: boolean;
117
124
  forceRenew: boolean;
125
+ /**
126
+ * `getGatewayClientCertificate` and `releaseGatewayClientCertificate` are served: an exact
127
+ * certificate for one owned hostname, with no route behind it. Absent means unsupported.
128
+ */
129
+ exactIssuance?: boolean;
118
130
  };
119
131
  email: {
120
132
  domains: boolean;
@@ -127,6 +139,11 @@ export interface IGatewayCapabilities {
127
139
  dns: {
128
140
  authoritative: boolean;
129
141
  providerManaged: boolean;
142
+ /**
143
+ * `syncGatewayClientDnsRecord` is served: an address record at a value the client chooses
144
+ * inside its `allowedDnsRecordAddresses`. Absent means unsupported.
145
+ */
146
+ clientRecords?: boolean;
130
147
  };
131
148
  http3: {
132
149
  enabled: boolean;
@@ -176,6 +193,11 @@ export interface IGatewayClient {
176
193
  /** `*` matches any hostname under a managed zone; `*.zone` matches exactly one label. */
177
194
  hostnamePatterns: string[];
178
195
  allowedRouteTargets: IGatewayRouteTargetAllowance[];
196
+ /**
197
+ * Canonical address prefixes the client's own address records may point into; see
198
+ * `validateGatewayAllowedDnsRecordAddresses`. Absent or empty allows no address record.
199
+ */
200
+ allowedDnsRecordAddresses?: string[];
179
201
  capabilities: IGatewayTokenCapabilities;
180
202
  enabled: boolean;
181
203
  tokenCount?: number;
@@ -195,6 +217,11 @@ export interface IGatewayClientProvisioningSpec {
195
217
  /** `*` matches any hostname under a managed zone; `*.zone` matches exactly one label. */
196
218
  hostnamePatterns: string[];
197
219
  allowedRouteTargets: IGatewayRouteTargetAllowance[];
220
+ /**
221
+ * Canonical address prefixes the client's own address records may point into; see
222
+ * `validateGatewayAllowedDnsRecordAddresses`. Absent or empty allows no address record.
223
+ */
224
+ allowedDnsRecordAddresses?: string[];
198
225
  capabilities: IGatewayTokenCapabilities;
199
226
  enabled?: boolean;
200
227
  }
@@ -226,6 +253,8 @@ interface IGatewayClientContextBase {
226
253
  /** `*` matches any hostname under a managed zone; `*.zone` matches exactly one label. */
227
254
  hostnamePatterns: string[];
228
255
  allowedRouteTargets: IGatewayRouteTargetAllowance[];
256
+ /** Absent on gateways that predate address records; read it as an empty allowance. */
257
+ allowedDnsRecordAddresses?: string[];
229
258
  capabilities: IGatewayTokenCapabilities;
230
259
  }
231
260
 
@@ -459,3 +488,282 @@ export interface IGatewayRouteSyncResult {
459
488
  dns?: IGatewayRouteDnsResult;
460
489
  message?: string;
461
490
  }
491
+
492
+ /**
493
+ * Bounds of the gateway-client address record and certificate contracts.
494
+ *
495
+ * A gateway applies `defaultTtl` to a record that names none, and refuses a TTL outside
496
+ * `minTtl`..`maxTtl` as it refuses an allowance list longer than `maxAllowedDnsRecordAddresses`.
497
+ */
498
+ export const gatewayClientHostnameContract = Object.freeze({
499
+ minTtl: 60,
500
+ maxTtl: 86_400,
501
+ defaultTtl: 300,
502
+ maxAllowedDnsRecordAddresses: 64,
503
+ maxAppIdLength: 128,
504
+ maxHostnameLength: 253,
505
+ });
506
+
507
+ /**
508
+ * What one gateway client owns a hostname for: its app and the exact hostname.
509
+ *
510
+ * The gateway client itself is never named here. The gateway resolves it from the authenticating
511
+ * gateway-client credential, so a client cannot act for another one. One ownership holds at most
512
+ * one address record and one certificate for its hostname, and a hostname is held by at most one
513
+ * ownership: a second client or app, a gateway route claim, or a record the gateway does not
514
+ * manage makes the hostname a conflict rather than a shared name.
515
+ */
516
+ export interface IGatewayClientHostnameOwnership {
517
+ /** 1 to 128 characters of letters, digits, `.`, `_`, `:` or `-`, starting with a letter or digit. */
518
+ appId: string;
519
+ /** Lowercase fully qualified hostname without a trailing dot and without a wildcard. */
520
+ hostname: string;
521
+ }
522
+
523
+ /** Address record types a gateway client may publish at a value it chooses. */
524
+ export type TGatewayClientDnsRecordType = 'A' | 'AAAA';
525
+
526
+ /**
527
+ * One address record a gateway client asks the gateway to publish for its hostname.
528
+ *
529
+ * The value is the canonical literal of the record's family (a dotted quad for `A`, RFC 5952 text
530
+ * for `AAAA`) and must lie inside the client's `allowedDnsRecordAddresses`. The record is never
531
+ * proxied: a client that chooses its own address asks for exactly that address to be served.
532
+ */
533
+ export interface IGatewayClientDnsRecord {
534
+ type: TGatewayClientDnsRecordType;
535
+ value: string;
536
+ /** Seconds, `minTtl`..`maxTtl`; `defaultTtl` when omitted. */
537
+ ttl?: number;
538
+ }
539
+
540
+ /**
541
+ * Outcome of one address record synchronization, in the gateway route DNS vocabulary.
542
+ *
543
+ * - `managed-created` / `managed-updated` / `managed-unchanged` / `managed-deleted`: done.
544
+ * - `target-invalid`: the value is outside the client's address allowance.
545
+ * - `zone-unmanaged`: no zone the gateway manages holds the hostname.
546
+ * - `zone-unavailable`: the zone's DNS provider or authority cannot be reached; retryable.
547
+ * - `record-conflict`: another ownership, a gateway route claim, or an unmanaged record holds
548
+ * the hostname. The gateway never overwrites or adopts it.
549
+ * - `mutation-failed`: writing the record failed; retryable.
550
+ */
551
+ export type TGatewayClientDnsRecordStatus = Extract<
552
+ TGatewayRouteDnsStatus,
553
+ | 'managed-created'
554
+ | 'managed-updated'
555
+ | 'managed-unchanged'
556
+ | 'managed-deleted'
557
+ | 'target-invalid'
558
+ | 'zone-unmanaged'
559
+ | 'zone-unavailable'
560
+ | 'record-conflict'
561
+ | 'mutation-failed'
562
+ >;
563
+
564
+ /** Result of `syncGatewayClientDnsRecord`. */
565
+ export interface IGatewayClientDnsRecordSyncResult {
566
+ success: boolean;
567
+ /** True when the same request may succeed later without any change on the client's side. */
568
+ retryable: boolean;
569
+ status: TGatewayClientDnsRecordStatus;
570
+ hostname: string;
571
+ zoneName?: string;
572
+ /** The record this ownership holds after the call; absent after a deletion or a refusal. */
573
+ record?: IGatewayRouteDnsRecord;
574
+ /** The records that hold the hostname, as evidence for `record-conflict`. */
575
+ conflictingRecords?: IGatewayRouteDnsRecord[];
576
+ /** Unix epoch milliseconds at which the gateway checked the DNS state. */
577
+ checkedAt: number;
578
+ /** Unix epoch milliseconds at which authoritative DNS confirmed the desired state. */
579
+ authoritativeVerifiedAt?: number;
580
+ message?: string;
581
+ }
582
+
583
+ /**
584
+ * The certificate a gateway issued for one owned hostname, with its private key.
585
+ *
586
+ * Secret-bearing: a receiver keeps the key sealed and never logs or caches the response as is.
587
+ */
588
+ export interface IGatewayClientCertificate {
589
+ hostname: string;
590
+ /** PEM leaf certificate followed by its chain. */
591
+ certificatePem: string;
592
+ /** PEM private key of the leaf certificate. */
593
+ privateKeyPem: string;
594
+ /** Unix epoch milliseconds. */
595
+ validFrom: number;
596
+ /** Unix epoch milliseconds. */
597
+ validUntil: number;
598
+ /** Unix epoch milliseconds after which asking again yields a renewed certificate. */
599
+ renewAfter: number;
600
+ }
601
+
602
+ /**
603
+ * Why a gateway does not issue a certificate for an ownership.
604
+ *
605
+ * - `zone-unmanaged`: no zone the gateway manages with verified authority holds the hostname.
606
+ * - `hostname-conflict`: another ownership or a gateway route claim holds the hostname.
607
+ * - `issuance-failed`: the certificate authority refused the order; `retryAfter` says when a new
608
+ * order is placed.
609
+ * - `issuance-unavailable`: the gateway's ACME configuration or DNS authority is not ready.
610
+ */
611
+ export type TGatewayClientCertificateRefusal =
612
+ | 'zone-unmanaged'
613
+ | 'hostname-conflict'
614
+ | 'issuance-failed'
615
+ | 'issuance-unavailable';
616
+
617
+ /** Result of `getGatewayClientCertificate`. */
618
+ export type TGatewayClientCertificateResult =
619
+ | {
620
+ status: 'ready';
621
+ certificate: IGatewayClientCertificate;
622
+ }
623
+ | {
624
+ /** An issuance runs; ask again after `retryAfterMs`. */
625
+ status: 'pending';
626
+ hostname: string;
627
+ retryAfterMs: number;
628
+ }
629
+ | {
630
+ status: 'refused';
631
+ hostname: string;
632
+ code: TGatewayClientCertificateRefusal;
633
+ retryable: boolean;
634
+ /** Unix epoch milliseconds before which asking again cannot change the answer. */
635
+ retryAfter?: number;
636
+ message?: string;
637
+ };
638
+
639
+ /** Result of `releaseGatewayClientCertificate`. */
640
+ export type TGatewayClientCertificateReleaseResult =
641
+ | {
642
+ success: true;
643
+ /** `released` when material or an issuance was held; `unchanged` when nothing was. */
644
+ action: 'released' | 'unchanged';
645
+ }
646
+ | {
647
+ success: false;
648
+ retryable: boolean;
649
+ message: string;
650
+ };
651
+
652
+ const gatewayHostnameLabel = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
653
+ const gatewayAppId = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
654
+
655
+ /** True for a lowercase fully qualified hostname of two or more labels, without wildcard or dot. */
656
+ export const isGatewayClientHostname = (valueArg: unknown): valueArg is string => (
657
+ typeof valueArg === 'string'
658
+ && valueArg.length <= gatewayClientHostnameContract.maxHostnameLength
659
+ && valueArg.split('.').length >= 2
660
+ && valueArg.split('.').every((labelArg) => gatewayHostnameLabel.test(labelArg))
661
+ );
662
+
663
+ /** Validates one hostname ownership and names every reason it is refused. */
664
+ export const validateGatewayClientHostnameOwnership = (
665
+ valueArg: unknown,
666
+ pathArg = 'ownership',
667
+ ): string[] => {
668
+ const ownership = readDataProperties(valueArg);
669
+ if (ownership === null || !hasExactKeys(ownership, ['appId', 'hostname'])) {
670
+ return [`${pathArg} must use its exact schema`];
671
+ }
672
+ const errors: string[] = [];
673
+ const appId = ownership.appId;
674
+ if (typeof appId !== 'string' || appId.length > gatewayClientHostnameContract.maxAppIdLength
675
+ || !gatewayAppId.test(appId)) {
676
+ errors.push(`${pathArg} appId must be 1 to ${gatewayClientHostnameContract.maxAppIdLength} `
677
+ + 'characters of letters, digits, dot, underscore, colon or hyphen, starting with a letter or digit');
678
+ }
679
+ if (!isGatewayClientHostname(ownership.hostname)) {
680
+ errors.push(`${pathArg} hostname must be a lowercase fully qualified hostname `
681
+ + 'without a trailing dot or wildcard');
682
+ }
683
+ return errors;
684
+ };
685
+
686
+ /** True for one canonical address prefix of either family whose host bits are zero. */
687
+ export const isGatewayAddressPrefix = (valueArg: unknown): valueArg is string =>
688
+ parseCanonicalIpPrefix(valueArg) !== null;
689
+
690
+ /** Validates a client's address allowance and names every reason it is refused. */
691
+ export const validateGatewayAllowedDnsRecordAddresses = (
692
+ valueArg: unknown,
693
+ pathArg = 'allowedDnsRecordAddresses',
694
+ ): string[] => {
695
+ const maximum = gatewayClientHostnameContract.maxAllowedDnsRecordAddresses;
696
+ const prefixes = readDataElements(valueArg, maximum);
697
+ if (prefixes === null) {
698
+ return [`${pathArg} must be a list of at most ${maximum} address prefixes`];
699
+ }
700
+ const errors: string[] = [];
701
+ prefixes.forEach((prefixArg, indexArg) => {
702
+ if (!isGatewayAddressPrefix(prefixArg)) {
703
+ errors.push(`${pathArg}[${indexArg}] must be a canonical address prefix whose host bits are zero`);
704
+ } else if (prefixes.indexOf(prefixArg) !== indexArg) {
705
+ errors.push(`${pathArg}[${indexArg}] repeats ${prefixArg}`);
706
+ }
707
+ });
708
+ return errors;
709
+ };
710
+
711
+ /** True when the value is a canonical literal of the record type that can address one host. */
712
+ const isGatewayClientDnsRecordAddress = (
713
+ typeArg: TGatewayClientDnsRecordType,
714
+ valueArg: unknown,
715
+ ): valueArg is string => {
716
+ if (typeArg === 'A') {
717
+ return isPlausibleUnicastIpv4(valueArg);
718
+ }
719
+ const address = parseCanonicalIpAddress(valueArg);
720
+ // `::` is unspecified and `ff00::/8` is multicast; neither addresses one host.
721
+ return address?.family === 6 && address.value !== 0n && address.value >> 120n !== 0xffn;
722
+ };
723
+
724
+ /** Validates one requested address record and names every reason it is refused. */
725
+ export const validateGatewayClientDnsRecord = (valueArg: unknown, pathArg = 'record'): string[] => {
726
+ const record = readDataProperties(valueArg);
727
+ if (record === null || !hasExactKeys(record, ['type', 'value'], ['ttl'])) {
728
+ return [`${pathArg} must use its exact schema`];
729
+ }
730
+ const type = record.type;
731
+ if (type !== 'A' && type !== 'AAAA') {
732
+ return [`${pathArg} type must be A or AAAA`];
733
+ }
734
+ const errors: string[] = [];
735
+ if (!isGatewayClientDnsRecordAddress(type, record.value)) {
736
+ errors.push(type === 'A'
737
+ ? `${pathArg} value must be a canonical IPv4 host address (not 0.0.0.0/8, multicast or reserved)`
738
+ : `${pathArg} value must be a canonical RFC 5952 IPv6 host address (not unspecified or multicast)`);
739
+ }
740
+ const { minTtl, maxTtl } = gatewayClientHostnameContract;
741
+ if (Object.hasOwn(record, 'ttl') && !(typeof record.ttl === 'number'
742
+ && Number.isSafeInteger(record.ttl) && record.ttl >= minTtl && record.ttl <= maxTtl)) {
743
+ errors.push(`${pathArg} ttl must be an integer from ${minTtl} to ${maxTtl} seconds`);
744
+ }
745
+ return errors;
746
+ };
747
+
748
+ /**
749
+ * True when a valid record's value lies inside one of the allowance's prefixes of its family.
750
+ *
751
+ * A prefix that is not canonical allows nothing, so an allowance a gateway would refuse never
752
+ * widens what a record may point at.
753
+ */
754
+ export const isGatewayClientDnsRecordAllowed = (
755
+ recordArg: IGatewayClientDnsRecord,
756
+ allowedDnsRecordAddressesArg: readonly string[] | undefined,
757
+ ): boolean => {
758
+ if (validateGatewayClientDnsRecord(recordArg).length > 0) {
759
+ return false;
760
+ }
761
+ const address = parseCanonicalIpAddress(recordArg.value);
762
+ const allowance = allowedDnsRecordAddressesArg === undefined
763
+ ? []
764
+ : readDataElements(allowedDnsRecordAddressesArg, gatewayClientHostnameContract.maxAllowedDnsRecordAddresses);
765
+ return address !== null && allowance !== null && allowance.some((prefixArg) => {
766
+ const prefix = parseCanonicalIpPrefix(prefixArg);
767
+ return prefix !== null && ipPrefixContains(prefix, address);
768
+ });
769
+ };
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Canonical IP address and prefix text, read into numbers a prefix test can compare.
3
+ *
4
+ * Only the one canonical spelling of each value is accepted: a dotted quad without leading zeros
5
+ * for IPv4, and the RFC 5952 form for IPv6 (lowercase, no leading zeros, the longest run of two or
6
+ * more zero groups compressed, the first run on a tie). A value two peers could spell differently
7
+ * would let the same address pass one comparison and fail another, so a non-canonical spelling is
8
+ * refused rather than normalized. IPv6 text with an embedded dotted quad or a zone index is refused
9
+ * for the same reason.
10
+ */
11
+
12
+ export type TIpAddressFamily = 4 | 6;
13
+
14
+ export interface IIpAddressValue {
15
+ family: TIpAddressFamily;
16
+ value: bigint;
17
+ }
18
+
19
+ export interface IIpPrefixValue {
20
+ family: TIpAddressFamily;
21
+ network: bigint;
22
+ length: number;
23
+ }
24
+
25
+ const familyBits = (familyArg: TIpAddressFamily): number => (familyArg === 4 ? 32 : 128);
26
+
27
+ const parseIpv4 = (valueArg: string): bigint | null => {
28
+ if (valueArg.length > 15) {
29
+ return null;
30
+ }
31
+ const octets = valueArg.split('.');
32
+ if (octets.length !== 4
33
+ || !octets.every((octetArg) => /^(?:0|[1-9][0-9]{0,2})$/.test(octetArg) && Number(octetArg) <= 255)) {
34
+ return null;
35
+ }
36
+ return octets.reduce((sumArg, octetArg) => (sumArg << 8n) | BigInt(Number(octetArg)), 0n);
37
+ };
38
+
39
+ const parseIpv6Groups = (valueArg: string): string[] | null => {
40
+ if (valueArg.length > 39 || !/^[0-9a-f:]+$/.test(valueArg)) {
41
+ return null;
42
+ }
43
+ const halves = valueArg.split('::');
44
+ if (halves.length > 2) {
45
+ return null;
46
+ }
47
+ const head = halves[0] === '' ? [] : halves[0].split(':');
48
+ if (halves.length === 1) {
49
+ return head;
50
+ }
51
+ const tail = halves[1] === '' ? [] : halves[1].split(':');
52
+ if (head.length + tail.length > 7) {
53
+ return null;
54
+ }
55
+ return [...head, ...new Array<string>(8 - head.length - tail.length).fill('0'), ...tail];
56
+ };
57
+
58
+ const parseIpv6 = (valueArg: string): bigint | null => {
59
+ const groups = parseIpv6Groups(valueArg);
60
+ if (groups === null || groups.length !== 8
61
+ || !groups.every((groupArg) => /^[0-9a-f]{1,4}$/.test(groupArg))) {
62
+ return null;
63
+ }
64
+ return groups.reduce((sumArg, groupArg) => (sumArg << 16n) | BigInt(parseInt(groupArg, 16)), 0n);
65
+ };
66
+
67
+ /** The RFC 5952 text of one IPv6 address. */
68
+ const formatIpv6 = (valueArg: bigint): string => {
69
+ const groups: number[] = [];
70
+ for (let index = 7; index >= 0; index--) {
71
+ groups.push(Number((valueArg >> BigInt(index * 16)) & 0xffffn));
72
+ }
73
+ let bestStart = -1;
74
+ let bestLength = 0;
75
+ for (let start = 0; start < 8;) {
76
+ if (groups[start] !== 0) {
77
+ start++;
78
+ continue;
79
+ }
80
+ let end = start;
81
+ while (end < 8 && groups[end] === 0) {
82
+ end++;
83
+ }
84
+ if (end - start > bestLength) {
85
+ bestStart = start;
86
+ bestLength = end - start;
87
+ }
88
+ start = end;
89
+ }
90
+ const text = groups.map((groupArg) => groupArg.toString(16));
91
+ if (bestLength < 2) {
92
+ return text.join(':');
93
+ }
94
+ return `${text.slice(0, bestStart).join(':')}::${text.slice(bestStart + bestLength).join(':')}`;
95
+ };
96
+
97
+ /** One canonical address literal of either family, or `null` for anything else. */
98
+ export const parseCanonicalIpAddress = (valueArg: unknown): IIpAddressValue | null => {
99
+ if (typeof valueArg !== 'string') {
100
+ return null;
101
+ }
102
+ const ipv4 = parseIpv4(valueArg);
103
+ if (ipv4 !== null) {
104
+ return { family: 4, value: ipv4 };
105
+ }
106
+ const ipv6 = parseIpv6(valueArg);
107
+ return ipv6 !== null && formatIpv6(ipv6) === valueArg ? { family: 6, value: ipv6 } : null;
108
+ };
109
+
110
+ /**
111
+ * One canonical prefix `<address>/<length>` of either family whose host bits are all zero, or
112
+ * `null` for anything else. The length is written without leading zeros.
113
+ */
114
+ export const parseCanonicalIpPrefix = (valueArg: unknown): IIpPrefixValue | null => {
115
+ if (typeof valueArg !== 'string') {
116
+ return null;
117
+ }
118
+ const parts = valueArg.split('/');
119
+ if (parts.length !== 2 || !/^(?:0|[1-9][0-9]{0,2})$/.test(parts[1])) {
120
+ return null;
121
+ }
122
+ const address = parseCanonicalIpAddress(parts[0]);
123
+ const length = Number(parts[1]);
124
+ if (address === null || length > familyBits(address.family)) {
125
+ return null;
126
+ }
127
+ const hostBits = BigInt(familyBits(address.family) - length);
128
+ return address.value % (1n << hostBits) === 0n
129
+ ? { family: address.family, network: address.value, length }
130
+ : null;
131
+ };
132
+
133
+ /** True when the address lies inside the prefix; families never contain each other. */
134
+ export const ipPrefixContains = (prefixArg: IIpPrefixValue, addressArg: IIpAddressValue): boolean => {
135
+ if (prefixArg.family !== addressArg.family) {
136
+ return false;
137
+ }
138
+ const hostBits = BigInt(familyBits(prefixArg.family) - prefixArg.length);
139
+ return addressArg.value >> hostBits === prefixArg.network >> hostBits;
140
+ };
@@ -5,6 +5,9 @@ import type {
5
5
  IGatewayClientCredential,
6
6
  IGatewayClientProvisioningSpec,
7
7
  IGatewayClientContext,
8
+ IGatewayClientDnsRecord,
9
+ IGatewayClientDnsRecordSyncResult,
10
+ IGatewayClientHostnameOwnership,
8
11
  IGatewayClientOwnership,
9
12
  IGatewayClientRoute,
10
13
  IGatewayDnsRecord,
@@ -13,6 +16,8 @@ import type {
13
16
  IGatewayRouteConfig,
14
17
  IGatewayRouteSyncResult,
15
18
  TGatewayRouteDnsMode,
19
+ TGatewayClientCertificateReleaseResult,
20
+ TGatewayClientCertificateResult,
16
21
  TGatewayClientProvisioningAction,
17
22
  } from '../data/gateway.js';
18
23
  import type { IMailOverviewDomain, IMailOverviewEmail } from './mail.js';
@@ -233,6 +238,72 @@ export interface IReq_SyncGatewayClientRoute extends plugins.typedrequestInterfa
233
238
  response: IGatewayRouteSyncResult;
234
239
  }
235
240
 
241
+ /**
242
+ * Publishes, replaces or withdraws the one address record a gateway-client ownership holds.
243
+ *
244
+ * Gateway-client credentials only, with the `syncDnsRecords` capability; the hostname must match
245
+ * the client's `hostnamePatterns`. Idempotent: the same record again answers `managed-unchanged`,
246
+ * and deleting a record the ownership does not hold answers `managed-deleted`. The value must lie
247
+ * inside the client's `allowedDnsRecordAddresses` (`target-invalid` otherwise). A hostname held by
248
+ * anything else is `record-conflict` and stays untouched. Requires `dns.clientRecords`.
249
+ */
250
+ export interface IReq_SyncGatewayClientDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
251
+ plugins.typedrequestInterfaces.ITypedRequest,
252
+ IReq_SyncGatewayClientDnsRecord
253
+ > {
254
+ method: 'syncGatewayClientDnsRecord';
255
+ request: {
256
+ apiToken: string;
257
+ identity?: never;
258
+ ownership: IGatewayClientHostnameOwnership;
259
+ } & (
260
+ | { record: IGatewayClientDnsRecord; delete?: never }
261
+ | { delete: true; record?: never }
262
+ );
263
+ response: IGatewayClientDnsRecordSyncResult;
264
+ }
265
+
266
+ /**
267
+ * Answers the exact certificate for one owned hostname, starting its issuance when none is valid.
268
+ *
269
+ * Gateway-client credentials only, with `requestCertificates` and `readCertificates`; the hostname
270
+ * must match the client's `hostnamePatterns`. No gateway route is needed. The certificate is issued
271
+ * for exactly this one identifier in the client's own namespace, and asking again after
272
+ * `renewAfter` answers a renewed one. The `ready` answer carries the private key. Requires
273
+ * `certificates.exactIssuance`.
274
+ */
275
+ export interface IReq_GetGatewayClientCertificate extends plugins.typedrequestInterfaces.implementsTR<
276
+ plugins.typedrequestInterfaces.ITypedRequest,
277
+ IReq_GetGatewayClientCertificate
278
+ > {
279
+ method: 'getGatewayClientCertificate';
280
+ request: {
281
+ apiToken: string;
282
+ identity?: never;
283
+ ownership: IGatewayClientHostnameOwnership;
284
+ };
285
+ response: TGatewayClientCertificateResult;
286
+ }
287
+
288
+ /**
289
+ * Removes the certificate material and any issuance an ownership holds, releasing its certificate claim; an address record under the same ownership is unaffected.
290
+ *
291
+ * Gateway-client credentials only, with `requestCertificates`. Idempotent: an ownership that holds
292
+ * nothing answers `unchanged`. Requires `certificates.exactIssuance`.
293
+ */
294
+ export interface IReq_ReleaseGatewayClientCertificate extends plugins.typedrequestInterfaces.implementsTR<
295
+ plugins.typedrequestInterfaces.ITypedRequest,
296
+ IReq_ReleaseGatewayClientCertificate
297
+ > {
298
+ method: 'releaseGatewayClientCertificate';
299
+ request: {
300
+ apiToken: string;
301
+ identity?: never;
302
+ ownership: IGatewayClientHostnameOwnership;
303
+ };
304
+ response: TGatewayClientCertificateReleaseResult;
305
+ }
306
+
236
307
  export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestInterfaces.implementsTR<
237
308
  plugins.typedrequestInterfaces.ITypedRequest,
238
309
  IReq_ReprovisionCertificateDomain