firebase-tools 9.21.0 → 9.22.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 +3 -3
- package/lib/api.js +2 -0
- package/lib/apiv2.js +3 -2
- package/lib/commands/crashlytics-symbols-upload.js +1 -1
- package/lib/commands/deploy.js +9 -1
- package/lib/commands/ext-configure.js +1 -1
- package/lib/commands/ext-dev-deprecate.js +63 -0
- package/lib/commands/ext-dev-undeprecate.js +56 -0
- package/lib/commands/ext-export.js +44 -0
- package/lib/commands/ext-install.js +1 -1
- package/lib/commands/ext-update.js +1 -1
- package/lib/commands/functions-delete.js +2 -0
- package/lib/commands/index.js +6 -5
- package/lib/commands/init.js +3 -0
- package/lib/config.js +3 -2
- package/lib/deploy/extensions/args.js +2 -0
- package/lib/deploy/extensions/deploy.js +49 -0
- package/lib/deploy/extensions/deploymentSummary.js +52 -0
- package/lib/deploy/extensions/errors.js +31 -0
- package/lib/deploy/extensions/index.js +8 -0
- package/lib/deploy/extensions/planner.js +95 -0
- package/lib/deploy/extensions/prepare.js +103 -0
- package/lib/deploy/extensions/release.js +43 -0
- package/lib/deploy/extensions/secrets.js +150 -0
- package/lib/deploy/extensions/tasks.js +98 -0
- package/lib/deploy/extensions/validate.js +17 -0
- package/lib/deploy/functions/backend.js +8 -1
- package/lib/deploy/functions/containerCleaner.js +77 -21
- package/lib/deploy/functions/release/fabricator.js +69 -9
- package/lib/deploy/functions/release/index.js +5 -1
- package/lib/deploy/functions/release/planner.js +3 -0
- package/lib/deploy/functions/release/reporter.js +4 -1
- package/lib/deploy/functions/runtimes/discovery/v1alpha1.js +28 -0
- package/lib/deploy/functions/runtimes/node/parseTriggers.js +7 -2
- package/lib/deploy/index.js +1 -0
- package/lib/emulator/functionsEmulator.js +3 -1
- package/lib/extensions/askUserForParam.js +14 -6
- package/lib/extensions/checkProjectBilling.js +7 -7
- package/lib/extensions/export.js +107 -0
- package/lib/extensions/extensionsApi.js +103 -21
- package/lib/extensions/extensionsHelper.js +4 -1
- package/lib/extensions/listExtensions.js +16 -11
- package/lib/extensions/paramHelper.js +6 -4
- package/lib/extensions/provisioningHelper.js +16 -3
- package/lib/extensions/refs.js +9 -1
- package/lib/extensions/secretsUtils.js +10 -9
- package/lib/extensions/updateHelper.js +12 -2
- package/lib/extensions/versionHelper.js +14 -0
- package/lib/extensions/warnings.js +33 -1
- package/lib/gcp/artifactregistry.js +16 -0
- package/lib/gcp/cloudfunctions.js +25 -7
- package/lib/gcp/cloudfunctionsv2.js +10 -2
- package/lib/gcp/cloudtasks.js +143 -0
- package/lib/gcp/docker.js +7 -1
- package/lib/gcp/proto.js +2 -2
- package/lib/gcp/secretManager.js +27 -6
- package/lib/previews.js +1 -1
- package/package.json +2 -1
- package/schema/firebase-config.json +9 -0
|
@@ -158,13 +158,16 @@ function functionFromEndpoint(endpoint, source) {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
else if (backend.isScheduleTriggered(endpoint)) {
|
|
161
|
-
gcfFunction.labels = Object.assign(Object.assign({}, gcfFunction.labels), {
|
|
161
|
+
gcfFunction.labels = Object.assign(Object.assign({}, gcfFunction.labels), { "deployment-scheduled": "true" });
|
|
162
|
+
}
|
|
163
|
+
else if (backend.isTaskQueueTriggered(endpoint)) {
|
|
164
|
+
gcfFunction.labels = Object.assign(Object.assign({}, gcfFunction.labels), { "deployment-taskqueue": "true" });
|
|
162
165
|
}
|
|
163
166
|
return gcfFunction;
|
|
164
167
|
}
|
|
165
168
|
exports.functionFromEndpoint = functionFromEndpoint;
|
|
166
169
|
function endpointFromFunction(gcfFunction) {
|
|
167
|
-
var _a;
|
|
170
|
+
var _a, _b;
|
|
168
171
|
const [, project, , region, , id] = gcfFunction.name.split("/");
|
|
169
172
|
let trigger;
|
|
170
173
|
if (((_a = gcfFunction.labels) === null || _a === void 0 ? void 0 : _a["deployment-scheduled"]) === "true") {
|
|
@@ -172,6 +175,11 @@ function endpointFromFunction(gcfFunction) {
|
|
|
172
175
|
scheduleTrigger: {},
|
|
173
176
|
};
|
|
174
177
|
}
|
|
178
|
+
else if (((_b = gcfFunction.labels) === null || _b === void 0 ? void 0 : _b["deployment-taskqueue"]) === "true") {
|
|
179
|
+
trigger = {
|
|
180
|
+
taskQueueTrigger: {},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
175
183
|
else if (gcfFunction.eventTrigger) {
|
|
176
184
|
trigger = {
|
|
177
185
|
eventTrigger: {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.queueFromEndpoint = exports.queueNameForEndpoint = exports.setEnqueuer = exports.getIamPolicy = exports.setIamPolicy = exports.deleteQueue = exports.purgeQueue = exports.upsertQueue = exports.updateQueue = exports.getQueue = exports.createQueue = exports.DEFAULT_SETTINGS = void 0;
|
|
4
|
+
const proto = require("./proto");
|
|
5
|
+
const apiv2_1 = require("../apiv2");
|
|
6
|
+
const api_1 = require("../api");
|
|
7
|
+
const API_VERSION = "v2";
|
|
8
|
+
const client = new apiv2_1.Client({
|
|
9
|
+
urlPrefix: api_1.cloudTasksOrigin,
|
|
10
|
+
auth: true,
|
|
11
|
+
apiVersion: API_VERSION,
|
|
12
|
+
});
|
|
13
|
+
exports.DEFAULT_SETTINGS = {
|
|
14
|
+
rateLimits: {
|
|
15
|
+
maxConcurrentDispatches: 1000,
|
|
16
|
+
maxBurstSize: 100,
|
|
17
|
+
maxDispatchesPerSecond: 500,
|
|
18
|
+
},
|
|
19
|
+
state: "RUNNING",
|
|
20
|
+
retryConfig: {
|
|
21
|
+
maxDoublings: 16,
|
|
22
|
+
maxAttempts: 3,
|
|
23
|
+
maxBackoff: "3600s",
|
|
24
|
+
minBackoff: "0.100s",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
async function createQueue(queue) {
|
|
28
|
+
const path = queue.name.substring(0, queue.name.lastIndexOf("/"));
|
|
29
|
+
const res = await client.post(path, queue);
|
|
30
|
+
return res.body;
|
|
31
|
+
}
|
|
32
|
+
exports.createQueue = createQueue;
|
|
33
|
+
async function getQueue(name) {
|
|
34
|
+
const res = await client.get(name);
|
|
35
|
+
return res.body;
|
|
36
|
+
}
|
|
37
|
+
exports.getQueue = getQueue;
|
|
38
|
+
async function updateQueue(queue) {
|
|
39
|
+
const res = await client.patch(queue.name, queue, {
|
|
40
|
+
queryParams: { updateMask: proto.fieldMasks(queue).join(",") },
|
|
41
|
+
});
|
|
42
|
+
return res.body;
|
|
43
|
+
}
|
|
44
|
+
exports.updateQueue = updateQueue;
|
|
45
|
+
async function upsertQueue(queue) {
|
|
46
|
+
var _a, _b;
|
|
47
|
+
try {
|
|
48
|
+
const existing = await module.exports.getQueue(queue.name);
|
|
49
|
+
if (JSON.stringify(queue) === JSON.stringify(existing)) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (existing.state === "DISABLED") {
|
|
53
|
+
await module.exports.purgeQueue(queue.name);
|
|
54
|
+
}
|
|
55
|
+
await module.exports.updateQueue(queue);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (((_b = (_a = err === null || err === void 0 ? void 0 : err.context) === null || _a === void 0 ? void 0 : _a.response) === null || _b === void 0 ? void 0 : _b.statusCode) === 404) {
|
|
60
|
+
await module.exports.createQueue(queue);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.upsertQueue = upsertQueue;
|
|
67
|
+
async function purgeQueue(name) {
|
|
68
|
+
await client.post(`${name}:purge`);
|
|
69
|
+
}
|
|
70
|
+
exports.purgeQueue = purgeQueue;
|
|
71
|
+
async function deleteQueue(name) {
|
|
72
|
+
await client.delete(name);
|
|
73
|
+
}
|
|
74
|
+
exports.deleteQueue = deleteQueue;
|
|
75
|
+
async function setIamPolicy(name, policy) {
|
|
76
|
+
const res = await client.post(`${name}:setIamPolicy`, {
|
|
77
|
+
policy,
|
|
78
|
+
});
|
|
79
|
+
return res.body;
|
|
80
|
+
}
|
|
81
|
+
exports.setIamPolicy = setIamPolicy;
|
|
82
|
+
async function getIamPolicy(name) {
|
|
83
|
+
const res = await client.post(`${name}:getIamPolicy`);
|
|
84
|
+
return res.body;
|
|
85
|
+
}
|
|
86
|
+
exports.getIamPolicy = getIamPolicy;
|
|
87
|
+
const ENQUEUER_ROLE = "roles/cloudtasks.enqueuer";
|
|
88
|
+
async function setEnqueuer(name, invoker, assumeEmpty = false) {
|
|
89
|
+
var _a, _b;
|
|
90
|
+
let existing;
|
|
91
|
+
if (assumeEmpty) {
|
|
92
|
+
existing = {
|
|
93
|
+
bindings: [],
|
|
94
|
+
etag: "",
|
|
95
|
+
version: 3,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
existing = await module.exports.getIamPolicy(name);
|
|
100
|
+
}
|
|
101
|
+
const [, project] = name.split("/");
|
|
102
|
+
const invokerMembers = proto.getInvokerMembers(invoker, project);
|
|
103
|
+
while (true) {
|
|
104
|
+
const policy = {
|
|
105
|
+
bindings: existing.bindings.filter((binding) => binding.role != ENQUEUER_ROLE),
|
|
106
|
+
etag: existing.etag,
|
|
107
|
+
version: existing.version,
|
|
108
|
+
};
|
|
109
|
+
if (invokerMembers.length) {
|
|
110
|
+
policy.bindings.push({ role: ENQUEUER_ROLE, members: invokerMembers });
|
|
111
|
+
}
|
|
112
|
+
if (JSON.stringify(policy) === JSON.stringify(existing)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await module.exports.setIamPolicy(name, policy);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (((_b = (_a = err === null || err === void 0 ? void 0 : err.context) === null || _a === void 0 ? void 0 : _a.response) === null || _b === void 0 ? void 0 : _b.statusCode) === 429) {
|
|
121
|
+
existing = await module.exports.getIamPolicy(name);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
exports.setEnqueuer = setEnqueuer;
|
|
129
|
+
function queueNameForEndpoint(endpoint) {
|
|
130
|
+
return `projects/${endpoint.project}/locations/${endpoint.region}/queues/${endpoint.id}`;
|
|
131
|
+
}
|
|
132
|
+
exports.queueNameForEndpoint = queueNameForEndpoint;
|
|
133
|
+
function queueFromEndpoint(endpoint) {
|
|
134
|
+
const queue = Object.assign(Object.assign({}, JSON.parse(JSON.stringify(exports.DEFAULT_SETTINGS))), { name: queueNameForEndpoint(endpoint) });
|
|
135
|
+
if (endpoint.taskQueueTrigger.rateLimits) {
|
|
136
|
+
proto.copyIfPresent(queue.rateLimits, endpoint.taskQueueTrigger.rateLimits, "maxBurstSize", "maxConcurrentDispatches", "maxDispatchesPerSecond");
|
|
137
|
+
}
|
|
138
|
+
if (endpoint.taskQueueTrigger.retryConfig) {
|
|
139
|
+
proto.copyIfPresent(queue.retryConfig, endpoint.taskQueueTrigger.retryConfig, "maxAttempts", "maxBackoff", "maxDoublings", "maxRetryDuration", "minBackoff");
|
|
140
|
+
}
|
|
141
|
+
return queue;
|
|
142
|
+
}
|
|
143
|
+
exports.queueFromEndpoint = queueFromEndpoint;
|
package/lib/gcp/docker.js
CHANGED
|
@@ -4,7 +4,7 @@ exports.Client = void 0;
|
|
|
4
4
|
const error_1 = require("../error");
|
|
5
5
|
const api = require("../apiv2");
|
|
6
6
|
function isErrors(response) {
|
|
7
|
-
return Object.prototype.hasOwnProperty.call(response, "errors");
|
|
7
|
+
return !!response && Object.prototype.hasOwnProperty.call(response, "errors");
|
|
8
8
|
}
|
|
9
9
|
const API_VERSION = "v2";
|
|
10
10
|
class Client {
|
|
@@ -27,6 +27,9 @@ class Client {
|
|
|
27
27
|
async deleteTag(path, tag) {
|
|
28
28
|
var _a;
|
|
29
29
|
const response = await this.client.delete(`${path}/manifests/${tag}`);
|
|
30
|
+
if (!response.body) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
30
33
|
if (((_a = response.body.errors) === null || _a === void 0 ? void 0 : _a.length) != 0) {
|
|
31
34
|
throw new error_1.FirebaseError(`Failed to delete tag ${tag} at path ${path}`, {
|
|
32
35
|
children: response.body.errors,
|
|
@@ -36,6 +39,9 @@ class Client {
|
|
|
36
39
|
async deleteImage(path, digest) {
|
|
37
40
|
var _a;
|
|
38
41
|
const response = await this.client.delete(`${path}/manifests/${digest}`);
|
|
42
|
+
if (!response.body) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
39
45
|
if (((_a = response.body.errors) === null || _a === void 0 ? void 0 : _a.length) != 0) {
|
|
40
46
|
throw new error_1.FirebaseError(`Failed to delete image ${digest} at path ${path}`, {
|
|
41
47
|
children: response.body.errors,
|
package/lib/gcp/proto.js
CHANGED
|
@@ -67,10 +67,10 @@ function fieldMasksHelper(prefixes, cursor, doNotRecurseIn, masks) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
function getInvokerMembers(invoker, projectId) {
|
|
70
|
-
if (invoker
|
|
70
|
+
if (invoker.includes("private")) {
|
|
71
71
|
return [];
|
|
72
72
|
}
|
|
73
|
-
if (invoker
|
|
73
|
+
if (invoker.includes("public")) {
|
|
74
74
|
return ["allUsers"];
|
|
75
75
|
}
|
|
76
76
|
return invoker.map((inv) => formatServiceAccount(inv, projectId));
|
package/lib/gcp/secretManager.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.grantServiceAgentRole = exports.addVersion = exports.createSecret = exports.parseSecretResourceName = exports.secretExists = exports.
|
|
3
|
+
exports.grantServiceAgentRole = exports.addVersion = exports.createSecret = exports.toSecretVersionResourceName = exports.parseSecretVersionResourceName = exports.parseSecretResourceName = exports.secretExists = exports.getSecretVersion = exports.getSecret = exports.listSecrets = exports.secretManagerConsoleUri = void 0;
|
|
4
|
+
const utils_1 = require("../utils");
|
|
4
5
|
const api = require("../api");
|
|
6
|
+
exports.secretManagerConsoleUri = (projectId) => `https://console.cloud.google.com/security/secret-manager?project=${projectId}`;
|
|
5
7
|
async function listSecrets(projectId) {
|
|
6
8
|
const listRes = await api.request("GET", `/v1beta1/projects/${projectId}/secrets`, {
|
|
7
9
|
auth: true,
|
|
@@ -11,21 +13,24 @@ async function listSecrets(projectId) {
|
|
|
11
13
|
}
|
|
12
14
|
exports.listSecrets = listSecrets;
|
|
13
15
|
async function getSecret(projectId, name) {
|
|
16
|
+
var _a;
|
|
14
17
|
const getRes = await api.request("GET", `/v1beta1/projects/${projectId}/secrets/${name}`, {
|
|
15
18
|
auth: true,
|
|
16
19
|
origin: api.secretManagerOrigin,
|
|
17
20
|
});
|
|
18
|
-
|
|
21
|
+
const secret = parseSecretResourceName(getRes.body.name);
|
|
22
|
+
secret.labels = (_a = getRes.body.labels) !== null && _a !== void 0 ? _a : {};
|
|
23
|
+
return secret;
|
|
19
24
|
}
|
|
20
25
|
exports.getSecret = getSecret;
|
|
21
|
-
async function
|
|
22
|
-
const getRes = await api.request("GET", `/v1beta1/projects/${projectId}/secrets/${name}`, {
|
|
26
|
+
async function getSecretVersion(projectId, name, version) {
|
|
27
|
+
const getRes = await api.request("GET", `/v1beta1/projects/${projectId}/secrets/${name}/versions/${version}`, {
|
|
23
28
|
auth: true,
|
|
24
29
|
origin: api.secretManagerOrigin,
|
|
25
30
|
});
|
|
26
|
-
return getRes.body.
|
|
31
|
+
return parseSecretVersionResourceName(getRes.body.name);
|
|
27
32
|
}
|
|
28
|
-
exports.
|
|
33
|
+
exports.getSecretVersion = getSecretVersion;
|
|
29
34
|
async function secretExists(projectId, name) {
|
|
30
35
|
try {
|
|
31
36
|
await getSecret(projectId, name);
|
|
@@ -47,6 +52,21 @@ function parseSecretResourceName(resourceName) {
|
|
|
47
52
|
};
|
|
48
53
|
}
|
|
49
54
|
exports.parseSecretResourceName = parseSecretResourceName;
|
|
55
|
+
function parseSecretVersionResourceName(resourceName) {
|
|
56
|
+
const nameTokens = resourceName.split("/");
|
|
57
|
+
return {
|
|
58
|
+
secret: {
|
|
59
|
+
projectId: nameTokens[1],
|
|
60
|
+
name: nameTokens[3],
|
|
61
|
+
},
|
|
62
|
+
versionId: nameTokens[5],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
exports.parseSecretVersionResourceName = parseSecretVersionResourceName;
|
|
66
|
+
function toSecretVersionResourceName(secretVersion) {
|
|
67
|
+
return `projects/${secretVersion.secret.projectId}/secrets/${secretVersion.secret.name}/versions/${secretVersion.versionId}`;
|
|
68
|
+
}
|
|
69
|
+
exports.toSecretVersionResourceName = toSecretVersionResourceName;
|
|
50
70
|
async function createSecret(projectId, name, labels) {
|
|
51
71
|
const createRes = await api.request("POST", `/v1beta1/projects/${projectId}/secrets?secretId=${name}`, {
|
|
52
72
|
auth: true,
|
|
@@ -107,5 +127,6 @@ async function grantServiceAgentRole(secret, serviceAccountEmail, role) {
|
|
|
107
127
|
},
|
|
108
128
|
},
|
|
109
129
|
});
|
|
130
|
+
utils_1.logLabeledSuccess("SecretManager", `Granted ${role} on projects/${secret.projectId}/secrets/${secret.name} to ${serviceAccountEmail}`);
|
|
110
131
|
}
|
|
111
132
|
exports.grantServiceAgentRole = grantServiceAgentRole;
|
package/lib/previews.js
CHANGED
|
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.previews = void 0;
|
|
4
4
|
const lodash_1 = require("lodash");
|
|
5
5
|
const configstore_1 = require("./configstore");
|
|
6
|
-
exports.previews = Object.assign({ rtdbrules: false, ext: false, extdev: false, rtdbmanagement: false, functionsv2: false, golang: false, deletegcfartifacts: false, dotenv: false,
|
|
6
|
+
exports.previews = Object.assign({ rtdbrules: false, ext: false, extdev: false, rtdbmanagement: false, functionsv2: false, golang: false, deletegcfartifacts: false, dotenv: false, artifactregistry: false }, configstore_1.configstore.get("previews"));
|
|
7
7
|
if (process.env.FIREBASE_CLI_PREVIEWS) {
|
|
8
8
|
process.env.FIREBASE_CLI_PREVIEWS.split(",").forEach((feature) => {
|
|
9
9
|
if (lodash_1.has(exports.previews, feature)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firebase-tools",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.22.0",
|
|
4
4
|
"description": "Command-Line Interface for Firebase",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"test:client-integration": "./scripts/client-integration-tests/run.sh",
|
|
27
27
|
"test:compile": "tsc --project tsconfig.compile.json",
|
|
28
28
|
"test:emulator": "./scripts/emulator-tests/run.sh",
|
|
29
|
+
"test:extensions-deploy": "./scripts/extensions-deploy-tests/run.sh",
|
|
29
30
|
"test:extensions-emulator": "./scripts/extensions-emulator-tests/run.sh",
|
|
30
31
|
"test:hosting": "./scripts/hosting-tests/run.sh",
|
|
31
32
|
"test:triggers-end-to-end": "./scripts/triggers-end-to-end-tests/run.sh",
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"additionalProperties": false,
|
|
4
|
+
"definitions": {
|
|
5
|
+
"ExtensionsConfig": {
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"type": "object"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
4
10
|
"properties": {
|
|
5
11
|
"database": {
|
|
6
12
|
"anyOf": [
|
|
@@ -273,6 +279,9 @@
|
|
|
273
279
|
},
|
|
274
280
|
"type": "object"
|
|
275
281
|
},
|
|
282
|
+
"extensions": {
|
|
283
|
+
"$ref": "#/definitions/ExtensionsConfig"
|
|
284
|
+
},
|
|
276
285
|
"firestore": {
|
|
277
286
|
"additionalProperties": false,
|
|
278
287
|
"properties": {
|