@andrewheberle/serverless-ssh-ca 0.17.5

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 (81) hide show
  1. package/dist/api/v3/index.d.ts +2 -0
  2. package/dist/api/v3/index.js +278 -0
  3. package/dist/api/v3/schema.d.ts +218 -0
  4. package/dist/api/v3/schema.js +207 -0
  5. package/dist/certificate.d.ts +30 -0
  6. package/dist/certificate.js +154 -0
  7. package/dist/db.d.ts +36 -0
  8. package/dist/db.js +111 -0
  9. package/dist/index.d.ts +7 -0
  10. package/dist/index.js +14 -0
  11. package/dist/krl.d.ts +39 -0
  12. package/dist/krl.js +317 -0
  13. package/dist/logger.d.ts +3 -0
  14. package/dist/logger.js +2 -0
  15. package/dist/proof.d.ts +52 -0
  16. package/dist/proof.js +153 -0
  17. package/dist/router.d.ts +7 -0
  18. package/dist/router.js +24 -0
  19. package/dist/src/api/v3/index.d.ts +1 -0
  20. package/dist/src/api/v3/index.js +263 -0
  21. package/dist/src/api/v3/schema.d.ts +217 -0
  22. package/dist/src/api/v3/schema.js +216 -0
  23. package/dist/src/certificate.d.ts +30 -0
  24. package/dist/src/certificate.js +154 -0
  25. package/dist/src/db.d.ts +36 -0
  26. package/dist/src/db.js +111 -0
  27. package/dist/src/index.d.ts +6 -0
  28. package/dist/src/index.js +8 -0
  29. package/dist/src/krl.d.ts +39 -0
  30. package/dist/src/krl.js +317 -0
  31. package/dist/src/logger.d.ts +2 -0
  32. package/dist/src/logger.js +3 -0
  33. package/dist/src/proof.d.ts +51 -0
  34. package/dist/src/proof.js +154 -0
  35. package/dist/src/router.d.ts +14 -0
  36. package/dist/src/router.js +22 -0
  37. package/dist/src/sshsig/armor.d.ts +7 -0
  38. package/dist/src/sshsig/armor.js +18 -0
  39. package/dist/src/sshsig/formats.d.ts +52 -0
  40. package/dist/src/sshsig/formats.js +174 -0
  41. package/dist/src/sshsig/index.d.ts +15 -0
  42. package/dist/src/sshsig/index.js +31 -0
  43. package/dist/src/sshsig/reader.d.ts +20 -0
  44. package/dist/src/sshsig/reader.js +54 -0
  45. package/dist/src/sshsig/sig.d.ts +16 -0
  46. package/dist/src/sshsig/sig.js +1 -0
  47. package/dist/src/sshsig/sig_parser.d.ts +9 -0
  48. package/dist/src/sshsig/sig_parser.js +83 -0
  49. package/dist/src/sshsig/verifier.d.ts +2 -0
  50. package/dist/src/sshsig/verifier.js +37 -0
  51. package/dist/src/sshsig/writer.d.ts +17 -0
  52. package/dist/src/sshsig/writer.js +51 -0
  53. package/dist/src/types.d.ts +42 -0
  54. package/dist/src/types.js +20 -0
  55. package/dist/src/utils.d.ts +76 -0
  56. package/dist/src/utils.js +370 -0
  57. package/dist/src/verify.d.ts +6 -0
  58. package/dist/src/verify.js +17 -0
  59. package/dist/sshsig/armor.d.ts +7 -0
  60. package/dist/sshsig/armor.js +18 -0
  61. package/dist/sshsig/formats.d.ts +52 -0
  62. package/dist/sshsig/formats.js +174 -0
  63. package/dist/sshsig/index.d.ts +15 -0
  64. package/dist/sshsig/index.js +34 -0
  65. package/dist/sshsig/reader.d.ts +20 -0
  66. package/dist/sshsig/reader.js +54 -0
  67. package/dist/sshsig/sig.d.ts +16 -0
  68. package/dist/sshsig/sig.js +1 -0
  69. package/dist/sshsig/sig_parser.d.ts +9 -0
  70. package/dist/sshsig/sig_parser.js +83 -0
  71. package/dist/sshsig/verifier.d.ts +2 -0
  72. package/dist/sshsig/verifier.js +37 -0
  73. package/dist/sshsig/writer.d.ts +17 -0
  74. package/dist/sshsig/writer.js +51 -0
  75. package/dist/types.d.ts +42 -0
  76. package/dist/types.js +1 -0
  77. package/dist/utils.d.ts +59 -0
  78. package/dist/utils.js +323 -0
  79. package/dist/verify.d.ts +7 -0
  80. package/dist/verify.js +16 -0
  81. package/package.json +58 -0
