@thinger-io/cli-core 0.4.2 → 0.5.1
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 +207 -0
- package/README.md +82 -31
- package/lib/auth.js +25 -1
- package/lib/bucket-export.js +91 -0
- package/lib/cli.js +106 -50
- package/lib/commands/_shared.js +89 -1
- package/lib/commands/api.js +90 -0
- package/lib/commands/arguments.js +62 -0
- package/lib/commands/bucket.js +52 -44
- package/lib/commands/dashboard.js +5 -2
- package/lib/commands/data.js +539 -0
- package/lib/commands/device.js +128 -241
- package/lib/commands/grammar.js +279 -0
- package/lib/commands/product.js +104 -214
- package/lib/commands/profile.js +1 -0
- package/lib/commands/resources.js +975 -0
- package/lib/dashboard-init.js +4 -3
- package/lib/dashboard.js +10 -2
- package/lib/mcp/catalog.js +302 -64
- package/lib/mcp/core-types.js +36 -0
- package/lib/mcp/helpers.js +23 -2
- package/lib/mcp/naming.js +20 -0
- package/lib/mcp/registry.js +18 -67
- package/lib/mcp/resource-hooks.js +19 -5
- package/lib/mcp/server.js +43 -11
- package/lib/mcp/tools-resource.js +1234 -330
- package/lib/mcp/types/bucket-data.js +151 -0
- package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
- package/lib/mcp/types/device-resource.js +117 -0
- package/lib/mcp/types/doc.js +35 -0
- package/lib/mcp/types/plugin-call.js +75 -0
- package/lib/mcp/types/product-api.js +44 -0
- package/lib/mcp/types/profile.js +37 -0
- package/lib/mcp/types/storage-file.js +170 -0
- package/lib/openapi.js +18 -0
- package/lib/product-apply.js +121 -0
- package/lib/raw-api.js +111 -0
- package/lib/storage.js +41 -13
- package/lib/tokens.js +16 -2
- package/package.json +1 -1
- package/lib/mcp/tools-buckets.js +0 -130
- package/lib/mcp/tools-devices.js +0 -134
- package/lib/mcp/tools-misc.js +0 -48
- package/lib/mcp/tools-plugins.js +0 -63
- package/lib/mcp/tools-products.js +0 -52
- package/lib/mcp/tools-storage.js +0 -173
package/lib/mcp/catalog.js
CHANGED
|
@@ -30,6 +30,11 @@ import { inputError } from '../errors.js';
|
|
|
30
30
|
*
|
|
31
31
|
* A type without `id` is a single resource of its parent (get / update on
|
|
32
32
|
* the path itself).
|
|
33
|
+
*
|
|
34
|
+
* The tools locate a record with `ids`: its own id and its parents', each
|
|
35
|
+
* under its name (`{"device": "boiler", "property": "setpoint"}`). The name
|
|
36
|
+
* is the REST placeholder, the one the server uses in bodies and events as
|
|
37
|
+
* well (`rule` for an alarm rule, `type` for an asset type).
|
|
33
38
|
*/
|
|
34
39
|
|
|
35
40
|
/**
|
|
@@ -43,16 +48,31 @@ import { inputError } from '../errors.js';
|
|
|
43
48
|
* @property {'v1' | 'v2' | 'v3'} [version] When it differs from the type's.
|
|
44
49
|
* @property {Record<string, string>} [query] Fixed query parameters (the caller's are added).
|
|
45
50
|
* @property {string[]} [bodyFromRecord] Without a `body`, send these fields of the saved record.
|
|
51
|
+
* @property {boolean} [readOnly] Changes nothing: served in read-only mode.
|
|
52
|
+
* @property {boolean} [bodyRequired] Refused without a body, before any request.
|
|
53
|
+
* @property {boolean} [confirm] High impact: without body.confirm true it only answers
|
|
54
|
+
* what it would do (or asks the user, when the client can).
|
|
55
|
+
* @property {Example[]} [examples] Ready-to-send calls, shown by resource_describe.
|
|
46
56
|
* @property {string} description
|
|
47
57
|
*
|
|
58
|
+
* @typedef {object} Example
|
|
59
|
+
* @property {string} tool Bare tool name: resource_list, resource_call…
|
|
60
|
+
* @property {Record<string, any>} arguments
|
|
61
|
+
* @property {string} does
|
|
62
|
+
*
|
|
48
63
|
* @typedef {object} ResourceType
|
|
49
64
|
* @property {string} description One line, shown in the catalog summary.
|
|
50
65
|
* @property {'v1' | 'v2' | 'v3'} version
|
|
51
66
|
* @property {string} path Collection path, relative to the parent item or the account.
|
|
52
67
|
* @property {string} [parent] Type this one hangs off.
|
|
53
|
-
* @property {string
|
|
68
|
+
* @property {string} [id] Name of the item id in the REST path (its placeholder), which
|
|
69
|
+
* is also the record's id field.
|
|
54
70
|
* @property {string[]} [bodyId] Body fields `create` copies the id into, when they differ
|
|
55
71
|
* from the id names; empty when the server generates the id.
|
|
72
|
+
* @property {{ field?: string, from?: 'key' | 'value' }} [recordId] Where a listed or read
|
|
73
|
+
* record carries its own id: a field (default: the id field), the key of a map answer
|
|
74
|
+
* (`from: 'key'`), or the value itself in a list of strings (`from: 'value'`).
|
|
75
|
+
* @property {Example[]} [examples] Ready-to-send calls, shown by resource_describe.
|
|
56
76
|
* @property {Partial<Record<Verb, string>>} ops Allowed verbs → server operationId.
|
|
57
77
|
* @property {boolean} [upsert] Created with PUT on the item path, so `create` needs the id.
|
|
58
78
|
* @property {string} [notes] Longer guidance returned by `resource_describe`.
|
|
@@ -74,13 +94,13 @@ const PROPERTY_NOTES =
|
|
|
74
94
|
'Properties hold any JSON `value`. Create with body {property, name?, description?, value}; update replaces the value ({value}), while update with merge: true patches it (JSON merge of objects).';
|
|
75
95
|
|
|
76
96
|
const PROFILE_NOTES =
|
|
77
|
-
'Product profile entries are keyed by name: create stores the entry under `id` (PUT), replacing what was there; update keeps the fields left out (the
|
|
97
|
+
'Product profile entries are keyed by name: create stores the entry under `id` (PUT), replacing what was there; update keeps the fields left out (the saved entry is sent with yours over it).';
|
|
78
98
|
|
|
79
99
|
/** Where profile listeners (buckets, properties) take their data from. */
|
|
80
100
|
const LISTENER_SOURCE =
|
|
81
101
|
'data.source is one of: "resource" (poll a device resource: {resource, update: "interval"|"event", interval, magnitude: "second"|"minute"|"hour"…}), "resource_stream" ({resource_stream}), "product_stream" ({product_stream, product?}), "topic" ({topic}, MQTT), "event" ({event, filter?}). Optional: data.payload_type ("template_payload" with data.payload such as "{{payload}}", "source_payload" to keep the payload as is, "source_event", "none") and data.payload_function (a function of the product code).';
|
|
82
102
|
|
|
83
|
-
const PROFILE_API_NOTES = `${PROFILE_NOTES} An API resource is a REST endpoint added to every device of the product and dispatched by the server: {enabled, request: {data: {target, payload_type?, payload?, payload_function?, ...}}, response: {data: {payload_type, source?, payload?, payload_function?}}}. request.data.target is one of: "resource" ({resource}: a device resource), "resource_stream" ({resource_stream}; defaults to the API resource name), "product_stream" ({product, product_stream}), "property" ({property}), "function" ({function}: product code), "endpoint_call" ({endpoint}), "plugin_endpoint" ({plugin, path}), "topic" ({topic}), "none". Payload types: "template_payload" (default; data.payload is a template such as "{{payload}}"), "source_payload" (forward the body), "source_event" (the whole event), "none". response.data {payload_type: "none"} is fire-and-forget; "source_payload" returns the target's answer. Example: {"enabled": true, "request": {"data": {"target": "resource_stream", "resource_stream": "backup_complete", "payload_type": "source_payload"}}, "response": {"data": {"payload_type": "none"}}}. Called by device ({
|
|
103
|
+
const PROFILE_API_NOTES = `${PROFILE_NOTES} An API resource is a REST endpoint added to every device of the product and dispatched by the server: {enabled, request: {data: {target, payload_type?, payload?, payload_function?, ...}}, response: {data: {payload_type, source?, payload?, payload_function?}}}. request.data.target is one of: "resource" ({resource}: a device resource), "resource_stream" ({resource_stream}; defaults to the API resource name), "product_stream" ({product, product_stream}), "property" ({property}), "function" ({function}: product code), "endpoint_call" ({endpoint}), "plugin_endpoint" ({plugin, path}), "topic" ({topic}), "none". Payload types: "template_payload" (default; data.payload is a template such as "{{payload}}"), "source_payload" (forward the body), "source_event" (the whole event), "none". response.data {payload_type: "none"} is fire-and-forget; "source_payload" returns the target's answer. Example: {"enabled": true, "request": {"data": {"target": "resource_stream", "resource_stream": "backup_complete", "payload_type": "source_payload"}}, "response": {"data": {"payload_type": "none"}}}. Called by device ({op:device_resource.call} on a device of the product), or for the product as a whole with {op:product_profile_api.call}, which needs "device_id_resolver": "<function of the product code>" in the entry to find each payload's device. A resource computed by code (target "function") also needs response {"source": "request_response", "payload_type": "source_payload"} to return the value; this is how Virtual devices get data ({op:doc.get} ids {"doc": "product-code"}).`;
|
|
84
104
|
|
|
85
105
|
const PROFILE_BUCKET_NOTES = `${PROFILE_NOTES} A profile bucket stores, for each device of the product, the samples of a data source; the bucket itself is created on the first sample. Shape: {enabled, name, description, backend: "mongodb" (default) or "influxdb", data: {source, ...}, retention?: {period, unit: "minutes"|"hours"|"days"|"months"|"years"}, tags?: []}. ${LISTENER_SOURCE} Unless the bucket name has "{{device}}", samples get "device" and "group" tags, so one bucket holds the whole fleet. Sampling a resource needs data {"source": "resource", "resource", "update": "interval", "interval", "magnitude", "payload": "{{payload}}"} (or "payload_type": "source_payload"), which stores the value as it comes; the default template payload with no "payload" writes nothing. Deleting the entry stops recording but keeps the bucket and its data. Example: {"enabled": true, "name": "backups", "backend": "mongodb", "data": {"source": "resource_stream", "resource_stream": "backup_complete", "payload_type": "source_payload"}, "retention": {"period": 1, "unit": "years"}}.`;
|
|
86
106
|
|
|
@@ -103,16 +123,38 @@ export const CATALOG = {
|
|
|
103
123
|
clone: 'Device_Clone',
|
|
104
124
|
projects: 'Device_UpdateProjects',
|
|
105
125
|
},
|
|
106
|
-
notes: '`type` defaults to Generic (IOTMP). Generic and MQTT devices authenticate with `credentials`: when creating one without them, credentials are generated and returned once (the server stores them hashed). HTTP devices send data
|
|
126
|
+
notes: '`type` defaults to Generic (IOTMP). Generic and MQTT devices authenticate with `credentials`: when creating one without them, credentials are generated and returned once (the server stores them hashed). HTTP devices send data with their send action ({op:device.send}); assign a product with update {product} ("" unassigns). Virtual devices have no hardware: every resource comes from their product\'s API (product code can compute it), and they need a `location` property ({lat, lon}) to appear on maps, or they are placed by their IP. Native geofences (Thinger 7.4 and later): a `geofences` property on the device, or inherited from its asset type, group or product, holds a GeoJSON FeatureCollection ([lon, lat]; Polygon, or Point with properties.radius in meters; properties.onEnter / onExit name an endpoint or ""). Every change of the device `location` emits device_geofence_event (with geofence_id, geofence_properties, product, asset_type, asset_group): store it with a product profile bucket or flow whose source is that event. See the Geofences section of the guide {op:doc.get} ids {"doc": "concepts"}. A new Virtual device shows disconnected in the create answer and connects a moment later.',
|
|
107
127
|
actions: {
|
|
108
128
|
stats: {
|
|
109
129
|
op: 'Device_ReadStatistics',
|
|
130
|
+
readOnly: true,
|
|
110
131
|
method: 'get',
|
|
111
132
|
path: 'stats',
|
|
112
133
|
version: 'v3',
|
|
113
134
|
description:
|
|
114
135
|
'Usage over the last 30 days, per day: traffic and calls (empty when the server does not record usage).',
|
|
115
136
|
},
|
|
137
|
+
send: {
|
|
138
|
+
op: 'Device_CallCallbackData',
|
|
139
|
+
method: 'post',
|
|
140
|
+
path: 'callback/data',
|
|
141
|
+
version: 'v3',
|
|
142
|
+
bodyRequired: true,
|
|
143
|
+
description:
|
|
144
|
+
'Send `body` as an HTTP device would: the server runs its callback (write a bucket, set a property, call an endpoint; see type device_callback) and answers what it configures.',
|
|
145
|
+
examples: [
|
|
146
|
+
{
|
|
147
|
+
tool: 'resource_call',
|
|
148
|
+
arguments: {
|
|
149
|
+
type: 'device',
|
|
150
|
+
action: 'send',
|
|
151
|
+
ids: { device: 'boiler' },
|
|
152
|
+
body: { temperature: 21.5 },
|
|
153
|
+
},
|
|
154
|
+
does: 'Send a reading as the HTTP device boiler; its callback stores or forwards it.',
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
116
158
|
},
|
|
117
159
|
},
|
|
118
160
|
product: {
|
|
@@ -121,6 +163,7 @@ export const CATALOG = {
|
|
|
121
163
|
version: 'v1',
|
|
122
164
|
path: 'products',
|
|
123
165
|
id: 'product',
|
|
166
|
+
recordId: { field: 'product' },
|
|
124
167
|
ops: {
|
|
125
168
|
list: 'Product_List',
|
|
126
169
|
create: 'Product_Create',
|
|
@@ -134,6 +177,7 @@ export const CATALOG = {
|
|
|
134
177
|
actions: {
|
|
135
178
|
export: {
|
|
136
179
|
op: 'Product_Export',
|
|
180
|
+
readOnly: true,
|
|
137
181
|
method: 'get',
|
|
138
182
|
path: 'export',
|
|
139
183
|
description:
|
|
@@ -143,7 +187,7 @@ export const CATALOG = {
|
|
|
143
187
|
},
|
|
144
188
|
bucket: {
|
|
145
189
|
description:
|
|
146
|
-
'Data buckets (time series).
|
|
190
|
+
'Data buckets (time series). Their data is type bucket_data: {op:bucket_data.list} reads it, {op:bucket_data.create} writes samples.',
|
|
147
191
|
version: 'v1',
|
|
148
192
|
path: 'buckets',
|
|
149
193
|
id: 'bucket',
|
|
@@ -162,18 +206,18 @@ export const CATALOG = {
|
|
|
162
206
|
method: 'post',
|
|
163
207
|
path: 'imports',
|
|
164
208
|
description:
|
|
165
|
-
'Load records from a .csv already in a file storage (upload it with {
|
|
209
|
+
'Load records from a .csv already in a file storage (upload it with {op:storage_file.create}) into an existing bucket: body {source: "storage", storage, file}. Header row; ";" separated if the header has one, "," otherwise. The time column (ts, date or timestamp) takes unix MILLISECONDS (seconds are misread as ms) or ISO 8601, always taken as UTC. Other cells become numbers when they parse, text otherwise; empty and "null" are left out. Columns named like a bucket tag (config.tags) are stored as that tag: a device column in a bucket tagged `device` (product buckets are) makes the records readable per device. Writes straight to the backend (MongoDB or InfluxDB, not DynamoDB), with no rate limit and no bucket_write events; answers {"progress": N}, a percentage (100 when done; only the last line is shown). The way to load history in bulk: {op:bucket_data.create} is rate limited.',
|
|
166
210
|
},
|
|
167
211
|
},
|
|
168
212
|
},
|
|
169
213
|
dashboard: {
|
|
170
214
|
description:
|
|
171
|
-
'Dashboards: HTML apps on the dashboard SDK, published with {
|
|
215
|
+
'Dashboards: HTML apps on the dashboard SDK, published with {op:dashboard.publish} (widget layouts are legacy).',
|
|
172
216
|
version: 'v1',
|
|
173
217
|
path: 'dashboards',
|
|
174
218
|
id: 'dashboard',
|
|
175
219
|
sdk: true,
|
|
176
|
-
notes: 'Build dashboards as HTML apps that use the dashboard SDK (source "url", the page at `url`): {
|
|
220
|
+
notes: 'Build dashboards as HTML apps that use the dashboard SDK (source "url", the page at `url`): {op:dashboard.publish} uploads the app and points the dashboard at it. Start it with {op:dashboard.init} (a TypeScript project on the npm package @thinger-io/dashboard-sdk), develop it on its own dev server with {op:dashboard.dev} (a link that opens the local app against this server, in the context you pick), and publish it once it works. Widget layouts (source "widgets") are legacy, made in the console editor: read them, do not create new ones. The SDK reference is under `sdk`; its types come with the package (node_modules/@thinger-io/dashboard-sdk). A per-device, product, asset type, asset group or project dashboard is the "dashboard" property of that resource instead ({op:dashboard.publish} with body.to: [{type, id}]).',
|
|
177
221
|
ops: {
|
|
178
222
|
list: 'Dashboard_List',
|
|
179
223
|
create: 'Dashboard_Create',
|
|
@@ -211,7 +255,7 @@ export const CATALOG = {
|
|
|
211
255
|
notes: 'Every endpoint needs `config`, even type "virtual" (a no-op receiver for tests and alarm notifications): {"endpoint": "…", "name": "…", "type": "virtual", "config": {}}.',
|
|
212
256
|
},
|
|
213
257
|
storage: {
|
|
214
|
-
description: 'File storages
|
|
258
|
+
description: 'File storages; their files are type storage_file.',
|
|
215
259
|
version: 'v1',
|
|
216
260
|
path: 'storages',
|
|
217
261
|
id: 'storage',
|
|
@@ -275,6 +319,21 @@ export const CATALOG = {
|
|
|
275
319
|
version: 'v1',
|
|
276
320
|
path: 'tokens',
|
|
277
321
|
id: 'token',
|
|
322
|
+
examples: [
|
|
323
|
+
{
|
|
324
|
+
tool: 'resource_create',
|
|
325
|
+
arguments: {
|
|
326
|
+
type: 'token',
|
|
327
|
+
ids: { token: 'readonly_dashboard' },
|
|
328
|
+
body: {
|
|
329
|
+
name: 'Read-only dashboard',
|
|
330
|
+
allow: { Device: { '*': ['List*', 'Read*'] }, Bucket: { '*': ['Read*'] } },
|
|
331
|
+
expire: '30d',
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
does: 'A token that reads devices and buckets and expires in 30 days.',
|
|
335
|
+
},
|
|
336
|
+
],
|
|
278
337
|
ops: {
|
|
279
338
|
list: 'Token_List',
|
|
280
339
|
create: 'Token_Create',
|
|
@@ -300,18 +359,31 @@ export const CATALOG = {
|
|
|
300
359
|
clone: 'Alarm_CloneRule',
|
|
301
360
|
projects: 'Alarm_UpdateRuleProjects',
|
|
302
361
|
},
|
|
303
|
-
notes: 'Condition values are strings ("30", not 30: a number never matches) and only enabled devices are evaluated. A device_property source projects fields of the property document, so its fields are `value.<field>` (fields ["value.level"], condition field "battery.value.level"; "level" alone yields null and every device is skipped). The document also has `modified` and `created` (unix ms, UTC): fields ["modified"] with condition field `ts() - battery.modified` above "3600000" flags a property not updated for an hour. A condition value can name a field of another source of the same device ("limits.value.max_depth_m"): per-device thresholds, inherited from asset type, group or product; sources, conditions and confirmations are in {
|
|
362
|
+
notes: 'Condition values are strings ("30", not 30: a number never matches) and only enabled devices are evaluated. A device_property source projects fields of the property document, so its fields are `value.<field>` (fields ["value.level"], condition field "battery.value.level"; "level" alone yields null and every device is skipped). The document also has `modified` and `created` (unix ms, UTC): fields ["modified"] with condition field `ts() - battery.modified` above "3600000" flags a property not updated for an hour. A condition value can name a field of another source of the same device ("limits.value.max_depth_m"): per-device thresholds, inherited from asset type, group or product; sources, conditions and confirmations are in {op:doc.get} ids {"doc": "concepts"} (Alarms). Try a rule before saving it with its test_rule / test_source / test_notification actions ({tool:resource_call}).',
|
|
304
363
|
actions: {
|
|
305
364
|
test_rule: {
|
|
306
365
|
op: 'Alarm_TestRuleTest-rule',
|
|
366
|
+
readOnly: true,
|
|
307
367
|
method: 'post',
|
|
308
368
|
path: 'test-rule',
|
|
309
369
|
bodyFromRecord: ['name', 'description', 'enabled', 'config'],
|
|
310
370
|
description:
|
|
311
371
|
'Evaluate a whole rule without raising alarms or notifying: `body` is the rule as it would be saved ({name, enabled, config}), and the rule id may not exist yet. Without `body`, it tests the saved rule.',
|
|
372
|
+
examples: [
|
|
373
|
+
{
|
|
374
|
+
tool: 'resource_call',
|
|
375
|
+
arguments: {
|
|
376
|
+
type: 'alarm_rule',
|
|
377
|
+
action: 'test_rule',
|
|
378
|
+
ids: { rule: 'high_temp' },
|
|
379
|
+
},
|
|
380
|
+
does: 'Evaluate the saved rule high_temp now, without raising alarms.',
|
|
381
|
+
},
|
|
382
|
+
],
|
|
312
383
|
},
|
|
313
384
|
test_source: {
|
|
314
385
|
op: 'Alarm_TestRuleSourceTest-source',
|
|
386
|
+
readOnly: true,
|
|
315
387
|
method: 'post',
|
|
316
388
|
path: 'test-source',
|
|
317
389
|
description:
|
|
@@ -319,6 +391,7 @@ export const CATALOG = {
|
|
|
319
391
|
},
|
|
320
392
|
test_notification: {
|
|
321
393
|
op: 'Alarm_TestRuleNotificationTest-notification',
|
|
394
|
+
confirm: true,
|
|
322
395
|
method: 'post',
|
|
323
396
|
path: 'test-notification',
|
|
324
397
|
description:
|
|
@@ -332,6 +405,7 @@ export const CATALOG = {
|
|
|
332
405
|
version: 'v1',
|
|
333
406
|
path: 'alarms/instances',
|
|
334
407
|
id: 'instance',
|
|
408
|
+
recordId: { field: 'instance' },
|
|
335
409
|
ops: {
|
|
336
410
|
list: 'Alarm_ListInstances',
|
|
337
411
|
get: 'Alarm_ReadInstanceConfig',
|
|
@@ -342,6 +416,7 @@ export const CATALOG = {
|
|
|
342
416
|
actions: {
|
|
343
417
|
stats: {
|
|
344
418
|
op: 'Alarm_ReadInstanceStats',
|
|
419
|
+
readOnly: true,
|
|
345
420
|
method: 'get',
|
|
346
421
|
path: 'stats',
|
|
347
422
|
on: 'collection',
|
|
@@ -417,10 +492,11 @@ export const CATALOG = {
|
|
|
417
492
|
|
|
418
493
|
plugin: {
|
|
419
494
|
description:
|
|
420
|
-
'Plugins installed on the account: containers that add features (integrations, apps); their API is reached with
|
|
495
|
+
'Plugins installed on the account: containers that add features (integrations, apps); their API is reached with {op:plugin.call}.',
|
|
421
496
|
version: 'v1',
|
|
422
497
|
path: 'plugins',
|
|
423
498
|
id: 'plugin',
|
|
499
|
+
recordId: { field: 'name' },
|
|
424
500
|
ops: { list: 'Plugin_List', get: 'Plugin_ReadInfo' },
|
|
425
501
|
listQuery: { filter: 'installed' },
|
|
426
502
|
listWhole: true,
|
|
@@ -460,6 +536,7 @@ export const CATALOG = {
|
|
|
460
536
|
},
|
|
461
537
|
remove: {
|
|
462
538
|
op: 'Plugin_ManageState',
|
|
539
|
+
confirm: true,
|
|
463
540
|
method: 'post',
|
|
464
541
|
path: '',
|
|
465
542
|
query: { action: 'remove' },
|
|
@@ -503,6 +580,7 @@ export const CATALOG = {
|
|
|
503
580
|
},
|
|
504
581
|
kill: {
|
|
505
582
|
op: 'Plugin_ManageState',
|
|
583
|
+
confirm: true,
|
|
506
584
|
method: 'post',
|
|
507
585
|
path: '',
|
|
508
586
|
query: { action: 'kill' },
|
|
@@ -548,6 +626,7 @@ export const CATALOG = {
|
|
|
548
626
|
scope: 'server',
|
|
549
627
|
path: 'hosts',
|
|
550
628
|
id: 'host',
|
|
629
|
+
recordId: { field: 'host' },
|
|
551
630
|
ops: {
|
|
552
631
|
list: 'Host_List',
|
|
553
632
|
create: 'Host_Create',
|
|
@@ -555,10 +634,11 @@ export const CATALOG = {
|
|
|
555
634
|
update: 'Host_Update',
|
|
556
635
|
delete: 'Host_Delete',
|
|
557
636
|
},
|
|
558
|
-
notes: 'update_settings is a JSON merge over the host\'s custom settings and only takes effect after the restart action, which drops every connection: plan both with the user. Ports are strings. Read {
|
|
637
|
+
notes: 'update_settings is a JSON merge over the host\'s custom settings and only takes effect after the restart action, which drops every connection: plan both with the user. Ports are strings. Read {op:doc.get} ids {"doc": "server-admin"} first. read_settings leaves out some keys (limits of accounts, brands, domains and members, database, plugins, rate limiter); {tool:resource_get} of the host shows the effective ones in current_settings. On cloud instances (license "cloud" in /v1/server/version) the subscription manages those limits, database, plugins, rate limiter and http_server.hosts: changing them answers 403 naming the path; tell the user it depends on their plan.',
|
|
559
638
|
actions: {
|
|
560
639
|
logs: {
|
|
561
640
|
op: 'Host_ReadLogs',
|
|
641
|
+
readOnly: true,
|
|
562
642
|
method: 'get',
|
|
563
643
|
path: 'logs',
|
|
564
644
|
description:
|
|
@@ -566,6 +646,7 @@ export const CATALOG = {
|
|
|
566
646
|
},
|
|
567
647
|
restart: {
|
|
568
648
|
op: 'Host_UpdateStateRestart',
|
|
649
|
+
confirm: true,
|
|
569
650
|
method: 'post',
|
|
570
651
|
path: 'restart',
|
|
571
652
|
description:
|
|
@@ -573,16 +654,18 @@ export const CATALOG = {
|
|
|
573
654
|
},
|
|
574
655
|
read_settings: {
|
|
575
656
|
op: 'Host_ReadSettings',
|
|
657
|
+
readOnly: true,
|
|
576
658
|
method: 'get',
|
|
577
659
|
path: 'settings',
|
|
578
660
|
description: 'Read the host settings.',
|
|
579
661
|
},
|
|
580
662
|
update_settings: {
|
|
581
663
|
op: 'Host_UpdateSettings',
|
|
664
|
+
confirm: true,
|
|
582
665
|
method: 'put',
|
|
583
666
|
path: 'settings',
|
|
584
667
|
description:
|
|
585
|
-
'
|
|
668
|
+
'Patch the host settings with `body` (JSON merge: keys left out stay, null removes one); read them first. Confirm with the user first.',
|
|
586
669
|
},
|
|
587
670
|
},
|
|
588
671
|
},
|
|
@@ -608,7 +691,7 @@ export const CATALOG = {
|
|
|
608
691
|
'Check that the domain resolves to this server (after the user configures its DNS).',
|
|
609
692
|
},
|
|
610
693
|
},
|
|
611
|
-
notes: 'A domain is usable once it resolves to this server (an A/AAAA record with its IP, or a CNAME to its current hostname) with port 80 reachable, and the verify action succeeds (it gets the certificate; a failure says why: DNS, invalid suffix…). Ask the user to set up the DNS record, then verify. A brand with the same domain then gives it its look: {
|
|
694
|
+
notes: 'A domain is usable once it resolves to this server (an A/AAAA record with its IP, or a CNAME to its current hostname) with port 80 reachable, and the verify action succeeds (it gets the certificate; a failure says why: DNS, invalid suffix…). Ask the user to set up the DNS record, then verify. A brand with the same domain then gives it its look: {op:doc.get} ids {"doc": "server-admin"} has the whole flow.',
|
|
612
695
|
},
|
|
613
696
|
brand: {
|
|
614
697
|
description: 'Brands: the look (and email) of the console on a domain.',
|
|
@@ -616,7 +699,7 @@ export const CATALOG = {
|
|
|
616
699
|
scope: 'server',
|
|
617
700
|
path: 'brands',
|
|
618
701
|
id: 'brand',
|
|
619
|
-
notes: 'Change a brand with {tool:resource_update} and merge: true (JSON merge of config and email; null removes a key; logos are kept). Without merge the whole brand is replaced and logos missing from it are deleted: only send complete objects. Logos and images go as data URLs (png or webp); to keep one in a full update, send back its /brand/images/… URL. The email settings are the brand\'s `email` field; test them with the test_email action (sent to the admin calling). For the whole domain + brand setup read {
|
|
702
|
+
notes: 'Change a brand with {tool:resource_update} and merge: true (JSON merge of config and email; null removes a key; logos are kept). Without merge the whole brand is replaced and logos missing from it are deleted: only send complete objects. Logos and images go as data URLs (png or webp); to keep one in a full update, send back its /brand/images/… URL. The email settings are the brand\'s `email` field; test them with the test_email action (sent to the admin calling). For the whole domain + brand setup read {op:doc.get} ids {"doc": "server-admin"}. Reading a brand that does not exist returns defaults (only config.accounts), not an error: a real brand has the `brand` field.',
|
|
620
703
|
ops: {
|
|
621
704
|
list: 'Brand_List',
|
|
622
705
|
create: 'Brand_Create',
|
|
@@ -663,7 +746,7 @@ export const CATALOG = {
|
|
|
663
746
|
update: 'IdentityProvider_Update',
|
|
664
747
|
delete: 'IdentityProvider_Delete',
|
|
665
748
|
},
|
|
666
|
-
notes: 'Client secrets belong to the user: ask for them, never invent or echo them back. The server allows them only when accounts.limits.identity_providers.max_count is raised (host settings, then restart). See {
|
|
749
|
+
notes: 'Client secrets belong to the user: ask for them, never invent or echo them back. The server allows them only when accounts.limits.identity_providers.max_count is raised (host settings, then restart). See {op:doc.get} ids {"doc": "server-admin"}.',
|
|
667
750
|
},
|
|
668
751
|
proxy: {
|
|
669
752
|
description: 'Proxies: services the server exposes through its own address.',
|
|
@@ -720,6 +803,7 @@ export const CATALOG = {
|
|
|
720
803
|
parent: 'device',
|
|
721
804
|
path: 'tokens',
|
|
722
805
|
id: 'token',
|
|
806
|
+
recordId: { field: 'token' },
|
|
723
807
|
bodyId: [],
|
|
724
808
|
ops: {
|
|
725
809
|
list: 'Device_ListTokens',
|
|
@@ -734,14 +818,16 @@ export const CATALOG = {
|
|
|
734
818
|
parent: 'device',
|
|
735
819
|
path: 'buckets',
|
|
736
820
|
id: 'bucket',
|
|
821
|
+
recordId: { field: 'bucket' },
|
|
737
822
|
ops: { list: 'Device_ListBuckets' },
|
|
738
823
|
},
|
|
739
824
|
device_bucket_tag: {
|
|
740
|
-
description: 'Tags of a device bucket
|
|
825
|
+
description: 'Tags of a device bucket.',
|
|
741
826
|
version: 'v3',
|
|
742
827
|
parent: 'device_bucket',
|
|
743
828
|
path: 'tags',
|
|
744
829
|
id: 'tag',
|
|
830
|
+
recordId: { from: 'value' },
|
|
745
831
|
ops: { list: 'Device_ListBucketTags', get: 'Device_ReadBucketTag' },
|
|
746
832
|
},
|
|
747
833
|
device_service: {
|
|
@@ -750,6 +836,7 @@ export const CATALOG = {
|
|
|
750
836
|
parent: 'device',
|
|
751
837
|
path: 'services',
|
|
752
838
|
id: 'service',
|
|
839
|
+
recordId: { field: 'service' },
|
|
753
840
|
ops: { list: 'Device_ListServices', get: 'Device_ReadService' },
|
|
754
841
|
},
|
|
755
842
|
device_callback: {
|
|
@@ -786,6 +873,7 @@ export const CATALOG = {
|
|
|
786
873
|
parent: 'product',
|
|
787
874
|
path: 'profile/api',
|
|
788
875
|
id: 'resource',
|
|
876
|
+
recordId: { from: 'key' },
|
|
789
877
|
upsert: true,
|
|
790
878
|
ops: {
|
|
791
879
|
list: 'Product_ReadProfile',
|
|
@@ -798,6 +886,7 @@ export const CATALOG = {
|
|
|
798
886
|
actions: {
|
|
799
887
|
replace_all: {
|
|
800
888
|
op: 'Product_UpdateProfile',
|
|
889
|
+
confirm: true,
|
|
801
890
|
method: 'put',
|
|
802
891
|
path: '',
|
|
803
892
|
on: 'collection',
|
|
@@ -812,6 +901,30 @@ export const CATALOG = {
|
|
|
812
901
|
parent: 'product',
|
|
813
902
|
path: 'profile/buckets',
|
|
814
903
|
id: 'resource',
|
|
904
|
+
examples: [
|
|
905
|
+
{
|
|
906
|
+
tool: 'resource_create',
|
|
907
|
+
arguments: {
|
|
908
|
+
type: 'product_profile_bucket',
|
|
909
|
+
ids: { product: 'boiler_kit', resource: 'telemetry' },
|
|
910
|
+
body: {
|
|
911
|
+
name: 'Telemetry',
|
|
912
|
+
enabled: true,
|
|
913
|
+
data: {
|
|
914
|
+
source: 'resource',
|
|
915
|
+
resource: 'telemetry',
|
|
916
|
+
update: 'interval',
|
|
917
|
+
interval: 60,
|
|
918
|
+
magnitude: 'second',
|
|
919
|
+
payload_type: 'source_payload',
|
|
920
|
+
},
|
|
921
|
+
},
|
|
922
|
+
},
|
|
923
|
+
does: 'A bucket per device of the product, fed from its resource telemetry every 60 s.',
|
|
924
|
+
},
|
|
925
|
+
],
|
|
926
|
+
|
|
927
|
+
recordId: { from: 'key' },
|
|
815
928
|
upsert: true,
|
|
816
929
|
ops: {
|
|
817
930
|
list: 'Product_ReadProfile',
|
|
@@ -824,6 +937,7 @@ export const CATALOG = {
|
|
|
824
937
|
actions: {
|
|
825
938
|
replace_all: {
|
|
826
939
|
op: 'Product_UpdateProfile',
|
|
940
|
+
confirm: true,
|
|
827
941
|
method: 'put',
|
|
828
942
|
path: '',
|
|
829
943
|
on: 'collection',
|
|
@@ -838,6 +952,7 @@ export const CATALOG = {
|
|
|
838
952
|
parent: 'product',
|
|
839
953
|
path: 'profile/properties',
|
|
840
954
|
id: 'resource',
|
|
955
|
+
recordId: { from: 'key' },
|
|
841
956
|
upsert: true,
|
|
842
957
|
ops: {
|
|
843
958
|
list: 'Product_ReadProfile',
|
|
@@ -850,6 +965,7 @@ export const CATALOG = {
|
|
|
850
965
|
actions: {
|
|
851
966
|
replace_all: {
|
|
852
967
|
op: 'Product_UpdateProfile',
|
|
968
|
+
confirm: true,
|
|
853
969
|
method: 'put',
|
|
854
970
|
path: '',
|
|
855
971
|
on: 'collection',
|
|
@@ -864,6 +980,7 @@ export const CATALOG = {
|
|
|
864
980
|
parent: 'product',
|
|
865
981
|
path: 'profile/autoprovisions',
|
|
866
982
|
id: 'resource',
|
|
983
|
+
recordId: { from: 'key' },
|
|
867
984
|
upsert: true,
|
|
868
985
|
ops: {
|
|
869
986
|
list: 'Product_ReadProfile',
|
|
@@ -876,6 +993,7 @@ export const CATALOG = {
|
|
|
876
993
|
actions: {
|
|
877
994
|
replace_all: {
|
|
878
995
|
op: 'Product_UpdateProfile',
|
|
996
|
+
confirm: true,
|
|
879
997
|
method: 'put',
|
|
880
998
|
path: '',
|
|
881
999
|
on: 'collection',
|
|
@@ -890,6 +1008,7 @@ export const CATALOG = {
|
|
|
890
1008
|
parent: 'product',
|
|
891
1009
|
path: 'profile/flows',
|
|
892
1010
|
id: 'resource',
|
|
1011
|
+
recordId: { from: 'key' },
|
|
893
1012
|
upsert: true,
|
|
894
1013
|
ops: {
|
|
895
1014
|
list: 'Product_ReadProfile',
|
|
@@ -902,6 +1021,7 @@ export const CATALOG = {
|
|
|
902
1021
|
actions: {
|
|
903
1022
|
replace_all: {
|
|
904
1023
|
op: 'Product_UpdateProfile',
|
|
1024
|
+
confirm: true,
|
|
905
1025
|
method: 'put',
|
|
906
1026
|
path: '',
|
|
907
1027
|
on: 'collection',
|
|
@@ -916,7 +1036,7 @@ export const CATALOG = {
|
|
|
916
1036
|
parent: 'product',
|
|
917
1037
|
path: 'profile/code',
|
|
918
1038
|
ops: { get: 'Product_ReadProfileCode', update: 'Product_UpdateProfileCode' },
|
|
919
|
-
notes: 'Update with {code: "<JavaScript>", storage: ""}, or {storage: "<id>"} to load <storage>/index.js, or {storage, path: "<folder>"} to load <storage>/<folder>/index.js, with that folder\'s node_modules (one storage can hold the dashboard in app/ and each product\'s code in code/<product>/). Update again after editing the file. Functions are synchronous globals (`function name(arg) {…}`) that the profile calls by name: API targets, payload_function, device_id_resolver. `Date` runs in UTC: for local hours use Intl.DateTimeFormat with a timeZone (it follows daylight saving). Read {
|
|
1039
|
+
notes: 'Update with {code: "<JavaScript>", storage: ""}, or {storage: "<id>"} to load <storage>/index.js, or {storage, path: "<folder>"} to load <storage>/<folder>/index.js, with that folder\'s node_modules (one storage can hold the dashboard in app/ and each product\'s code in code/<product>/). Update again after editing the file. Functions are synchronous globals (`function name(arg) {…}`) that the profile calls by name: API targets, payload_function, device_id_resolver. `Date` runs in UTC: for local hours use Intl.DateTimeFormat with a timeZone (it follows daylight saving). Read {op:doc.get} ids {"doc": "product-code"} before writing it.',
|
|
920
1040
|
},
|
|
921
1041
|
product_service: {
|
|
922
1042
|
description: 'Services a product exposes on its devices.',
|
|
@@ -924,6 +1044,7 @@ export const CATALOG = {
|
|
|
924
1044
|
parent: 'product',
|
|
925
1045
|
path: 'services',
|
|
926
1046
|
id: 'service',
|
|
1047
|
+
recordId: { field: 'service' },
|
|
927
1048
|
upsert: true,
|
|
928
1049
|
ops: {
|
|
929
1050
|
list: 'Product_ReadServices',
|
|
@@ -941,6 +1062,7 @@ export const CATALOG = {
|
|
|
941
1062
|
parent: 'bucket',
|
|
942
1063
|
path: 'tags',
|
|
943
1064
|
id: 'tag',
|
|
1065
|
+
recordId: { from: 'value' },
|
|
944
1066
|
ops: { list: 'Bucket_ListTags', get: 'Bucket_ReadTag' },
|
|
945
1067
|
},
|
|
946
1068
|
bucket_export: {
|
|
@@ -949,6 +1071,7 @@ export const CATALOG = {
|
|
|
949
1071
|
parent: 'bucket',
|
|
950
1072
|
path: 'exports',
|
|
951
1073
|
id: 'file',
|
|
1074
|
+
recordId: { field: 'file' },
|
|
952
1075
|
bodyId: [],
|
|
953
1076
|
ops: {
|
|
954
1077
|
list: 'Bucket_ListExports',
|
|
@@ -1094,69 +1217,151 @@ export function idNames(type) {
|
|
|
1094
1217
|
return type.id === undefined ? [] : [type.id].flat();
|
|
1095
1218
|
}
|
|
1096
1219
|
|
|
1097
|
-
/**
|
|
1098
|
-
export function
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
.join('/');
|
|
1220
|
+
/** Name of a type's own id in `ids`: the REST placeholder. */
|
|
1221
|
+
export function idKey(name) {
|
|
1222
|
+
const type = CATALOG[name];
|
|
1223
|
+
return type?.id === undefined ? undefined : String(type.id);
|
|
1102
1224
|
}
|
|
1103
1225
|
|
|
1104
|
-
/**
|
|
1105
|
-
|
|
1106
|
-
|
|
1226
|
+
/**
|
|
1227
|
+
* @typedef {object} IdLevel
|
|
1228
|
+
* @property {string} type The type this id belongs to (the record's, or a parent's).
|
|
1229
|
+
* @property {string} key Its name in `ids`.
|
|
1230
|
+
*/
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* The ids that locate a record of a type, outermost parent first, its own
|
|
1234
|
+
* last (single records have only their parents'). `local` are the types a
|
|
1235
|
+
* CLI adds, which may hang off the catalog's or off each other.
|
|
1236
|
+
*
|
|
1237
|
+
* @param {string} name
|
|
1238
|
+
* @param {Record<string, { id?: string, parent?: string }>} [local]
|
|
1239
|
+
* @returns {IdLevel[]}
|
|
1240
|
+
*/
|
|
1241
|
+
export function idLevels(name, local = {}) {
|
|
1242
|
+
/** @type {IdLevel[]} */
|
|
1243
|
+
const levels = [];
|
|
1244
|
+
for (let t = name; t;) {
|
|
1245
|
+
const mine = Object.hasOwn(local, t) ? local[t] : undefined;
|
|
1246
|
+
const spec = mine ?? CATALOG[t];
|
|
1247
|
+
if (!spec) break;
|
|
1248
|
+
const key = mine ? mine.id : idKey(t);
|
|
1249
|
+
if (key) levels.unshift({ type: t, key });
|
|
1250
|
+
t = spec.parent;
|
|
1251
|
+
}
|
|
1252
|
+
return levels;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** Names of a type's ids, outermost first: `device_bucket_tag` → [device, bucket, tag]. */
|
|
1256
|
+
export const idKeys = (name, local) => idLevels(name, local).map((l) => l.key);
|
|
1257
|
+
|
|
1258
|
+
/** "a", "a and b", "a, b and c". */
|
|
1259
|
+
export const listed = (words) =>
|
|
1260
|
+
words.length < 2 ? words.join('') : `${words.slice(0, -1).join(', ')} and ${words.at(-1)}`;
|
|
1261
|
+
|
|
1262
|
+
// eslint-disable-next-line no-control-regex -- control characters are what it looks for
|
|
1263
|
+
const CONTROL = /[\x00-\x1f]/;
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* One id: a non-empty string (a number becomes one), never `.` or `..`
|
|
1267
|
+
* (the URL would resolve them: `..` would point at the parent) or with
|
|
1268
|
+
* control characters.
|
|
1269
|
+
*/
|
|
1270
|
+
function idValue(key, value) {
|
|
1271
|
+
if (typeof value === 'number' && Number.isFinite(value)) value = String(value);
|
|
1272
|
+
if (
|
|
1273
|
+
typeof value !== 'string' ||
|
|
1274
|
+
!value ||
|
|
1275
|
+
value === '.' ||
|
|
1276
|
+
value === '..' ||
|
|
1277
|
+
CONTROL.test(value)
|
|
1278
|
+
) {
|
|
1279
|
+
throw inputError(`ids.${key}: invalid id ${JSON.stringify(value)}`);
|
|
1280
|
+
}
|
|
1281
|
+
return value;
|
|
1107
1282
|
}
|
|
1108
1283
|
|
|
1109
1284
|
/**
|
|
1110
|
-
*
|
|
1285
|
+
* Check the `ids` a call gives for a type: every parent's, and its own as
|
|
1286
|
+
* `own` says (required, optional or forbidden).
|
|
1111
1287
|
*
|
|
1112
|
-
* @param {
|
|
1113
|
-
* @param {
|
|
1114
|
-
* @param {
|
|
1288
|
+
* @param {string} name
|
|
1289
|
+
* @param {unknown} ids
|
|
1290
|
+
* @param {{
|
|
1291
|
+
* own?: 'required' | 'optional' | 'forbidden',
|
|
1292
|
+
* forbidden?: (key: string) => string,
|
|
1293
|
+
* missing?: (key: string) => string,
|
|
1294
|
+
* local?: Record<string, { id?: string, parent?: string }>,
|
|
1295
|
+
* }} [opts] `forbidden` and `missing` word the errors about the own id.
|
|
1296
|
+
* @returns {{ levels: string[], own?: string, ids: Record<string, string> }}
|
|
1297
|
+
* The parents' ids outermost first, the own id, and every id by its name.
|
|
1115
1298
|
*/
|
|
1116
|
-
function
|
|
1117
|
-
if (
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
throw inputError(`${what} is required`);
|
|
1299
|
+
export function resolveIds(name, ids, { own = 'required', forbidden, missing, local = {} } = {}) {
|
|
1300
|
+
if (ids === undefined || ids === null) ids = {};
|
|
1301
|
+
if (typeof ids !== 'object' || Array.isArray(ids)) {
|
|
1302
|
+
throw inputError('ids must be an object of ids by name: {"device": "boiler"}');
|
|
1121
1303
|
}
|
|
1122
|
-
|
|
1304
|
+
const all = idLevels(name, local);
|
|
1305
|
+
const single = Object.hasOwn(local, name) ? !local[name].id : idKey(name) === undefined;
|
|
1306
|
+
const parents = single ? all : all.slice(0, -1);
|
|
1307
|
+
const mine = single ? undefined : all.at(-1);
|
|
1308
|
+
const names = new Set(all.map((l) => l.key));
|
|
1309
|
+
const unknown = Object.keys(ids).filter((k) => !names.has(k));
|
|
1310
|
+
if (unknown.length) {
|
|
1311
|
+
const keys = all.map((l) => l.key);
|
|
1312
|
+
throw inputError(
|
|
1313
|
+
`${name} takes ${keys.length ? `ids ${listed(keys)}` : 'no ids'}; unknown: ${unknown.join(', ')}`,
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
const valueOf = (/** @type {IdLevel} */ l) => {
|
|
1317
|
+
const value = ids[l.key];
|
|
1318
|
+
return value === undefined || value === null ? undefined : idValue(l.key, value);
|
|
1319
|
+
};
|
|
1320
|
+
/** @type {Record<string, string>} */
|
|
1321
|
+
const out = {};
|
|
1322
|
+
const levels = parents.map((l) => {
|
|
1323
|
+
const value = valueOf(l);
|
|
1324
|
+
if (value === undefined) throw inputError(`missing ${l.key}: the ${l.type}'s id`);
|
|
1325
|
+
return (out[l.key] = value);
|
|
1326
|
+
});
|
|
1327
|
+
let ownId;
|
|
1328
|
+
if (mine) {
|
|
1329
|
+
ownId = valueOf(mine);
|
|
1330
|
+
if (ownId !== undefined && own === 'forbidden') {
|
|
1331
|
+
throw inputError(forbidden?.(mine.key) ?? `leave ${mine.key} out of ids`);
|
|
1332
|
+
}
|
|
1333
|
+
if (ownId === undefined && own === 'required') {
|
|
1334
|
+
throw inputError(missing?.(mine.key) ?? `missing ${mine.key}: the ${name}'s id`);
|
|
1335
|
+
}
|
|
1336
|
+
if (ownId !== undefined) out[mine.key] = ownId;
|
|
1337
|
+
}
|
|
1338
|
+
return { levels, ...(ownId !== undefined ? { own: ownId } : {}), ids: out };
|
|
1123
1339
|
}
|
|
1124
1340
|
|
|
1125
1341
|
/**
|
|
1126
1342
|
* The path of a type's collection or item, from the outermost ancestor
|
|
1127
|
-
* down: the real URL (ids escaped,
|
|
1128
|
-
* OpenAPI template the tests and `resource_describe`
|
|
1343
|
+
* down: the real URL (ids escaped, a "/" in one too, the account from
|
|
1344
|
+
* lib/paths.js), or the OpenAPI template the tests and `resource_describe`
|
|
1345
|
+
* match (ids as `{}`).
|
|
1129
1346
|
*
|
|
1130
1347
|
* @param {string} name
|
|
1131
1348
|
* @param {{ on: 'collection' | 'item', suffix?: string, version?: string }} at
|
|
1132
|
-
* @param {{
|
|
1349
|
+
* @param {{ levels?: string[], own?: string, user?: string | null }} where As resolveIds gives.
|
|
1133
1350
|
* @param {boolean} template
|
|
1134
1351
|
*/
|
|
1135
|
-
function locate(name, at, {
|
|
1352
|
+
function locate(name, at, { levels = [], own, user } = {}, template = false) {
|
|
1136
1353
|
const type = CATALOG[name];
|
|
1137
1354
|
const version = at.version || type.version;
|
|
1138
|
-
const chain = ancestry(name);
|
|
1139
|
-
const parentIds = template
|
|
1140
|
-
? []
|
|
1141
|
-
: segments(
|
|
1142
|
-
parent,
|
|
1143
|
-
chain.reduce((n, t) => n + idNames(CATALOG[t]).length, 0),
|
|
1144
|
-
`parent (${parentHint(name)})`,
|
|
1145
|
-
);
|
|
1146
1355
|
const seg = (value) => (template ? '{}' : encodeURIComponent(value));
|
|
1147
1356
|
|
|
1148
1357
|
let path = '';
|
|
1149
|
-
|
|
1358
|
+
let i = 0;
|
|
1359
|
+
for (const t of ancestry(name)) {
|
|
1150
1360
|
path += `/${CATALOG[t].path}`;
|
|
1151
|
-
|
|
1361
|
+
if (CATALOG[t].id !== undefined) path += `/${seg(levels[i++])}`;
|
|
1152
1362
|
}
|
|
1153
1363
|
path += `/${type.path}`;
|
|
1154
|
-
if (at.on === 'item' &&
|
|
1155
|
-
const ids = template
|
|
1156
|
-
? idNames(type).map(() => '{}')
|
|
1157
|
-
: segments(id, idNames(type).length, `id (${idHint(name)})`);
|
|
1158
|
-
path += ids.map((i) => `/${template ? i : encodeURIComponent(i)}`).join('');
|
|
1159
|
-
}
|
|
1364
|
+
if (at.on === 'item' && type.id !== undefined) path += `/${seg(own)}`;
|
|
1160
1365
|
if (at.suffix) path += `/${at.suffix}`;
|
|
1161
1366
|
|
|
1162
1367
|
// Server-wide types (users, hosts, domains…) hang off /v{N}, not off an account.
|
|
@@ -1185,19 +1390,37 @@ function verbTarget(type, verb) {
|
|
|
1185
1390
|
}
|
|
1186
1391
|
|
|
1187
1392
|
/**
|
|
1188
|
-
* Resolve the request a verb maps to
|
|
1393
|
+
* Resolve the request a verb maps to, its `ids` checked: an item takes
|
|
1394
|
+
* every id, a collection only its parents'.
|
|
1189
1395
|
*
|
|
1190
1396
|
* @param {string} name Catalog type.
|
|
1191
1397
|
* @param {Verb} verb
|
|
1192
|
-
* @param {{
|
|
1398
|
+
* @param {{ ids?: Record<string, any>, user?: string | null }} [args]
|
|
1193
1399
|
* @returns {{ method: string, url: string }}
|
|
1194
1400
|
*/
|
|
1195
|
-
export function requestFor(name, verb,
|
|
1401
|
+
export function requestFor(name, verb, { ids, user } = {}) {
|
|
1196
1402
|
const type = CATALOG[name];
|
|
1197
1403
|
if (!type) throw inputError(`Unknown type "${name}"`);
|
|
1198
1404
|
if (!(verb in type.ops)) throw inputError(`Type "${name}" does not support ${verb}`);
|
|
1199
1405
|
const { method, at } = verbTarget(type, verb);
|
|
1200
|
-
|
|
1406
|
+
const where = resolveIds(
|
|
1407
|
+
name,
|
|
1408
|
+
ids,
|
|
1409
|
+
at.on === 'item'
|
|
1410
|
+
? {
|
|
1411
|
+
own: 'required',
|
|
1412
|
+
// An upsert create: the entry is stored under that name.
|
|
1413
|
+
missing:
|
|
1414
|
+
verb === 'create'
|
|
1415
|
+
? (key) => `missing ${key}: the entry's name (it is stored under it)`
|
|
1416
|
+
: undefined,
|
|
1417
|
+
}
|
|
1418
|
+
: {
|
|
1419
|
+
own: 'forbidden',
|
|
1420
|
+
forbidden: (key) => `${verb} takes no ${key}: leave it out of ids`,
|
|
1421
|
+
},
|
|
1422
|
+
);
|
|
1423
|
+
return { method, url: locate(name, at, { ...where, user }) };
|
|
1201
1424
|
}
|
|
1202
1425
|
|
|
1203
1426
|
/**
|
|
@@ -1213,13 +1436,14 @@ export function templateFor(name, verb) {
|
|
|
1213
1436
|
}
|
|
1214
1437
|
|
|
1215
1438
|
/**
|
|
1216
|
-
* Resolve the request of a type's action (verify a domain, restart a host…)
|
|
1439
|
+
* Resolve the request of a type's action (verify a domain, restart a host…),
|
|
1440
|
+
* its `ids` checked as requestFor does.
|
|
1217
1441
|
*
|
|
1218
1442
|
* @param {string} name
|
|
1219
1443
|
* @param {string} action
|
|
1220
|
-
* @param {{
|
|
1444
|
+
* @param {{ ids?: Record<string, any>, user?: string | null }} [args]
|
|
1221
1445
|
*/
|
|
1222
|
-
export function actionRequest(name, action,
|
|
1446
|
+
export function actionRequest(name, action, { ids, user } = {}) {
|
|
1223
1447
|
const type = CATALOG[name];
|
|
1224
1448
|
if (!type) throw inputError(`Unknown type "${name}"`);
|
|
1225
1449
|
const spec = type.actions?.[action];
|
|
@@ -1229,7 +1453,21 @@ export function actionRequest(name, action, args = {}) {
|
|
|
1229
1453
|
`Type "${name}" has no action "${action}"${known.length ? ` (it has: ${known.join(', ')})` : ''}`,
|
|
1230
1454
|
);
|
|
1231
1455
|
}
|
|
1232
|
-
|
|
1456
|
+
const where = resolveIds(
|
|
1457
|
+
name,
|
|
1458
|
+
ids,
|
|
1459
|
+
spec.on === 'collection'
|
|
1460
|
+
? {
|
|
1461
|
+
own: 'forbidden',
|
|
1462
|
+
forbidden: (key) => `${action} acts on every ${name}: leave ${key} out of ids`,
|
|
1463
|
+
}
|
|
1464
|
+
: {},
|
|
1465
|
+
);
|
|
1466
|
+
return {
|
|
1467
|
+
method: spec.method,
|
|
1468
|
+
url: locate(name, actionAt(spec), { ...where, user }),
|
|
1469
|
+
query: spec.query,
|
|
1470
|
+
};
|
|
1233
1471
|
}
|
|
1234
1472
|
|
|
1235
1473
|
/** OpenAPI template of an action, like templateFor. */
|