@thinger-io/cli-core 0.4.2 → 0.5.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +82 -31
  3. package/lib/bucket-export.js +91 -0
  4. package/lib/cli.js +106 -50
  5. package/lib/commands/_shared.js +89 -1
  6. package/lib/commands/api.js +90 -0
  7. package/lib/commands/arguments.js +62 -0
  8. package/lib/commands/bucket.js +52 -44
  9. package/lib/commands/dashboard.js +5 -2
  10. package/lib/commands/data.js +539 -0
  11. package/lib/commands/device.js +128 -241
  12. package/lib/commands/grammar.js +279 -0
  13. package/lib/commands/product.js +104 -214
  14. package/lib/commands/profile.js +1 -0
  15. package/lib/commands/resources.js +975 -0
  16. package/lib/dashboard-init.js +4 -3
  17. package/lib/dashboard.js +10 -2
  18. package/lib/mcp/catalog.js +301 -63
  19. package/lib/mcp/core-types.js +36 -0
  20. package/lib/mcp/helpers.js +23 -2
  21. package/lib/mcp/naming.js +20 -0
  22. package/lib/mcp/registry.js +18 -67
  23. package/lib/mcp/resource-hooks.js +19 -5
  24. package/lib/mcp/server.js +43 -11
  25. package/lib/mcp/tools-resource.js +1232 -330
  26. package/lib/mcp/types/bucket-data.js +151 -0
  27. package/lib/mcp/{tools-dashboards.js → types/dashboard-apps.js} +99 -37
  28. package/lib/mcp/types/device-resource.js +117 -0
  29. package/lib/mcp/types/doc.js +35 -0
  30. package/lib/mcp/types/plugin-call.js +75 -0
  31. package/lib/mcp/types/product-api.js +44 -0
  32. package/lib/mcp/types/profile.js +37 -0
  33. package/lib/mcp/types/storage-file.js +170 -0
  34. package/lib/openapi.js +18 -0
  35. package/lib/product-apply.js +121 -0
  36. package/lib/raw-api.js +111 -0
  37. package/lib/storage.js +41 -13
  38. package/lib/tokens.js +16 -2
  39. package/package.json +1 -1
  40. package/lib/mcp/tools-buckets.js +0 -130
  41. package/lib/mcp/tools-devices.js +0 -134
  42. package/lib/mcp/tools-misc.js +0 -48
  43. package/lib/mcp/tools-plugins.js +0 -63
  44. package/lib/mcp/tools-products.js +0 -52
  45. package/lib/mcp/tools-storage.js +0 -173
@@ -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 | string[]} [id] Name of the item id (or names, for ids in several segments).
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 MCP sends the saved entry with yours over it).';
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 ({tool:device_resource_call} on a device of the product), or for the product as a whole with {tool:product_resource_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 ({tool:doc_read} id "product-code").`;
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 through {tool:device_data_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 {tool:doc_read} id "concepts". A new Virtual device shows disconnected in the create answer and connects a moment later.',
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). Data is read and written with {tool:bucket_data_read} / {tool:bucket_data_write}.',
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 {tool:storage_file_write}) 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; the MCP shows only the last line). The way to load history in bulk: {tool:bucket_data_write} is one sample per call and rate limited.',
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 {tool:dashboard_publish} (widget layouts are legacy).',
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`): {tool:dashboard_publish} uploads the app and points the dashboard at it. Start it with {tool:dashboard_init} (a TypeScript project on the npm package @thinger-io/dashboard-sdk), develop it on its own dev server with {tool: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 ({tool:dashboard_publish} with `to: [{type, id}]`).',
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 (their files are handled with the storage_file_* tools).',
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 {tool:doc_read} id "concepts" (Alarms). Try a rule before saving it with its test_rule / test_source / test_notification actions ({tool:resource_call}).',
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 plugin_call.',
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 {tool:doc_read} id "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.',
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,12 +654,14 @@ 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:
@@ -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: {tool:doc_read} id "server-admin" has the whole flow.',
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 {tool:doc_read} id "server-admin". Reading a brand that does not exist returns defaults (only config.accounts), not an error: a real brand has the `brand` field.',
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 {tool:doc_read} id "server-admin".',
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 (parent: "<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 {tool:doc_read} id "product-code" before writing it.',
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
- /** How a type's ids are written: `<device>`. */
1098
- export function idHint(name) {
1099
- return idNames(CATALOG[name])
1100
- .map((n) => `<${n}>`)
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
- /** How the parent of a type is written: `<device>/<bucket>`. */
1105
- export function parentHint(name) {
1106
- return ancestry(name).map(idHint).join('/');
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
- * Split a `a/b` reference into exactly `count` non-empty segments.
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 {unknown} value
1113
- * @param {number} count
1114
- * @param {string} what Argument name and expected shape, for the error.
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 segments(value, count, what) {
1117
- if (count === 0) return [];
1118
- const parts = value === undefined || value === null ? [] : String(value).split('/');
1119
- if (parts.length !== count || parts.some((p) => !p)) {
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
- return parts;
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, the account from lib/paths.js), or the
1128
- * OpenAPI template the tests and `resource_describe` match (ids as `{}`).
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 {{ parent?: string, id?: string, user?: string | null }} args
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, { parent, id, user } = {}, template = false) {
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
- for (const t of chain) {
1358
+ let i = 0;
1359
+ for (const t of ancestry(name)) {
1150
1360
  path += `/${CATALOG[t].path}`;
1151
- for (const _ of idNames(CATALOG[t])) path += `/${seg(parentIds.shift())}`;
1361
+ if (CATALOG[t].id !== undefined) path += `/${seg(levels[i++])}`;
1152
1362
  }
1153
1363
  path += `/${type.path}`;
1154
- if (at.on === 'item' && idNames(type).length) {
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 {{ parent?: string, id?: string, user?: string | null }} [args]
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, args = {}) {
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
- return { method, url: locate(name, at, args) };
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 {{ parent?: string, id?: string, user?: string | null }} [args]
1444
+ * @param {{ ids?: Record<string, any>, user?: string | null }} [args]
1221
1445
  */
1222
- export function actionRequest(name, action, args = {}) {
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
- return { method: spec.method, url: locate(name, actionAt(spec), args), query: spec.query };
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. */