@@ -0,0 +1,2 @@
1
+ import type { SshCaBindings } from "../../types";
2
+ export declare const createApi: (env: SshCaBindings) => import("chanfana").HonoOpenAPIRouterType<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -0,0 +1,278 @@
1
+ import { group } from "@andrewheberle/ts-slog";
2
+ import { ApiException, ConflictException, ForbiddenException, fromHono, InternalServerErrorException, NotFoundException, OpenAPIRoute, UnauthorizedException, UnprocessableEntityException, } from "chanfana";
3
+ import { Hono } from "hono";
4
+ import { BadIssuerError, createSignedCertificate, createSignedHostCertificate, } from "../../certificate";
5
+ import { getPublic, split, } from "../../utils";
6
+ import { KeyParseError, parsePrivateKey, } from "sshpk";
7
+ import { CertificateType, getRevocationList, recordCertificate, RevocationStatus, revocationStatus, revokeCertificate, } from "../../db";
8
+ import { KRLBuilder } from "../../krl";
9
+ import { CaPublicKeyEndpointSchema, createUserCertificateRequestEndpointSchema, createRevocationListEndpointSchema, createHostCertificateRequestEndpointSchema, createHostCertificateRenewEndpointSchema, createRevokeCertificateEndpointSchema, } from "./schema";
10
+ import { logger } from "../../logger";
11
+ const CaPublicKeyEndpoint = (env) => {
12
+ return class extends OpenAPIRoute {
13
+ schema = CaPublicKeyEndpointSchema;
14
+ async handle(c) {
15
+ const l = logger(env).with(...group("request", "action", "ca"));
16
+ try {
17
+ const pub = await getPublic(c.env);
18
+ return c.text(`${pub.toString("ssh").trim()}\n`);
19
+ }
20
+ catch (err) {
21
+ switch (true) {
22
+ case (err instanceof KeyParseError):
23
+ l.error("error parsing private key");
24
+ throw new InternalServerErrorException("error parsing private key");
25
+ case (err instanceof ApiException):
26
+ // re-throw any exisiting chanfana error
27
+ throw err;
28
+ default:
29
+ // otherwise throw as InternalServerErrorException
30
+ l.error("unhandled error", "error", err);
31
+ throw new InternalServerErrorException("internal server error");
32
+ }
33
+ }
34
+ }
35
+ };
36
+ };
37
+ const UserCertificateRequestEndpoint = (env) => {
38
+ return class extends OpenAPIRoute {
39
+ schema = createUserCertificateRequestEndpointSchema(env);
40
+ async handle(c) {
41
+ const data = await this.getValidatedData();
42
+ const l = logger(env).with(...group("request", "type", "user", "action", "request"), ...group("auth_token", "email", data.headers.Authorization.email, "sub", data.headers.Authorization.sub), ...group("id_token", "sub", data.body.identity.sub));
43
+ if (data.body.identity.sub !== data.headers["Authorization"].sub) {
44
+ l.error("token subjects did not match");
45
+ throw new ForbiddenException("Possible token substitution as subjects for authentication and identity tokens did not match");
46
+ }
47
+ const opts = {
48
+ lifetime: data.body.lifetime,
49
+ principals: data.body.identity.principals,
50
+ extensions: data.body.extensions,
51
+ };
52
+ try {
53
+ const certificate = await createSignedCertificate(c.env, data.headers.Authorization.email, data.body.public_key, opts);
54
+ const response = {
55
+ certificate: btoa(certificate.toString("openssh"))
56
+ };
57
+ try {
58
+ await recordCertificate(c.env, certificate, data.headers.Authorization.email);
59
+ }
60
+ catch (err) {
61
+ l.error("there was a problem adding issued certificate to database", "error", err);
62
+ }
63
+ l.info("completed issuing certificate", ...group("certificate", "principals", certificate.subjects.map(v => v.toString()).join(","), "serial", certificate.serial.readBigUInt64BE(0)));
64
+ return c.json(response);
65
+ }
66
+ catch (err) {
67
+ switch (true) {
68
+ case (err instanceof ApiException):
69
+ // re-throw any exisiting chanfana error
70
+ throw err;
71
+ default:
72
+ // otherwise throw as InternalServerErrorException
73
+ l.error("unhandled error", "error", err);
74
+ throw new InternalServerErrorException("internal server error");
75
+ }
76
+ }
77
+ }
78
+ };
79
+ };
80
+ const RevocationListEndpoint = (env) => {
81
+ return class extends OpenAPIRoute {
82
+ schema = createRevocationListEndpointSchema(env);
83
+ async handle(c) {
84
+ const data = await this.getValidatedData();
85
+ const l = logger(env).with(...group("request", "type", data.params.certificateType, "action", "krl"));
86
+ const certificateType = data.params.certificateType === "user" ? CertificateType.User : CertificateType.Host;
87
+ try {
88
+ // get revoked serials
89
+ const serials = (await getRevocationList(c.env, certificateType)).map(v => BigInt(v));
90
+ // grab private key from secret store
91
+ const secret = await c.env.PRIVATE_KEY.get();
92
+ // parse key
93
+ const key = parsePrivateKey(secret);
94
+ // generate KRL
95
+ const krl = new KRLBuilder(key)
96
+ .addSerials(serials);
97
+ const krlBytes = krl.generate();
98
+ // convert to base64
99
+ const bytes = new Uint8Array(krlBytes);
100
+ let binary = "";
101
+ for (const byte of bytes) {
102
+ binary += String.fromCharCode(byte);
103
+ }
104
+ const base64Krl = btoa(binary);
105
+ const signature = await krl.signature();
106
+ return c.json({
107
+ krl: base64Krl,
108
+ signature: signature,
109
+ });
110
+ }
111
+ catch (err) {
112
+ switch (true) {
113
+ case (err instanceof KeyParseError):
114
+ l.error("error parsing private key");
115
+ throw new InternalServerErrorException("error parsing private key");
116
+ case (err instanceof ApiException):
117
+ // re-throw any exisiting chanfana error
118
+ throw err;
119
+ default:
120
+ // otherwise throw as InternalServerErrorException
121
+ l.error("unhandled error", "error", err);
122
+ throw new InternalServerErrorException("internal server error");
123
+ }
124
+ }
125
+ }
126
+ };
127
+ };
128
+ const HostCertificateRequestEndpoint = (env) => {
129
+ return class extends OpenAPIRoute {
130
+ schema = createHostCertificateRequestEndpointSchema(env);
131
+ async handle(c) {
132
+ const data = await this.getValidatedData();
133
+ const l = logger(env).with(...group("request", "type", "host", "action", "request"), ...group("auth_token", "email", data.headers.Authorization.email, "sub", data.headers.Authorization.sub), ...group("id_token", "sub", data.body.identity.sub));
134
+ // check id and access token are for the same user
135
+ if (data.body.identity.sub !== data.headers.Authorization.sub) {
136
+ l.error("token subjects did not match");
137
+ throw new ForbiddenException("possible token substitution as subjects for authentication and identity tokens did not match");
138
+ }
139
+ // check user can issue host certificates
140
+ if (!split(c.env.SSH_HOST_CERTIFICATE_ALLOWED_EMAILS).includes(data.headers.Authorization.email) && !data.body.identity.principals.some((p) => split(c.env.SSH_HOST_CERTIFICATE_ALLOWED_ROLES).includes(p))) {
141
+ l.error("unauthorized host certificate request");
142
+ throw new UnauthorizedException("User not allowed to issue host certificates");
143
+ }
144
+ const opts = {
145
+ lifetime: data.body.lifetime,
146
+ principals: data.body.principals,
147
+ };
148
+ try {
149
+ const certificate = await createSignedHostCertificate(c.env, data.body.public_key, opts);
150
+ const response = {
151
+ certificate: btoa(certificate.toString("openssh"))
152
+ };
153
+ try {
154
+ await recordCertificate(c.env, certificate, `host_${certificate.subjects[0].hostname}`, CertificateType.Host);
155
+ }
156
+ catch (err) {
157
+ l.error("there was a problem adding issued certificate to database", "error", err);
158
+ }
159
+ l.info("completed issuing certificate", ...group("certificate", "principals", certificate.subjects.map(v => v.toString()).join(","), "serial", certificate.serial.readBigUInt64BE(0)));
160
+ return c.json(response);
161
+ }
162
+ catch (err) {
163
+ switch (true) {
164
+ case (err instanceof ApiException):
165
+ // re-throw any exisiting chanfana error
166
+ throw err;
167
+ default:
168
+ // otherwise throw as InternalServerErrorException
169
+ l.error("unhandled error", "error", err);
170
+ throw new InternalServerErrorException("internal server error");
171
+ }
172
+ }
173
+ }
174
+ };
175
+ };
176
+ const HostCertificateRenewEndpoint = (env) => {
177
+ return class extends OpenAPIRoute {
178
+ schema = createHostCertificateRenewEndpointSchema(env);
179
+ async handle(c) {
180
+ const data = await this.getValidatedData();
181
+ const serial = data.body.certificate.serial.readBigUInt64BE(0);
182
+ const l = logger(env).with(...group("request", "type", "host", "action", "renewal", "serial", serial));
183
+ // use smaller of the current certificate lifetime and the requested lifetime
184
+ const originalLifetime = (data.body.certificate.validUntil.getTime() - data.body.certificate.validFrom.getTime()) / 1000;
185
+ const lifetime = originalLifetime > data.body.lifetime
186
+ ? data.body.lifetime
187
+ : originalLifetime;
188
+ const opts = {
189
+ lifetime: lifetime,
190
+ subjects: data.body.certificate.subjects
191
+ };
192
+ try {
193
+ const certificate = await createSignedHostCertificate(c.env, data.body.public_key, opts);
194
+ const response = {
195
+ certificate: btoa(certificate.toString("openssh"))
196
+ };
197
+ try {
198
+ await recordCertificate(c.env, certificate, `host_${certificate.subjects[0].hostname}`, CertificateType.Host);
199
+ }
200
+ catch (err) {
201
+ l.error("there was a problem adding issued certificate to database", "error", err);
202
+ }
203
+ l.info("completed issuing certificate", ...group("certificate", "principals", certificate.subjects.map(v => v.toString()).join(","), "serial", certificate.serial.readBigUInt64BE(0)));
204
+ return c.json(response);
205
+ }
206
+ catch (err) {
207
+ switch (true) {
208
+ case (err instanceof ApiException):
209
+ // re-throw any exisiting chanfana error
210
+ throw err;
211
+ case (err instanceof BadIssuerError):
212
+ throw new UnprocessableEntityException(err.message);
213
+ default:
214
+ // otherwise throw as InternalServerErrorException
215
+ l.error("unhandled error", "error", err);
216
+ throw new InternalServerErrorException("internal server error");
217
+ }
218
+ }
219
+ }
220
+ };
221
+ };
222
+ const RevokeCertificateEndpoint = (env) => {
223
+ return class extends OpenAPIRoute {
224
+ schema = createRevokeCertificateEndpointSchema(env);
225
+ async handle(c) {
226
+ const data = await this.getValidatedData();
227
+ const l = logger(env).with(...group("request", "type", data.params.certificateType, "action", "revoke", "serial", data.body.serial));
228
+ const certificatetype = data.params.certificateType === "user" ? CertificateType.User : CertificateType.Host;
229
+ // ensure certificate can be revoked
230
+ const status = await revocationStatus(c.env, data.body.serial, data.body.public_key.toString("ssh"), certificatetype);
231
+ switch (status) {
232
+ case RevocationStatus.Revoked:
233
+ l.warn("certificate is already revoked");
234
+ throw new ConflictException("certificate is already revoked");
235
+ case RevocationStatus.Unrevokable:
236
+ l.warn("cannot revoke this certificate");
237
+ throw new ForbiddenException("cannot revoke this certificate");
238
+ case RevocationStatus.NotFound:
239
+ l.warn("certificate not found");
240
+ throw new NotFoundException("certificate not found");
241
+ }
242
+ try {
243
+ const res = await revokeCertificate(c.env, data.body.serial);
244
+ if (res.length === 0) {
245
+ throw new InternalServerErrorException("no results were returned for revocation");
246
+ }
247
+ if (res.length > 1) {
248
+ l.warn("more than one revocation result was returned", "count", res.length);
249
+ }
250
+ l.info("completed revocation");
251
+ return c.json({
252
+ revoked_at: res[0].revoked_at
253
+ });
254
+ }
255
+ catch (err) {
256
+ switch (true) {
257
+ case (err instanceof ApiException):
258
+ // re-throw any exisiting chanfana error
259
+ throw err;
260
+ default:
261
+ // otherwise throw as InternalServerErrorException
262
+ l.error("unhandled error", "error", err);
263
+ throw new InternalServerErrorException("internal server error");
264
+ }
265
+ }
266
+ }
267
+ };
268
+ };
269
+ export const createApi = (env) => {
270
+ const api = fromHono(new Hono());
271
+ api.get("/ca", CaPublicKeyEndpoint(env));
272
+ api.post("/user/certificate", UserCertificateRequestEndpoint(env));
273
+ api.get("/:certificateType/krl", RevocationListEndpoint(env));
274
+ api.post("/host/certificate", HostCertificateRequestEndpoint(env));
275
+ api.post("/host/renew", HostCertificateRenewEndpoint(env));
276
+ api.post("/:certificateType/revoke", RevokeCertificateEndpoint(env));
277
+ return api;
278
+ };
@@ -0,0 +1,218 @@
1
+ import z from "zod";
2
+ import type { SshCaBindings } from "../../types";
3
+ export declare const CaPublicKeyEndpointSchema: {
4
+ responses: {
5
+ "200": {
6
+ content: {
7
+ "text/plain": {
8
+ schema: z.ZodString;
9
+ };
10
+ };
11
+ description: string;
12
+ };
13
+ };
14
+ };
15
+ export declare const createUserCertificateRequestEndpointSchema: (env: SshCaBindings) => {
16
+ security: {
17
+ oidcAuth: never[];
18
+ }[];
19
+ request: {
20
+ headers: z.ZodObject<{
21
+ Authorization: z.ZodPipe<z.ZodString, z.ZodTransform<{
22
+ email: string;
23
+ sub: string;
24
+ }, string>>;
25
+ }, z.core.$strip>;
26
+ body: {
27
+ content: {
28
+ "application/json": {
29
+ schema: z.ZodType<{
30
+ public_key: import("sshpk").Key;
31
+ proof: import("../../proof").ProofOfPossession;
32
+ identity: {
33
+ sub: string;
34
+ principals: string[];
35
+ };
36
+ extensions: string[];
37
+ lifetime: number;
38
+ }, unknown, z.core.$ZodTypeInternals<{
39
+ public_key: import("sshpk").Key;
40
+ proof: import("../../proof").ProofOfPossession;
41
+ identity: {
42
+ sub: string;
43
+ principals: string[];
44
+ };
45
+ extensions: string[];
46
+ lifetime: number;
47
+ }, unknown>>;
48
+ };
49
+ };
50
+ };
51
+ };
52
+ responses: {
53
+ "200": {
54
+ content: {
55
+ "application/json": {
56
+ schema: z.ZodType<{
57
+ certificate: Buffer<ArrayBuffer>;
58
+ }, unknown, z.core.$ZodTypeInternals<{
59
+ certificate: Buffer<ArrayBuffer>;
60
+ }, unknown>>;
61
+ };
62
+ };
63
+ description: string;
64
+ };
65
+ };
66
+ };
67
+ export declare const createRevocationListEndpointSchema: (env: SshCaBindings) => {
68
+ request: {
69
+ params: z.ZodObject<{
70
+ certificateType: z.ZodEnum<{
71
+ user: "user";
72
+ host: "host";
73
+ }>;
74
+ }, z.core.$strip>;
75
+ };
76
+ responses: {
77
+ "200": {
78
+ content: {
79
+ "application/json": {
80
+ schema: z.ZodType<{
81
+ krl: Buffer<ArrayBuffer>;
82
+ signature: string;
83
+ }, unknown, z.core.$ZodTypeInternals<{
84
+ krl: Buffer<ArrayBuffer>;
85
+ signature: string;
86
+ }, unknown>>;
87
+ };
88
+ };
89
+ description: string;
90
+ };
91
+ };
92
+ };
93
+ export declare const createHostCertificateRequestEndpointSchema: (env: SshCaBindings) => {
94
+ security: {
95
+ oidcAuth: never[];
96
+ }[];
97
+ request: {
98
+ headers: z.ZodObject<{
99
+ Authorization: z.ZodPipe<z.ZodString, z.ZodTransform<{
100
+ email: string;
101
+ sub: string;
102
+ }, string>>;
103
+ }, z.core.$strip>;
104
+ body: {
105
+ content: {
106
+ "application/json": {
107
+ schema: z.ZodType<{
108
+ public_key: import("sshpk").Key;
109
+ proof: import("../../proof").ProofOfPossession;
110
+ identity: {
111
+ sub: string;
112
+ principals: string[];
113
+ };
114
+ principals: string[];
115
+ lifetime: number;
116
+ }, unknown, z.core.$ZodTypeInternals<{
117
+ public_key: import("sshpk").Key;
118
+ proof: import("../../proof").ProofOfPossession;
119
+ identity: {
120
+ sub: string;
121
+ principals: string[];
122
+ };
123
+ principals: string[];
124
+ lifetime: number;
125
+ }, unknown>>;
126
+ };
127
+ };
128
+ };
129
+ };
130
+ responses: {
131
+ "200": {
132
+ content: {
133
+ "application/json": {
134
+ schema: z.ZodType<{
135
+ certificate: Buffer<ArrayBuffer>;
136
+ }, unknown, z.core.$ZodTypeInternals<{
137
+ certificate: Buffer<ArrayBuffer>;
138
+ }, unknown>>;
139
+ };
140
+ };
141
+ description: string;
142
+ };
143
+ };
144
+ };
145
+ export declare const createHostCertificateRenewEndpointSchema: (env: SshCaBindings) => {
146
+ request: {
147
+ body: {
148
+ content: {
149
+ "application/json": {
150
+ schema: z.ZodType<{
151
+ certificate: import("sshpk").Certificate;
152
+ public_key: import("sshpk").Key;
153
+ proof: import("../../proof").RenewalProofOfPossession;
154
+ lifetime: number;
155
+ }, unknown, z.core.$ZodTypeInternals<{
156
+ certificate: import("sshpk").Certificate;
157
+ public_key: import("sshpk").Key;
158
+ proof: import("../../proof").RenewalProofOfPossession;
159
+ lifetime: number;
160
+ }, unknown>>;
161
+ };
162
+ };
163
+ };
164
+ };
165
+ responses: {
166
+ "200": {
167
+ content: {
168
+ "application/json": {
169
+ schema: z.ZodType<{
170
+ certificate: Buffer<ArrayBuffer>;
171
+ }, unknown, z.core.$ZodTypeInternals<{
172
+ certificate: Buffer<ArrayBuffer>;
173
+ }, unknown>>;
174
+ };
175
+ };
176
+ description: string;
177
+ };
178
+ };
179
+ };
180
+ export declare const createRevokeCertificateEndpointSchema: (env: SshCaBindings) => {
181
+ request: {
182
+ params: z.ZodObject<{
183
+ certificateType: z.ZodEnum<{
184
+ user: "user";
185
+ host: "host";
186
+ }>;
187
+ }, z.core.$strip>;
188
+ body: {
189
+ content: {
190
+ "application/json": {
191
+ schema: z.ZodType<{
192
+ serial: bigint;
193
+ public_key: import("sshpk").Key;
194
+ proof: import("../../proof").ProofOfPossession;
195
+ }, unknown, z.core.$ZodTypeInternals<{
196
+ serial: bigint;
197
+ public_key: import("sshpk").Key;
198
+ proof: import("../../proof").ProofOfPossession;
199
+ }, unknown>>;
200
+ };
201
+ };
202
+ };
203
+ };
204
+ responses: {
205
+ "200": {
206
+ content: {
207
+ "application/json": {
208
+ schema: z.ZodType<{
209
+ revoked_at: string;
210
+ }, unknown, z.core.$ZodTypeInternals<{
211
+ revoked_at: string;
212
+ }, unknown>>;
213
+ };
214
+ };
215
+ description: string;
216
+ };
217
+ };
218
+ };