doover-js 0.7.1 → 0.8.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.
@@ -19,6 +19,65 @@ export interface MultiAgentMessagesParams {
19
19
  agent_message_limit?: number;
20
20
  field_name?: string[];
21
21
  }
22
+ /**
23
+ * One token-posture knob across all three layers, so a UI can show
24
+ * "inherited from organisation" vs "overridden on this device" and offer a reset
25
+ * without duplicating the precedence rule client-side.
26
+ */
27
+ export interface TokenPolicyField<T> {
28
+ /** What is actually enforced: `agent_override` if set, else `org_default`. */
29
+ effective: T | null;
30
+ /** This device's override. `null` means it inherits. */
31
+ agent_override: T | null;
32
+ /** The organisation-wide default inherited when not overridden. */
33
+ org_default: T | null;
34
+ }
35
+ export interface AgentTokenState {
36
+ /**
37
+ * Unix-ms revocation floor: every long-lived credential issued before this is
38
+ * rejected. `null` means nothing has ever been revoked for this agent.
39
+ */
40
+ tokens_valid_from: number | null;
41
+ /** Auth kill-switch — while true, every auth attempt is rejected. */
42
+ auth_locked: boolean;
43
+ self_rotation: TokenPolicyField<boolean>;
44
+ max_age_ms: TokenPolicyField<number>;
45
+ reuse_grace_ms: TokenPolicyField<number>;
46
+ }
47
+ /**
48
+ * A token-posture override. `null` on any field means "inherit the organisation
49
+ * default"; there is no way to express "explicitly off, ignoring the default",
50
+ * because every knob here is off when absent.
51
+ */
52
+ export interface AgentTokenPolicy {
53
+ /** Allow the device to trade its own credential for a fresh one. */
54
+ self_rotation?: boolean | null;
55
+ /**
56
+ * Hard maximum credential age. Only valid alongside `self_rotation` — without
57
+ * it the device cannot rotate before the deadline and will be locked out.
58
+ */
59
+ max_age_ms?: number | null;
60
+ /**
61
+ * Grace window after a rotation before a stale credential counts as reuse.
62
+ * Setting this opts the device into *automatic* lockout on reuse detection.
63
+ */
64
+ reuse_grace_ms?: number | null;
65
+ }
66
+ export interface ResourcePermission {
67
+ permission_id: string;
68
+ permission: string;
69
+ }
70
+ export interface AdhocTokenResponse {
71
+ /** Shown once. Not retrievable afterwards. */
72
+ token: string;
73
+ token_id: string | null;
74
+ }
75
+ export interface DeviceTokenResponse {
76
+ /** Shown once. Not stored anywhere and not retrievable afterwards. */
77
+ token: string;
78
+ /** Unix-ms issuance time, which is what the revocation floor compares against. */
79
+ issued_at_ms: number | null;
80
+ }
22
81
  export interface MultiAgentAggregatesParams {
23
82
  agent_id: string[];
24
83
  /**
@@ -56,4 +115,82 @@ export declare class AgentsApi {
56
115
  results: AgentAggregate[];
57
116
  count: number;
58
117
  }>;
118
+ /**
119
+ * Read an agent's credential state: the revocation floor, the auth
120
+ * kill-switch, and the token posture resolved across the per-agent override
121
+ * and the organisation default.
122
+ *
123
+ * Requires `AgentTokenRead`, which is read-only — a caller can display
124
+ * credential state (including an auth-lock banner, often the only explanation
125
+ * for why a device is offline) without being able to change any of it.
126
+ */
127
+ getTokenState(agentId: string): Promise<AgentTokenState>;
128
+ /**
129
+ * Replace an agent's token-posture override. Omitted or null fields mean
130
+ * "inherit the organisation default" — a full replacement rather than a
131
+ * merge, so clearing an override back to inherited is expressible.
132
+ *
133
+ * Rejected server-side if the resolved posture sets `max_age_ms` without
134
+ * `self_rotation`: a device that can't self-rotate has no way to trade its
135
+ * token in before the max age elapses, making that combination a scheduled
136
+ * outage.
137
+ */
138
+ setTokenPolicy(agentId: string, policy: AgentTokenPolicy): Promise<{
139
+ effective: AgentTokenPolicy;
140
+ }>;
141
+ /**
142
+ * Set or clear the auth kill-switch. While locked, every auth attempt for the
143
+ * agent is rejected regardless of token validity.
144
+ *
145
+ * doover-data sets this automatically on suspected token reuse, so clearing it
146
+ * is a routine incident-response action rather than an exotic one.
147
+ */
148
+ setAuthLock(agentId: string, locked: boolean): Promise<void>;
149
+ /**
150
+ * Revoke every outstanding long-lived credential for an agent by advancing the
151
+ * revocation floor. Mints nothing.
152
+ *
153
+ * This takes the device offline until it is re-provisioned. Break-glass:
154
+ * confirm before calling.
155
+ */
156
+ revokeAllTokens(agentId: string): Promise<{
157
+ tokens_valid_from: number;
158
+ }>;
159
+ /**
160
+ * Set the revocation floor to an explicit point, in either direction. `0` or
161
+ * `null` clears it.
162
+ *
163
+ * `revokeAllTokens` is the normal way to revoke. Lowering the floor reverses a
164
+ * revocation, so it is for undoing one made by mistake — not for recovering
165
+ * from a leak, where you should issue fresh credentials instead.
166
+ */
167
+ setRevocationFloor(agentId: string, tokensValidFrom: number | null): Promise<{
168
+ tokens_valid_from: number | null;
169
+ }>;
170
+ /**
171
+ * Mint a short-lived scoped JWT for ad-hoc access to an agent — debugging, a
172
+ * one-off script, an integration.
173
+ *
174
+ * This is the way to give a *person* access to a device. The device's own
175
+ * long-lived credential is deliberately not obtainable through the API: it is
176
+ * minted into an installer bundle and never shown. Expiry stands in for
177
+ * revocation here, so keep `timeout` as short as the task allows.
178
+ */
179
+ createAdhocToken(agentId: string, options?: {
180
+ permissions?: ResourcePermission[];
181
+ timeout?: number;
182
+ }): Promise<AdhocTokenResponse>;
183
+ /**
184
+ * Mint the device's long-lived credential and return it once, for manual
185
+ * provisioning — pasting into a device's config by hand. The installer download
186
+ * mints one into the bundle for you; this is the equivalent for a device you're
187
+ * configuring yourself.
188
+ *
189
+ * Always additive: the revocation floor is left alone, so minting can never
190
+ * knock a running device offline. `revoke_existing` is intentionally not
191
+ * exposed here — revoking is `revokeAllTokens`, a separate deliberate act.
192
+ *
193
+ * Returned once and stored nowhere. If it's lost, mint another.
194
+ */
195
+ createDeviceToken(agentId: string): Promise<DeviceTokenResponse>;
59
196
  }
@@ -232,6 +232,96 @@ class AgentsApi {
232
232
  count: responses.reduce((acc, r) => acc + r.count, 0),
233
233
  };
234
234
  }
235
+ /**
236
+ * Read an agent's credential state: the revocation floor, the auth
237
+ * kill-switch, and the token posture resolved across the per-agent override
238
+ * and the organisation default.
239
+ *
240
+ * Requires `AgentTokenRead`, which is read-only — a caller can display
241
+ * credential state (including an auth-lock banner, often the only explanation
242
+ * for why a device is offline) without being able to change any of it.
243
+ */
244
+ getTokenState(agentId) {
245
+ return this.rest.get(`/agents/${agentId}/token_state`);
246
+ }
247
+ /**
248
+ * Replace an agent's token-posture override. Omitted or null fields mean
249
+ * "inherit the organisation default" — a full replacement rather than a
250
+ * merge, so clearing an override back to inherited is expressible.
251
+ *
252
+ * Rejected server-side if the resolved posture sets `max_age_ms` without
253
+ * `self_rotation`: a device that can't self-rotate has no way to trade its
254
+ * token in before the max age elapses, making that combination a scheduled
255
+ * outage.
256
+ */
257
+ setTokenPolicy(agentId, policy) {
258
+ return this.rest.put(`/agents/${agentId}/token_policy`, policy);
259
+ }
260
+ /**
261
+ * Set or clear the auth kill-switch. While locked, every auth attempt for the
262
+ * agent is rejected regardless of token validity.
263
+ *
264
+ * doover-data sets this automatically on suspected token reuse, so clearing it
265
+ * is a routine incident-response action rather than an exotic one.
266
+ */
267
+ setAuthLock(agentId, locked) {
268
+ return this.rest.post(`/agents/${agentId}/auth_lock`, { locked });
269
+ }
270
+ /**
271
+ * Revoke every outstanding long-lived credential for an agent by advancing the
272
+ * revocation floor. Mints nothing.
273
+ *
274
+ * This takes the device offline until it is re-provisioned. Break-glass:
275
+ * confirm before calling.
276
+ */
277
+ revokeAllTokens(agentId) {
278
+ return this.rest.post(`/agents/${agentId}/token/revoke_all`, {});
279
+ }
280
+ /**
281
+ * Set the revocation floor to an explicit point, in either direction. `0` or
282
+ * `null` clears it.
283
+ *
284
+ * `revokeAllTokens` is the normal way to revoke. Lowering the floor reverses a
285
+ * revocation, so it is for undoing one made by mistake — not for recovering
286
+ * from a leak, where you should issue fresh credentials instead.
287
+ */
288
+ setRevocationFloor(agentId, tokensValidFrom) {
289
+ return this.rest.post(`/agents/${agentId}/token/floor`, { tokens_valid_from: tokensValidFrom });
290
+ }
291
+ /**
292
+ * Mint a short-lived scoped JWT for ad-hoc access to an agent — debugging, a
293
+ * one-off script, an integration.
294
+ *
295
+ * This is the way to give a *person* access to a device. The device's own
296
+ * long-lived credential is deliberately not obtainable through the API: it is
297
+ * minted into an installer bundle and never shown. Expiry stands in for
298
+ * revocation here, so keep `timeout` as short as the task allows.
299
+ */
300
+ createAdhocToken(agentId, options = {}) {
301
+ return this.rest.post(`/agents/${agentId}/token`, {
302
+ format: "jwt",
303
+ permissions: options.permissions ?? [],
304
+ timeout: options.timeout,
305
+ });
306
+ }
307
+ /**
308
+ * Mint the device's long-lived credential and return it once, for manual
309
+ * provisioning — pasting into a device's config by hand. The installer download
310
+ * mints one into the bundle for you; this is the equivalent for a device you're
311
+ * configuring yourself.
312
+ *
313
+ * Always additive: the revocation floor is left alone, so minting can never
314
+ * knock a running device offline. `revoke_existing` is intentionally not
315
+ * exposed here — revoking is `revokeAllTokens`, a separate deliberate act.
316
+ *
317
+ * Returned once and stored nowhere. If it's lost, mint another.
318
+ */
319
+ createDeviceToken(agentId) {
320
+ return this.rest.post(`/agents/${agentId}/token`, {
321
+ format: "mini_v1",
322
+ revoke_existing: false,
323
+ });
324
+ }
235
325
  }
236
326
  exports.AgentsApi = AgentsApi;
237
327
  // CloudFront fronts the channels-rest API with a hard 8,192-byte URL quota.
@@ -1,6 +1,7 @@
1
1
  import type { RestClient } from "../http/rest-client";
2
2
  import type { Aggregate } from "../types/openapi";
3
3
  import type { DooverRequestOptions } from "../client/request-options";
4
+ import { type BatchAggregateResponse, type BatchAggregateUpdateItem } from "../types/batch";
4
5
  export interface AggregateMutationParams {
5
6
  suppress_response?: boolean;
6
7
  clear_attachments?: boolean;
@@ -9,6 +10,26 @@ export interface AggregateMutationParams {
9
10
  export declare class AggregatesApi {
10
11
  private readonly rest;
11
12
  constructor(rest: RestClient);
13
+ /**
14
+ * Merge-patch aggregates across many agents and channels in one request.
15
+ *
16
+ * Replaces N single-agent PATCHes with one round trip, which is the point:
17
+ * it removes the per-request HTTP, auth, routing and tracing overhead, and
18
+ * shrinks synchronised write bursts. It does *not* reduce DynamoDB writes —
19
+ * the server still issues one `UpdateItem` per aggregate, capped at four
20
+ * concurrently — so callers should still coalesce on the client rather than
21
+ * relying on this to absorb a storm.
22
+ *
23
+ * Batches over {@link MAX_BATCH_ITEMS} are split automatically and the
24
+ * responses merged, so a caller can pass an arbitrarily long list. An empty
25
+ * list resolves to an empty response without issuing a request.
26
+ *
27
+ * Partial success is normal: inspect `items` and retry only the entries
28
+ * whose `success` is false. Successful entries are never rolled back.
29
+ */
30
+ batchPatchAggregates(items: BatchAggregateUpdateItem[]): Promise<BatchAggregateResponse>;
31
+ batchPatchAggregates(items: BatchAggregateUpdateItem[], requestOptions: DooverRequestOptions): Promise<BatchAggregateResponse>;
32
+ private _batchPatchAggregates;
12
33
  getAggregate(agentId: string, channelName: string): Promise<Aggregate>;
13
34
  getAggregate(agentId: string, channelName: string, requestOptions: DooverRequestOptions): Promise<Aggregate>;
14
35
  getAggregate(identifier: {
@@ -2,10 +2,28 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AggregatesApi = void 0;
4
4
  const _args_1 = require("./_args");
5
+ const batch_1 = require("../types/batch");
5
6
  class AggregatesApi {
6
7
  constructor(rest) {
7
8
  this.rest = rest;
8
9
  }
10
+ batchPatchAggregates(...args) {
11
+ return this._batchPatchAggregates(args[0]);
12
+ }
13
+ async _batchPatchAggregates(items) {
14
+ if (items.length === 0) {
15
+ return { items: [], count: 0, succeeded: 0, failed: 0 };
16
+ }
17
+ const responses = [];
18
+ // Sequential: the point of batching is to stop hammering the write path,
19
+ // so firing every chunk at once would defeat it.
20
+ for (const chunk of (0, batch_1.chunkBatchItems)(items)) {
21
+ responses.push(await this.rest.patch("/agents/aggregates", {
22
+ items: chunk,
23
+ }));
24
+ }
25
+ return (0, batch_1.mergeBatchResponses)(responses);
26
+ }
9
27
  getAggregate(...args) {
10
28
  const { agentId, channelName } = (0, _args_1.resolveChannelArgs)(args);
11
29
  return this._getAggregate(agentId, channelName);
@@ -1,6 +1,7 @@
1
1
  import type { RestClient } from "../http/rest-client";
2
2
  import type { CreateMessageRequest, DataSeries, MessageStructure, UpdateMessageRequest } from "../types/openapi";
3
3
  import type { DooverRequestOptions } from "../client/request-options";
4
+ import { type BatchCreateMessageItem, type BatchDeleteMessageItem, type BatchMessageResponse, type BatchUpdateMessageItem } from "../types/batch";
4
5
  export interface ListMessagesParams {
5
6
  before?: string;
6
7
  /**
@@ -93,5 +94,35 @@ export declare class MessagesApi {
93
94
  channelName: string;
94
95
  }, messageId: string): Promise<TLog[]>;
95
96
  private _getInvocationLogs;
97
+ /**
98
+ * Create messages across many agents and channels in one request.
99
+ *
100
+ * Supply `message_id` per item where you can: the server's create path is
101
+ * not yet idempotent, so a retry after a lost response can otherwise
102
+ * duplicate the message and double-count the daily summary.
103
+ */
104
+ batchPostMessages(items: BatchCreateMessageItem[]): Promise<BatchMessageResponse>;
105
+ batchPostMessages(items: BatchCreateMessageItem[], requestOptions: DooverRequestOptions): Promise<BatchMessageResponse>;
106
+ /** Merge-patch messages across many agents and channels in one request. */
107
+ batchPatchMessages(items: BatchUpdateMessageItem[]): Promise<BatchMessageResponse>;
108
+ batchPatchMessages(items: BatchUpdateMessageItem[], requestOptions: DooverRequestOptions): Promise<BatchMessageResponse>;
109
+ /** Replace messages across many agents and channels in one request. */
110
+ batchPutMessages(items: BatchUpdateMessageItem[]): Promise<BatchMessageResponse>;
111
+ batchPutMessages(items: BatchUpdateMessageItem[], requestOptions: DooverRequestOptions): Promise<BatchMessageResponse>;
112
+ /**
113
+ * Delete messages across many agents and channels in one request.
114
+ *
115
+ * Deletion is not idempotent server-side yet — a retried delete can
116
+ * decrement the daily summary twice — so retry only items the response
117
+ * reported as failed.
118
+ */
119
+ batchDeleteMessages(items: BatchDeleteMessageItem[]): Promise<BatchMessageResponse>;
120
+ batchDeleteMessages(items: BatchDeleteMessageItem[], requestOptions: DooverRequestOptions): Promise<BatchMessageResponse>;
121
+ /**
122
+ * Shared driver for the four `/agents/messages` verbs. Chunks to the
123
+ * server's item ceiling and sends sequentially, so a long list doesn't
124
+ * reintroduce the burst that batching exists to remove.
125
+ */
126
+ private _batchMessages;
96
127
  createMultipartPayload(jsonPayload: Record<string, unknown>, attachments: Array<Blob | File>): FormData;
97
128
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MessagesApi = void 0;
4
4
  const snowflake_1 = require("../utils/snowflake");
5
5
  const _args_1 = require("./_args");
6
+ const batch_1 = require("../types/batch");
6
7
  function resolveListMessagesParams(params) {
7
8
  const order = params?.order ?? "desc";
8
9
  const hasAfter = params?.after !== undefined;
@@ -137,6 +138,39 @@ class MessagesApi {
137
138
  _getInvocationLogs(agentId, channelName, messageId) {
138
139
  return this.rest.get(`/agents/${agentId}/channels/${channelName}/messages/${messageId}/logs`);
139
140
  }
141
+ batchPostMessages(...args) {
142
+ return this._batchMessages("POST", args[0]);
143
+ }
144
+ batchPatchMessages(...args) {
145
+ return this._batchMessages("PATCH", args[0]);
146
+ }
147
+ batchPutMessages(...args) {
148
+ return this._batchMessages("PUT", args[0]);
149
+ }
150
+ batchDeleteMessages(...args) {
151
+ return this._batchMessages("DELETE", args[0]);
152
+ }
153
+ /**
154
+ * Shared driver for the four `/agents/messages` verbs. Chunks to the
155
+ * server's item ceiling and sends sequentially, so a long list doesn't
156
+ * reintroduce the burst that batching exists to remove.
157
+ */
158
+ async _batchMessages(method, items) {
159
+ if (items.length === 0) {
160
+ return { items: [], count: 0, succeeded: 0, failed: 0 };
161
+ }
162
+ const responses = [];
163
+ for (const chunk of (0, batch_1.chunkBatchItems)(items)) {
164
+ // Goes through `request` rather than the verb helpers because DELETE
165
+ // needs a body, which `rest.delete` does not accept.
166
+ responses.push(await this.rest.request({
167
+ path: "/agents/messages",
168
+ method,
169
+ body: { items: chunk },
170
+ }));
171
+ }
172
+ return (0, batch_1.mergeBatchResponses)(responses);
173
+ }
140
174
  createMultipartPayload(jsonPayload, attachments) {
141
175
  const formData = new FormData();
142
176
  formData.set("json_payload", JSON.stringify(jsonPayload));
@@ -6,5 +6,5 @@
6
6
  * String-literal union (not a TS enum) so values serialise cleanly and can
7
7
  * appear in error messages / debug UIs verbatim.
8
8
  */
9
- export type Capability = "agents.list" | "agents.multiAgentMessages" | "agents.multiAgentAggregates" | "channels.list" | "channels.get" | "channels.create" | "channels.archive" | "channels.dataSeries" | "aggregates.get" | "aggregates.put" | "aggregates.patch" | "aggregates.attachment" | "messages.list" | "messages.listHistorical" | "messages.get" | "messages.post" | "messages.put" | "messages.delete" | "messages.attachment" | "messages.timeseries" | "messages.invocationLogs" | "gateway.subscribe" | "gateway.realtime" | "gateway.oneShot" | "rpc.send" | "alarms.read" | "alarms.write" | "connections.read" | "connections.write" | "notifications.read" | "notifications.write" | "permissions.read" | "permissions.write" | "processors.read" | "processors.write" | "turn.credentials" | "users.me";
9
+ export type Capability = "agents.list" | "agents.multiAgentMessages" | "agents.multiAgentAggregates" | "agents.tokenRead" | "agents.tokenManage" | "channels.list" | "channels.get" | "channels.create" | "channels.archive" | "channels.dataSeries" | "aggregates.get" | "aggregates.put" | "aggregates.patch" | "aggregates.attachment" | "messages.list" | "messages.listHistorical" | "messages.get" | "messages.post" | "messages.put" | "messages.delete" | "messages.attachment" | "messages.timeseries" | "messages.invocationLogs" | "gateway.subscribe" | "gateway.realtime" | "gateway.oneShot" | "rpc.send" | "alarms.read" | "alarms.write" | "connections.read" | "connections.write" | "notifications.read" | "notifications.write" | "permissions.read" | "permissions.write" | "processors.read" | "processors.write" | "turn.credentials" | "users.me";
10
10
  export declare const ALL_CAPABILITIES: readonly Capability[];
@@ -5,6 +5,8 @@ exports.ALL_CAPABILITIES = [
5
5
  "agents.list",
6
6
  "agents.multiAgentMessages",
7
7
  "agents.multiAgentAggregates",
8
+ "agents.tokenRead",
9
+ "agents.tokenManage",
8
10
  "channels.list",
9
11
  "channels.get",
10
12
  "channels.create",
@@ -183,6 +183,13 @@ const METHOD_TO_CAPABILITY = {
183
183
  "agents.listAgents": "agents.list",
184
184
  "agents.getMultiAgentMessages": "agents.multiAgentMessages",
185
185
  "agents.getMultiAgentAggregates": "agents.multiAgentAggregates",
186
+ "agents.getTokenState": "agents.tokenRead",
187
+ "agents.setTokenPolicy": "agents.tokenManage",
188
+ "agents.setAuthLock": "agents.tokenManage",
189
+ "agents.revokeAllTokens": "agents.tokenManage",
190
+ "agents.setRevocationFloor": "agents.tokenManage",
191
+ "agents.createAdhocToken": "agents.tokenManage",
192
+ "agents.createDeviceToken": "agents.tokenManage",
186
193
  "channels.listChannels": "channels.list",
187
194
  "channels.getChannel": "channels.get",
188
195
  "channels.createChannel": "channels.create",
@@ -97,6 +97,20 @@ export declare class MultiplexClient implements DataClient {
97
97
  private isNotFound;
98
98
  private requiredMessagesCapability;
99
99
  private unsupportedMethod;
100
+ /**
101
+ * Route a cross-agent batch mutation.
102
+ *
103
+ * A batch can name agents served by different members, so unlike
104
+ * `routedWrite` there is no single target. Each item is resolved to its own
105
+ * member using the same rules (unsupported and ambiguous both throw), the
106
+ * batch is split into one sub-batch per member, and results are stitched
107
+ * back into the caller's original item order.
108
+ *
109
+ * Resolution happens for every item up front so an unroutable item fails
110
+ * the whole call before any member is written to, rather than leaving a
111
+ * half-applied batch behind.
112
+ */
113
+ private routedBatchWrite;
100
114
  private guessNonCoreCap;
101
115
  protected recordConflict(method: string, agentId: string, channelName: string, values: Array<{
102
116
  value: unknown;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MultiplexClient = void 0;
4
4
  const multiplex_merge_1 = require("./multiplex-merge");
5
+ const batch_1 = require("../types/batch");
5
6
  const errors_1 = require("./errors");
6
7
  const snowflake_1 = require("../utils/snowflake");
7
8
  const multiplex_gateway_1 = require("./multiplex-gateway");
@@ -293,6 +294,76 @@ class MultiplexClient {
293
294
  unsupportedMethod(_method, cap) {
294
295
  return () => Promise.reject(new errors_1.UnsupportedCapabilityError(cap, this.clientId));
295
296
  }
297
+ /**
298
+ * Route a cross-agent batch mutation.
299
+ *
300
+ * A batch can name agents served by different members, so unlike
301
+ * `routedWrite` there is no single target. Each item is resolved to its own
302
+ * member using the same rules (unsupported and ambiguous both throw), the
303
+ * batch is split into one sub-batch per member, and results are stitched
304
+ * back into the caller's original item order.
305
+ *
306
+ * Resolution happens for every item up front so an unroutable item fails
307
+ * the whole call before any member is written to, rather than leaving a
308
+ * half-applied batch behind.
309
+ */
310
+ routedBatchWrite(method, cap) {
311
+ const self = this;
312
+ return async function (...rawArgs) {
313
+ const { args, sources } = self.splitSourcesOption(rawArgs);
314
+ const items = (args[0] ?? []);
315
+ if (!Array.isArray(items) || items.length === 0) {
316
+ return { items: [], count: 0, succeeded: 0, failed: 0 };
317
+ }
318
+ const groups = new Map();
319
+ items.forEach((item, index) => {
320
+ let members = self.candidates(item.agent_id, cap, sources);
321
+ if (sources && sources.length === 1) {
322
+ const only = members.find((m) => m.id === sources[0]);
323
+ if (!only)
324
+ throw new errors_1.UnsupportedCapabilityError(cap, sources[0]);
325
+ members = [only];
326
+ }
327
+ if (members.length === 0)
328
+ throw new errors_1.UnsupportedCapabilityError(cap, self.clientId);
329
+ if (members.length > 1) {
330
+ throw new errors_1.AmbiguousWriteError(cap, members.map((m) => m.id));
331
+ }
332
+ const target = members[0];
333
+ const group = groups.get(target.id) ?? {
334
+ client: target.client,
335
+ items: [],
336
+ indices: [],
337
+ };
338
+ group.items.push(item);
339
+ group.indices.push(index);
340
+ groups.set(target.id, group);
341
+ });
342
+ const subclient = method.split(".")[0];
343
+ const fnName = method.split(".")[1];
344
+ const results = new Array(items.length);
345
+ for (const group of groups.values()) {
346
+ const fn = group.client[subclient][fnName];
347
+ const response = (await fn.apply(group.client[subclient], [
348
+ group.items,
349
+ ]));
350
+ group.indices.forEach((originalIndex, i) => {
351
+ const item = response.items[i];
352
+ // A member that returns a short list would otherwise leave holes;
353
+ // record an explicit failure so `count` stays consistent.
354
+ results[originalIndex] = item ?? {
355
+ agent_id: group.items[i].agent_id,
356
+ channel_name: group.items[i].channel_name ?? "",
357
+ success: false,
358
+ error: "no result returned for batch item",
359
+ };
360
+ });
361
+ }
362
+ return (0, batch_1.mergeBatchResponses)([
363
+ { items: results, count: results.length, succeeded: 0, failed: 0 },
364
+ ]);
365
+ };
366
+ }
296
367
  guessNonCoreCap(subclient) {
297
368
  switch (subclient) {
298
369
  case "alarms": return "alarms.read";
@@ -459,6 +530,7 @@ class MultiplexClient {
459
530
  },
460
531
  putAggregate: self.routedWrite("aggregates.putAggregate", "aggregates.put"),
461
532
  patchAggregate: self.routedWrite("aggregates.patchAggregate", "aggregates.patch"),
533
+ batchPatchAggregates: self.routedBatchWrite("aggregates.batchPatchAggregates", "aggregates.patch"),
462
534
  async getAggregateAttachment(agentIdOrId, ...rest) {
463
535
  const { args, sources } = self.splitSourcesOption([agentIdOrId, ...rest]);
464
536
  const agentId = self.extractAgentId(args);
@@ -515,6 +587,10 @@ class MultiplexClient {
515
587
  putMessage: self.routedWrite("messages.putMessage", "messages.put"),
516
588
  patchMessage: self.routedWrite("messages.patchMessage", "messages.put"),
517
589
  deleteMessage: self.routedWrite("messages.deleteMessage", "messages.delete"),
590
+ batchPostMessages: self.routedBatchWrite("messages.batchPostMessages", "messages.post"),
591
+ batchPatchMessages: self.routedBatchWrite("messages.batchPatchMessages", "messages.put"),
592
+ batchPutMessages: self.routedBatchWrite("messages.batchPutMessages", "messages.put"),
593
+ batchDeleteMessages: self.routedBatchWrite("messages.batchDeleteMessages", "messages.delete"),
518
594
  getTimeseries: self.makeFanoutFirst("messages.getTimeseries", "messages.timeseries"),
519
595
  getMessageAttachment: self.makeBlobFanout("messages.getMessageAttachment", "messages.attachment"),
520
596
  getInvocationLogs: self.makeFanoutFirst("messages.getInvocationLogs", "messages.invocationLogs"),
@@ -584,6 +660,13 @@ class MultiplexClient {
584
660
  const merged = (0, multiplex_merge_1.dedupeBy)([].concat(...responses.map((r) => r.results)), (a) => a.agent_id);
585
661
  return { results: merged, count: merged.length };
586
662
  },
663
+ getTokenState: self.makeFanoutFirst("agents.getTokenState", "agents.tokenRead"),
664
+ setTokenPolicy: self.routedWrite("agents.setTokenPolicy", "agents.tokenManage"),
665
+ setAuthLock: self.routedWrite("agents.setAuthLock", "agents.tokenManage"),
666
+ revokeAllTokens: self.routedWrite("agents.revokeAllTokens", "agents.tokenManage"),
667
+ setRevocationFloor: self.routedWrite("agents.setRevocationFloor", "agents.tokenManage"),
668
+ createAdhocToken: self.routedWrite("agents.createAdhocToken", "agents.tokenManage"),
669
+ createDeviceToken: self.routedWrite("agents.createDeviceToken", "agents.tokenManage"),
587
670
  };
588
671
  }
589
672
  // ===== non-core generic facade =====
@@ -215,6 +215,7 @@ class OfflineDataClient {
215
215
  }),
216
216
  putAggregate: this.offlineGuard("aggregates.putAggregate", target.putAggregate.bind(target)),
217
217
  patchAggregate: this.offlineGuard("aggregates.patchAggregate", target.patchAggregate.bind(target)),
218
+ batchPatchAggregates: this.offlineGuard("aggregates.batchPatchAggregates", target.batchPatchAggregates.bind(target)),
218
219
  getAggregateAttachment: ((...rawArgs) => {
219
220
  const { args, request } = (0, request_options_1.splitRequestOptions)(rawArgs);
220
221
  const { agentId, channelName } = extractChannelId(args);
@@ -267,6 +268,10 @@ class OfflineDataClient {
267
268
  putMessage: this.offlineGuard("messages.putMessage", target.putMessage.bind(target)),
268
269
  patchMessage: this.offlineGuard("messages.patchMessage", target.patchMessage.bind(target)),
269
270
  deleteMessage: this.offlineGuard("messages.deleteMessage", target.deleteMessage.bind(target)),
271
+ batchPostMessages: this.offlineGuard("messages.batchPostMessages", target.batchPostMessages.bind(target)),
272
+ batchPatchMessages: this.offlineGuard("messages.batchPatchMessages", target.batchPatchMessages.bind(target)),
273
+ batchPutMessages: this.offlineGuard("messages.batchPutMessages", target.batchPutMessages.bind(target)),
274
+ batchDeleteMessages: this.offlineGuard("messages.batchDeleteMessages", target.batchDeleteMessages.bind(target)),
270
275
  getTimeseries: ((...args) => target.getTimeseries(...args)),
271
276
  getMessageAttachment: ((...rawArgs) => {
272
277
  const { args, request } = (0, request_options_1.splitRequestOptions)(rawArgs);
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { getDooverClient, peekDooverClient, resetDooverClient, } from "./client/
7
7
  export { DooverStatsCollector } from "./client/stats";
8
8
  export type { DooverStatsSnapshot, RestStatsSnapshot, GatewayStatsSnapshot, } from "./client/stats";
9
9
  export { AgentsApi } from "./apis/agents-api";
10
+ export type { AgentTokenState, AgentTokenPolicy, TokenPolicyField, AdhocTokenResponse, DeviceTokenResponse, ResourcePermission, } from "./apis/agents-api";
10
11
  export { AggregatesApi } from "./apis/aggregates-api";
11
12
  export { AlarmsApi } from "./apis/alarms-api";
12
13
  export { ChannelsApi } from "./apis/channels-api";
@@ -31,6 +32,8 @@ export { OfflineDataClient, MemoryOfflineStorageAdapter, DEFAULT_OFFLINE_RETENTI
31
32
  export type { OfflineCacheMode, OfflineReadCacheOptions, OfflineChannelPolicy, OfflineCacheScope, OfflineCacheRecord, OfflineStorageAdapter, OfflineDataClientOptions, } from "./client/offline-cache";
32
33
  export { requestOptions, isDooverRequestOptions, splitRequestOptions, } from "./client/request-options";
33
34
  export type { DooverRequestOptions } from "./client/request-options";
35
+ export { MAX_BATCH_ITEMS, chunkBatchItems, mergeBatchResponses } from "./types/batch";
36
+ export type { BatchAggregateUpdateItem, BatchAggregateResponse, BatchCreateMessageItem, BatchUpdateMessageItem, BatchDeleteMessageItem, BatchMessageResponse, BatchMessageResultItem, BatchResultItem, BatchResponse, } from "./types/batch";
34
37
  export type { DataClient, AgentScope, DataClientStatus, DataClientConnectionState, AgentsApiLike, AggregatesApiLike, AlarmsApiLike, ChannelsApiLike, ConnectionsApiLike, MessagesApiLike, NotificationsApiLike, PermissionsApiLike, ProcessorsApiLike, TurnApiLike, UsersApiLike, GatewayClientLike, RpcDispatcherLike, } from "./client/data-client";
35
38
  export type { SourceProvenance, SourceProvenanceViaRest, SourceProvenanceViaGateway, } from "./types/provenance";
36
39
  export { DooverAuth } from "./auth/doover-auth";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.generateSnowflakeIdAtTime = exports.extractSnowflakeId = exports.addTimestampToMessage = exports.DV_AUDIT_CHANNEL = exports.getIdentifierFromPath = exports.DooverDataProvider = exports.DooverValidationError = exports.DooverGatewayError = exports.DooverApiError = exports.RestClient = exports.GatewayClient = exports.buildAuth = exports.DooverAuthError = exports.AuthProfile = exports.DooverTokenAuth = exports.CookieAuth = exports.DooverAuth = exports.splitRequestOptions = exports.isDooverRequestOptions = exports.requestOptions = exports.DEFAULT_OFFLINE_RETENTION_MS = exports.MemoryOfflineStorageAdapter = exports.OfflineDataClient = exports.DooverOfflineError = exports.AmbiguousWriteError = exports.UnsupportedCapabilityError = exports.ALL_CAPABILITIES = exports.DooverRpcError = exports.RpcDispatcher = exports.UsersApi = exports.TurnApi = exports.ProcessorsApi = exports.PermissionsApi = exports.OrganisationsApi = exports.NotificationsApi = exports.MessagesApi = exports.ConnectionsApi = exports.ChannelsApi = exports.AlarmsApi = exports.AggregatesApi = exports.AgentsApi = exports.DooverStatsCollector = exports.resetDooverClient = exports.peekDooverClient = exports.getDooverClient = exports.MultiplexClient = exports.LocalAgentClient = exports.DooverClient = void 0;
3
+ exports.extractSnowflakeId = exports.addTimestampToMessage = exports.DV_AUDIT_CHANNEL = exports.getIdentifierFromPath = exports.DooverDataProvider = exports.DooverValidationError = exports.DooverGatewayError = exports.DooverApiError = exports.RestClient = exports.GatewayClient = exports.buildAuth = exports.DooverAuthError = exports.AuthProfile = exports.DooverTokenAuth = exports.CookieAuth = exports.DooverAuth = exports.mergeBatchResponses = exports.chunkBatchItems = exports.MAX_BATCH_ITEMS = exports.splitRequestOptions = exports.isDooverRequestOptions = exports.requestOptions = exports.DEFAULT_OFFLINE_RETENTION_MS = exports.MemoryOfflineStorageAdapter = exports.OfflineDataClient = exports.DooverOfflineError = exports.AmbiguousWriteError = exports.UnsupportedCapabilityError = exports.ALL_CAPABILITIES = exports.DooverRpcError = exports.RpcDispatcher = exports.UsersApi = exports.TurnApi = exports.ProcessorsApi = exports.PermissionsApi = exports.OrganisationsApi = exports.NotificationsApi = exports.MessagesApi = exports.ConnectionsApi = exports.ChannelsApi = exports.AlarmsApi = exports.AggregatesApi = exports.AgentsApi = exports.DooverStatsCollector = exports.resetDooverClient = exports.peekDooverClient = exports.getDooverClient = exports.MultiplexClient = exports.LocalAgentClient = exports.DooverClient = void 0;
4
+ exports.generateSnowflakeIdAtTime = void 0;
4
5
  var doover_client_1 = require("./client/doover-client");
5
6
  Object.defineProperty(exports, "DooverClient", { enumerable: true, get: function () { return doover_client_1.DooverClient; } });
6
7
  var local_agent_client_1 = require("./client/local-agent-client");
@@ -55,6 +56,10 @@ var request_options_1 = require("./client/request-options");
55
56
  Object.defineProperty(exports, "requestOptions", { enumerable: true, get: function () { return request_options_1.requestOptions; } });
56
57
  Object.defineProperty(exports, "isDooverRequestOptions", { enumerable: true, get: function () { return request_options_1.isDooverRequestOptions; } });
57
58
  Object.defineProperty(exports, "splitRequestOptions", { enumerable: true, get: function () { return request_options_1.splitRequestOptions; } });
59
+ var batch_1 = require("./types/batch");
60
+ Object.defineProperty(exports, "MAX_BATCH_ITEMS", { enumerable: true, get: function () { return batch_1.MAX_BATCH_ITEMS; } });
61
+ Object.defineProperty(exports, "chunkBatchItems", { enumerable: true, get: function () { return batch_1.chunkBatchItems; } });
62
+ Object.defineProperty(exports, "mergeBatchResponses", { enumerable: true, get: function () { return batch_1.mergeBatchResponses; } });
58
63
  var doover_auth_1 = require("./auth/doover-auth");
59
64
  Object.defineProperty(exports, "DooverAuth", { enumerable: true, get: function () { return doover_auth_1.DooverAuth; } });
60
65
  var cookie_auth_1 = require("./auth/cookie-auth");
@@ -33,4 +33,6 @@ export type { UseMultiAgentChannelMessagesOptions, UseMultiAgentChannelMessagesR
33
33
  export { useDeviceMap } from "./useDeviceMap";
34
34
  export type { DeviceMapEntry, UseDeviceMapOptions, UseDeviceMapResult, } from "./useDeviceMap";
35
35
  export { useTurnCredentials } from "./useTurnCredentials";
36
+ export { useAgentTokenState, useSetAgentTokenPolicy, useSetAgentAuthLock, useRevokeAgentTokens, useSetRevocationFloor, useCreateAdhocToken, useCreateDeviceToken, agentTokenStateQueryKey, } from "./useAgentTokenState";
37
+ export type { UseAgentTokenStateOptions } from "./useAgentTokenState";
36
38
  export { getSharedQueryClient, resetSharedQueryClient, } from "./sharedQueryClient";
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.resetSharedQueryClient = exports.getSharedQueryClient = exports.useTurnCredentials = exports.useDeviceMap = exports.multiAgentChannelMessagesQueryKey = exports.useMultiAgentChannelMessages = exports.multiAgentAggregatesQueryKey = exports.useMultiAgentAggregates = exports.useSendRpc = exports.invocationLogsQueryKey = exports.useInvocationLogs = exports.channelMessageQueryKey = exports.useChannelMessage = exports.channelMessagesQueryKey = exports.useChannelMessages = exports.useUpdateMessage = exports.useUpdateAggregate = exports.useSendMessage = exports.useAgentChannel = exports.channelAggregateQueryKey = exports.useChannelAggregate = exports.useChannelSubscription = exports.agentConnectionsQueryKey = exports.useAgentConnections = exports.useConnectionState = exports.hasOfflineStatus = exports.useOfflineStatus = exports.useClientStatus = exports.useDooverClient = exports.DooverProvider = void 0;
3
+ exports.resetSharedQueryClient = exports.getSharedQueryClient = exports.agentTokenStateQueryKey = exports.useCreateDeviceToken = exports.useCreateAdhocToken = exports.useSetRevocationFloor = exports.useRevokeAgentTokens = exports.useSetAgentAuthLock = exports.useSetAgentTokenPolicy = exports.useAgentTokenState = exports.useTurnCredentials = exports.useDeviceMap = exports.multiAgentChannelMessagesQueryKey = exports.useMultiAgentChannelMessages = exports.multiAgentAggregatesQueryKey = exports.useMultiAgentAggregates = exports.useSendRpc = exports.invocationLogsQueryKey = exports.useInvocationLogs = exports.channelMessageQueryKey = exports.useChannelMessage = exports.channelMessagesQueryKey = exports.useChannelMessages = exports.useUpdateMessage = exports.useUpdateAggregate = exports.useSendMessage = exports.useAgentChannel = exports.channelAggregateQueryKey = exports.useChannelAggregate = exports.useChannelSubscription = exports.agentConnectionsQueryKey = exports.useAgentConnections = exports.useConnectionState = exports.hasOfflineStatus = exports.useOfflineStatus = exports.useClientStatus = exports.useDooverClient = exports.DooverProvider = void 0;
4
4
  var context_1 = require("./context");
5
5
  Object.defineProperty(exports, "DooverProvider", { enumerable: true, get: function () { return context_1.DooverProvider; } });
6
6
  Object.defineProperty(exports, "useDooverClient", { enumerable: true, get: function () { return context_1.useDooverClient; } });
@@ -48,6 +48,15 @@ var useDeviceMap_1 = require("./useDeviceMap");
48
48
  Object.defineProperty(exports, "useDeviceMap", { enumerable: true, get: function () { return useDeviceMap_1.useDeviceMap; } });
49
49
  var useTurnCredentials_1 = require("./useTurnCredentials");
50
50
  Object.defineProperty(exports, "useTurnCredentials", { enumerable: true, get: function () { return useTurnCredentials_1.useTurnCredentials; } });
51
+ var useAgentTokenState_1 = require("./useAgentTokenState");
52
+ Object.defineProperty(exports, "useAgentTokenState", { enumerable: true, get: function () { return useAgentTokenState_1.useAgentTokenState; } });
53
+ Object.defineProperty(exports, "useSetAgentTokenPolicy", { enumerable: true, get: function () { return useAgentTokenState_1.useSetAgentTokenPolicy; } });
54
+ Object.defineProperty(exports, "useSetAgentAuthLock", { enumerable: true, get: function () { return useAgentTokenState_1.useSetAgentAuthLock; } });
55
+ Object.defineProperty(exports, "useRevokeAgentTokens", { enumerable: true, get: function () { return useAgentTokenState_1.useRevokeAgentTokens; } });
56
+ Object.defineProperty(exports, "useSetRevocationFloor", { enumerable: true, get: function () { return useAgentTokenState_1.useSetRevocationFloor; } });
57
+ Object.defineProperty(exports, "useCreateAdhocToken", { enumerable: true, get: function () { return useAgentTokenState_1.useCreateAdhocToken; } });
58
+ Object.defineProperty(exports, "useCreateDeviceToken", { enumerable: true, get: function () { return useAgentTokenState_1.useCreateDeviceToken; } });
59
+ Object.defineProperty(exports, "agentTokenStateQueryKey", { enumerable: true, get: function () { return useAgentTokenState_1.agentTokenStateQueryKey; } });
51
60
  var sharedQueryClient_1 = require("./sharedQueryClient");
52
61
  Object.defineProperty(exports, "getSharedQueryClient", { enumerable: true, get: function () { return sharedQueryClient_1.getSharedQueryClient; } });
53
62
  Object.defineProperty(exports, "resetSharedQueryClient", { enumerable: true, get: function () { return sharedQueryClient_1.resetSharedQueryClient; } });
@@ -0,0 +1,70 @@
1
+ import { type UseQueryResult } from "@tanstack/react-query";
2
+ import type { AgentTokenPolicy, AgentTokenState, ResourcePermission } from "../apis/agents-api";
3
+ export declare function agentTokenStateQueryKey(agentId: string | undefined): readonly ["doover", "agent", string | null, "tokenState"];
4
+ export interface UseAgentTokenStateOptions {
5
+ enabled?: boolean;
6
+ staleTime?: number;
7
+ }
8
+ /**
9
+ * Read an agent's credential state — revocation floor, auth kill-switch, and the
10
+ * token posture resolved across the per-agent override and organisation default.
11
+ *
12
+ * Deliberately short `staleTime`: `auth_locked` can be flipped by doover-data
13
+ * itself (automatic lockout on suspected token reuse), so this value changes
14
+ * without any action from this client. Showing a stale "not locked" on the one
15
+ * screen someone opens *because* their device went offline would be worse than
16
+ * an extra fetch.
17
+ */
18
+ export declare function useAgentTokenState(agentId: string | undefined, options?: UseAgentTokenStateOptions): UseQueryResult<AgentTokenState>;
19
+ /**
20
+ * Write an agent's token-posture override. Full replacement — a null field means
21
+ * "inherit the organisation default".
22
+ *
23
+ * Invalidates the token-state query on success so the inherited-vs-overridden
24
+ * display reflects the write without the caller wiring that up.
25
+ */
26
+ export declare function useSetAgentTokenPolicy(agentId: string | undefined): import("@tanstack/react-query").UseMutationResult<{
27
+ effective: AgentTokenPolicy;
28
+ }, Error, AgentTokenPolicy, unknown>;
29
+ /** Set or clear the auth kill-switch. */
30
+ export declare function useSetAgentAuthLock(agentId: string | undefined): import("@tanstack/react-query").UseMutationResult<void, Error, boolean, unknown>;
31
+ /**
32
+ * Mint a short-lived scoped token for ad-hoc access to an agent.
33
+ *
34
+ * Does not invalidate the token-state query: an ad-hoc token is a JWT with its
35
+ * own expiry and doesn't touch the revocation floor or the agent's posture, so
36
+ * there is nothing in that query for it to change.
37
+ *
38
+ * The resulting token is returned once and is not retrievable afterwards — hand
39
+ * it straight to the user and don't cache it.
40
+ */
41
+ export declare function useCreateAdhocToken(agentId: string | undefined): import("@tanstack/react-query").UseMutationResult<import("../apis/agents-api").AdhocTokenResponse, Error, {
42
+ permissions?: ResourcePermission[];
43
+ timeout?: number;
44
+ }, unknown>;
45
+ /**
46
+ * Set the revocation floor to an explicit point; pass `0` or `null` to clear it.
47
+ *
48
+ * Lowering it reverses a revocation, so this is for undoing one made by mistake
49
+ * rather than for recovering a device whose credentials leaked.
50
+ */
51
+ export declare function useSetRevocationFloor(agentId: string | undefined): import("@tanstack/react-query").UseMutationResult<{
52
+ tokens_valid_from: number | null;
53
+ }, Error, number | null, unknown>;
54
+ /**
55
+ * Mint the device's long-lived credential for manual provisioning, returned once.
56
+ *
57
+ * Invalidates the token-state query so the revocation floor display stays honest
58
+ * — the mint itself is additive and doesn't move the floor, but refetching keeps
59
+ * this in step with anything else that has changed since the panel loaded.
60
+ */
61
+ export declare function useCreateDeviceToken(agentId: string | undefined): import("@tanstack/react-query").UseMutationResult<import("../apis/agents-api").DeviceTokenResponse, Error, void, unknown>;
62
+ /**
63
+ * Revoke every outstanding long-lived credential for an agent.
64
+ *
65
+ * Break-glass: the device stays offline until it is re-provisioned. Confirm with
66
+ * the user before calling.
67
+ */
68
+ export declare function useRevokeAgentTokens(agentId: string | undefined): import("@tanstack/react-query").UseMutationResult<{
69
+ tokens_valid_from: number;
70
+ }, Error, void, unknown>;
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.agentTokenStateQueryKey = agentTokenStateQueryKey;
4
+ exports.useAgentTokenState = useAgentTokenState;
5
+ exports.useSetAgentTokenPolicy = useSetAgentTokenPolicy;
6
+ exports.useSetAgentAuthLock = useSetAgentAuthLock;
7
+ exports.useCreateAdhocToken = useCreateAdhocToken;
8
+ exports.useSetRevocationFloor = useSetRevocationFloor;
9
+ exports.useCreateDeviceToken = useCreateDeviceToken;
10
+ exports.useRevokeAgentTokens = useRevokeAgentTokens;
11
+ const react_query_1 = require("@tanstack/react-query");
12
+ const context_1 = require("./context");
13
+ function agentTokenStateQueryKey(agentId) {
14
+ return ["doover", "agent", agentId ?? null, "tokenState"];
15
+ }
16
+ /**
17
+ * Read an agent's credential state — revocation floor, auth kill-switch, and the
18
+ * token posture resolved across the per-agent override and organisation default.
19
+ *
20
+ * Deliberately short `staleTime`: `auth_locked` can be flipped by doover-data
21
+ * itself (automatic lockout on suspected token reuse), so this value changes
22
+ * without any action from this client. Showing a stale "not locked" on the one
23
+ * screen someone opens *because* their device went offline would be worse than
24
+ * an extra fetch.
25
+ */
26
+ function useAgentTokenState(agentId, options) {
27
+ const client = (0, context_1.useDooverClient)();
28
+ return (0, react_query_1.useQuery)({
29
+ queryKey: agentTokenStateQueryKey(agentId),
30
+ enabled: (options?.enabled ?? true) && Boolean(agentId),
31
+ staleTime: options?.staleTime ?? 15 * 1000,
32
+ queryFn: () => client.agents.getTokenState(agentId),
33
+ });
34
+ }
35
+ /**
36
+ * Write an agent's token-posture override. Full replacement — a null field means
37
+ * "inherit the organisation default".
38
+ *
39
+ * Invalidates the token-state query on success so the inherited-vs-overridden
40
+ * display reflects the write without the caller wiring that up.
41
+ */
42
+ function useSetAgentTokenPolicy(agentId) {
43
+ const client = (0, context_1.useDooverClient)();
44
+ const queryClient = (0, react_query_1.useQueryClient)();
45
+ return (0, react_query_1.useMutation)({
46
+ mutationFn: (policy) => client.agents.setTokenPolicy(agentId, policy),
47
+ onSuccess: () => {
48
+ void queryClient.invalidateQueries({
49
+ queryKey: agentTokenStateQueryKey(agentId),
50
+ });
51
+ },
52
+ });
53
+ }
54
+ /** Set or clear the auth kill-switch. */
55
+ function useSetAgentAuthLock(agentId) {
56
+ const client = (0, context_1.useDooverClient)();
57
+ const queryClient = (0, react_query_1.useQueryClient)();
58
+ return (0, react_query_1.useMutation)({
59
+ mutationFn: (locked) => client.agents.setAuthLock(agentId, locked),
60
+ onSuccess: () => {
61
+ void queryClient.invalidateQueries({
62
+ queryKey: agentTokenStateQueryKey(agentId),
63
+ });
64
+ },
65
+ });
66
+ }
67
+ /**
68
+ * Mint a short-lived scoped token for ad-hoc access to an agent.
69
+ *
70
+ * Does not invalidate the token-state query: an ad-hoc token is a JWT with its
71
+ * own expiry and doesn't touch the revocation floor or the agent's posture, so
72
+ * there is nothing in that query for it to change.
73
+ *
74
+ * The resulting token is returned once and is not retrievable afterwards — hand
75
+ * it straight to the user and don't cache it.
76
+ */
77
+ function useCreateAdhocToken(agentId) {
78
+ const client = (0, context_1.useDooverClient)();
79
+ return (0, react_query_1.useMutation)({
80
+ mutationFn: (options) => client.agents.createAdhocToken(agentId, options),
81
+ });
82
+ }
83
+ /**
84
+ * Set the revocation floor to an explicit point; pass `0` or `null` to clear it.
85
+ *
86
+ * Lowering it reverses a revocation, so this is for undoing one made by mistake
87
+ * rather than for recovering a device whose credentials leaked.
88
+ */
89
+ function useSetRevocationFloor(agentId) {
90
+ const client = (0, context_1.useDooverClient)();
91
+ const queryClient = (0, react_query_1.useQueryClient)();
92
+ return (0, react_query_1.useMutation)({
93
+ mutationFn: (tokensValidFrom) => client.agents.setRevocationFloor(agentId, tokensValidFrom),
94
+ onSuccess: () => {
95
+ void queryClient.invalidateQueries({
96
+ queryKey: agentTokenStateQueryKey(agentId),
97
+ });
98
+ },
99
+ });
100
+ }
101
+ /**
102
+ * Mint the device's long-lived credential for manual provisioning, returned once.
103
+ *
104
+ * Invalidates the token-state query so the revocation floor display stays honest
105
+ * — the mint itself is additive and doesn't move the floor, but refetching keeps
106
+ * this in step with anything else that has changed since the panel loaded.
107
+ */
108
+ function useCreateDeviceToken(agentId) {
109
+ const client = (0, context_1.useDooverClient)();
110
+ const queryClient = (0, react_query_1.useQueryClient)();
111
+ return (0, react_query_1.useMutation)({
112
+ mutationFn: () => client.agents.createDeviceToken(agentId),
113
+ onSuccess: () => {
114
+ void queryClient.invalidateQueries({
115
+ queryKey: agentTokenStateQueryKey(agentId),
116
+ });
117
+ },
118
+ });
119
+ }
120
+ /**
121
+ * Revoke every outstanding long-lived credential for an agent.
122
+ *
123
+ * Break-glass: the device stays offline until it is re-provisioned. Confirm with
124
+ * the user before calling.
125
+ */
126
+ function useRevokeAgentTokens(agentId) {
127
+ const client = (0, context_1.useDooverClient)();
128
+ const queryClient = (0, react_query_1.useQueryClient)();
129
+ return (0, react_query_1.useMutation)({
130
+ mutationFn: () => client.agents.revokeAllTokens(agentId),
131
+ onSuccess: () => {
132
+ void queryClient.invalidateQueries({
133
+ queryKey: agentTokenStateQueryKey(agentId),
134
+ });
135
+ },
136
+ });
137
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const chai_1 = require("chai");
4
+ const mocha_1 = require("mocha");
5
+ const aggregates_api_1 = require("../apis/aggregates-api");
6
+ const messages_api_1 = require("../apis/messages-api");
7
+ const rest_client_1 = require("../http/rest-client");
8
+ const batch_1 = require("../types/batch");
9
+ const helpers_1 = require("./helpers");
10
+ /**
11
+ * Rest client whose fetch mock records every request and replies with a
12
+ * well-formed batch response echoing the submitted items as successes.
13
+ */
14
+ function setupBatchRest(overrides) {
15
+ const requests = [];
16
+ const fetchMock = (0, helpers_1.createFetchMock)((url, init) => {
17
+ const body = JSON.parse(String(init?.body ?? "{}"));
18
+ const captured = { url, method: init?.method, body };
19
+ requests.push(captured);
20
+ const override = overrides?.(captured);
21
+ if (override)
22
+ return (0, helpers_1.createJsonResponse)(override);
23
+ const items = body.items.map((item) => ({
24
+ agent_id: item.agent_id,
25
+ channel_name: item.channel_name,
26
+ success: true,
27
+ }));
28
+ return (0, helpers_1.createJsonResponse)({
29
+ items,
30
+ count: items.length,
31
+ succeeded: items.length,
32
+ failed: 0,
33
+ });
34
+ });
35
+ const rest = new rest_client_1.RestClient({
36
+ dataRestUrl: "https://api.example.com",
37
+ controlApiUrl: "https://control.example.com",
38
+ dataWssUrl: "wss://ws.example.com",
39
+ fetchImpl: fetchMock,
40
+ });
41
+ return { rest, requests };
42
+ }
43
+ function aggregateItems(count) {
44
+ return Array.from({ length: count }, (_, i) => ({
45
+ agent_id: `agent-${i}`,
46
+ channel_name: "dv-ui-sub",
47
+ data: { group_open: { "u1:s1": 1 } },
48
+ }));
49
+ }
50
+ (0, mocha_1.describe)("batch helpers", () => {
51
+ (0, mocha_1.it)("chunks to the server ceiling and preserves order", () => {
52
+ const chunks = (0, batch_1.chunkBatchItems)([1, 2, 3, 4, 5], 2);
53
+ (0, chai_1.expect)(chunks).to.deep.equal([[1, 2], [3, 4], [5]]);
54
+ });
55
+ (0, mocha_1.it)("returns no chunks for an empty list", () => {
56
+ (0, chai_1.expect)((0, batch_1.chunkBatchItems)([])).to.deep.equal([]);
57
+ });
58
+ (0, mocha_1.it)("rejects a nonsensical chunk size rather than looping forever", () => {
59
+ (0, chai_1.expect)(() => (0, batch_1.chunkBatchItems)([1], 0)).to.throw(RangeError);
60
+ });
61
+ (0, mocha_1.it)("recomputes counts when merging responses", () => {
62
+ const merged = (0, batch_1.mergeBatchResponses)([
63
+ {
64
+ items: [{ agent_id: "a", channel_name: "c", success: true }],
65
+ count: 1,
66
+ succeeded: 1,
67
+ failed: 0,
68
+ },
69
+ {
70
+ items: [
71
+ { agent_id: "b", channel_name: "c", success: false, error: "boom" },
72
+ ],
73
+ count: 1,
74
+ succeeded: 1, // deliberately wrong: merge must not trust these
75
+ failed: 0,
76
+ },
77
+ ]);
78
+ (0, chai_1.expect)(merged.count).to.equal(2);
79
+ (0, chai_1.expect)(merged.succeeded).to.equal(1);
80
+ (0, chai_1.expect)(merged.failed).to.equal(1);
81
+ (0, chai_1.expect)(merged.items.map((i) => i.agent_id)).to.deep.equal(["a", "b"]);
82
+ });
83
+ });
84
+ (0, mocha_1.describe)("AggregatesApi.batchPatchAggregates", () => {
85
+ (0, mocha_1.it)("PATCHes /agents/aggregates with an items envelope", async () => {
86
+ const { rest, requests } = setupBatchRest();
87
+ const api = new aggregates_api_1.AggregatesApi(rest);
88
+ const response = await api.batchPatchAggregates(aggregateItems(2));
89
+ (0, chai_1.expect)(requests).to.have.length(1);
90
+ (0, chai_1.expect)(requests[0].url).to.equal("https://api.example.com/agents/aggregates");
91
+ (0, chai_1.expect)(requests[0].method).to.equal("PATCH");
92
+ (0, chai_1.expect)(requests[0].body.items).to.have.length(2);
93
+ (0, chai_1.expect)(requests[0].body.items[0]).to.deep.equal({
94
+ agent_id: "agent-0",
95
+ channel_name: "dv-ui-sub",
96
+ data: { group_open: { "u1:s1": 1 } },
97
+ });
98
+ (0, chai_1.expect)(response.count).to.equal(2);
99
+ (0, chai_1.expect)(response.succeeded).to.equal(2);
100
+ });
101
+ (0, mocha_1.it)("issues no request at all for an empty batch", async () => {
102
+ const { rest, requests } = setupBatchRest();
103
+ const api = new aggregates_api_1.AggregatesApi(rest);
104
+ const response = await api.batchPatchAggregates([]);
105
+ (0, chai_1.expect)(requests).to.have.length(0);
106
+ (0, chai_1.expect)(response).to.deep.equal({ items: [], count: 0, succeeded: 0, failed: 0 });
107
+ });
108
+ (0, mocha_1.it)("splits oversized batches and merges the responses in order", async () => {
109
+ const { rest, requests } = setupBatchRest();
110
+ const api = new aggregates_api_1.AggregatesApi(rest);
111
+ const total = batch_1.MAX_BATCH_ITEMS + 5;
112
+ const response = await api.batchPatchAggregates(aggregateItems(total));
113
+ (0, chai_1.expect)(requests).to.have.length(2);
114
+ (0, chai_1.expect)(requests[0].body.items).to.have.length(batch_1.MAX_BATCH_ITEMS);
115
+ (0, chai_1.expect)(requests[1].body.items).to.have.length(5);
116
+ (0, chai_1.expect)(response.count).to.equal(total);
117
+ (0, chai_1.expect)(response.items[0].agent_id).to.equal("agent-0");
118
+ (0, chai_1.expect)(response.items[total - 1].agent_id).to.equal(`agent-${total - 1}`);
119
+ });
120
+ (0, mocha_1.it)("surfaces partial failure without throwing", async () => {
121
+ const { rest } = setupBatchRest(() => ({
122
+ items: [
123
+ { agent_id: "agent-0", channel_name: "dv-ui-sub", success: true },
124
+ {
125
+ agent_id: "agent-1",
126
+ channel_name: "dv-ui-sub",
127
+ success: false,
128
+ error: "permission denied",
129
+ },
130
+ ],
131
+ count: 2,
132
+ succeeded: 1,
133
+ failed: 1,
134
+ }));
135
+ const api = new aggregates_api_1.AggregatesApi(rest);
136
+ const response = await api.batchPatchAggregates(aggregateItems(2));
137
+ (0, chai_1.expect)(response.failed).to.equal(1);
138
+ (0, chai_1.expect)(response.items[1].error).to.equal("permission denied");
139
+ });
140
+ });
141
+ (0, mocha_1.describe)("MessagesApi batch mutations", () => {
142
+ (0, mocha_1.it)("POSTs creates to /agents/messages", async () => {
143
+ const { rest, requests } = setupBatchRest();
144
+ const api = new messages_api_1.MessagesApi(rest);
145
+ await api.batchPostMessages([
146
+ { agent_id: "a1", channel_name: "activity", message_id: "m1", data: { event: "opened" } },
147
+ ]);
148
+ (0, chai_1.expect)(requests[0].url).to.equal("https://api.example.com/agents/messages");
149
+ (0, chai_1.expect)(requests[0].method).to.equal("POST");
150
+ (0, chai_1.expect)(requests[0].body.items[0].message_id).to.equal("m1");
151
+ });
152
+ (0, mocha_1.it)("uses the matching verb for patch, put and delete", async () => {
153
+ const { rest, requests } = setupBatchRest();
154
+ const api = new messages_api_1.MessagesApi(rest);
155
+ const items = [{ agent_id: "a1", channel_name: "c1", message_id: "m1", data: {} }];
156
+ await api.batchPatchMessages(items);
157
+ await api.batchPutMessages(items);
158
+ await api.batchDeleteMessages([{ agent_id: "a1", channel_name: "c1", message_id: "m1" }]);
159
+ (0, chai_1.expect)(requests.map((r) => r.method)).to.deep.equal(["PATCH", "PUT", "DELETE"]);
160
+ // DELETE must carry a body — the reason these bypass `rest.delete`.
161
+ (0, chai_1.expect)(requests[2].body.items).to.have.length(1);
162
+ });
163
+ (0, mocha_1.it)("chunks message batches too", async () => {
164
+ const { rest, requests } = setupBatchRest();
165
+ const api = new messages_api_1.MessagesApi(rest);
166
+ const items = Array.from({ length: batch_1.MAX_BATCH_ITEMS + 1 }, (_, i) => ({
167
+ agent_id: `a${i}`,
168
+ channel_name: "c1",
169
+ message_id: `m${i}`,
170
+ data: {},
171
+ }));
172
+ const response = await api.batchPatchMessages(items);
173
+ (0, chai_1.expect)(requests).to.have.length(2);
174
+ (0, chai_1.expect)(response.count).to.equal(batch_1.MAX_BATCH_ITEMS + 1);
175
+ });
176
+ (0, mocha_1.it)("issues no request for an empty message batch", async () => {
177
+ const { rest, requests } = setupBatchRest();
178
+ const api = new messages_api_1.MessagesApi(rest);
179
+ const response = await api.batchDeleteMessages([]);
180
+ (0, chai_1.expect)(requests).to.have.length(0);
181
+ (0, chai_1.expect)(response.count).to.equal(0);
182
+ });
183
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Shared types for the bounded batch mutation endpoints.
3
+ *
4
+ * The data API exposes cross-agent batch mutations at `/agents/aggregates`
5
+ * and `/agents/messages`. Every item carries its own `agent_id` and
6
+ * `channel_name`, each is authorised independently, and the batch can
7
+ * partially succeed — results come back in request order and callers should
8
+ * retry only the failed entries.
9
+ *
10
+ * See `docs/batch-aggregate-updates.md` in channels-rest for the contract.
11
+ */
12
+ /**
13
+ * Server-enforced ceiling on items per batch request. Exceeding it is a 400,
14
+ * so callers must chunk. `chunkBatchItems` does this for you.
15
+ */
16
+ export declare const MAX_BATCH_ITEMS = 50;
17
+ /** A single merge-patch against one agent's channel aggregate. */
18
+ export interface BatchAggregateUpdateItem {
19
+ agent_id: string;
20
+ channel_name: string;
21
+ /** Merge patch. `null` at a leaf clears that key. */
22
+ data: Record<string, unknown>;
23
+ /**
24
+ * Dot-paths to replace outright rather than merge into. Mirrors the
25
+ * `replace` query parameter on the single-aggregate PATCH.
26
+ */
27
+ replace?: string[];
28
+ /** Skip aggregate hooks (alarms, SNS fan-out, websocket publish). */
29
+ suppress_hooks?: boolean;
30
+ }
31
+ export interface BatchCreateMessageItem {
32
+ agent_id: string;
33
+ channel_name: string;
34
+ /**
35
+ * Supplying a stable ID makes create retries idempotent. Without one the
36
+ * server generates an ID and a lost response can duplicate the message.
37
+ */
38
+ message_id?: string;
39
+ data: unknown;
40
+ /** Message timestamp, ms since epoch. Defaults to server receipt time. */
41
+ ts?: number;
42
+ /** Time-to-live in seconds. */
43
+ ttl?: number;
44
+ }
45
+ export interface BatchUpdateMessageItem {
46
+ agent_id: string;
47
+ channel_name: string;
48
+ message_id: string;
49
+ data: unknown;
50
+ }
51
+ export interface BatchDeleteMessageItem {
52
+ agent_id: string;
53
+ channel_name: string;
54
+ message_id: string;
55
+ }
56
+ /** Per-item outcome. `error` is present only when `success` is false. */
57
+ export interface BatchResultItem {
58
+ agent_id: string;
59
+ channel_name: string;
60
+ success: boolean;
61
+ error?: string;
62
+ }
63
+ export interface BatchMessageResultItem extends BatchResultItem {
64
+ /** Present for creates — the server-generated ID when none was supplied. */
65
+ message_id?: string;
66
+ }
67
+ export interface BatchResponse<TItem extends BatchResultItem = BatchResultItem> {
68
+ /** Results in request order, one per submitted item. */
69
+ items: TItem[];
70
+ count: number;
71
+ succeeded: number;
72
+ failed: number;
73
+ }
74
+ export type BatchAggregateResponse = BatchResponse<BatchResultItem>;
75
+ export type BatchMessageResponse = BatchResponse<BatchMessageResultItem>;
76
+ /**
77
+ * Split `items` into server-acceptable chunks. Returns `[]` for an empty
78
+ * input, since an empty batch is a 400 rather than a no-op.
79
+ */
80
+ export declare function chunkBatchItems<T>(items: T[], size?: number): T[][];
81
+ /**
82
+ * Merge several batch responses into one, as if the whole set had been sent
83
+ * in a single request. Useful after chunking.
84
+ */
85
+ export declare function mergeBatchResponses<TItem extends BatchResultItem>(responses: BatchResponse<TItem>[]): BatchResponse<TItem>;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ /**
3
+ * Shared types for the bounded batch mutation endpoints.
4
+ *
5
+ * The data API exposes cross-agent batch mutations at `/agents/aggregates`
6
+ * and `/agents/messages`. Every item carries its own `agent_id` and
7
+ * `channel_name`, each is authorised independently, and the batch can
8
+ * partially succeed — results come back in request order and callers should
9
+ * retry only the failed entries.
10
+ *
11
+ * See `docs/batch-aggregate-updates.md` in channels-rest for the contract.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.MAX_BATCH_ITEMS = void 0;
15
+ exports.chunkBatchItems = chunkBatchItems;
16
+ exports.mergeBatchResponses = mergeBatchResponses;
17
+ /**
18
+ * Server-enforced ceiling on items per batch request. Exceeding it is a 400,
19
+ * so callers must chunk. `chunkBatchItems` does this for you.
20
+ */
21
+ exports.MAX_BATCH_ITEMS = 50;
22
+ /**
23
+ * Split `items` into server-acceptable chunks. Returns `[]` for an empty
24
+ * input, since an empty batch is a 400 rather than a no-op.
25
+ */
26
+ function chunkBatchItems(items, size = exports.MAX_BATCH_ITEMS) {
27
+ if (size < 1)
28
+ throw new RangeError("chunk size must be at least 1");
29
+ const out = [];
30
+ for (let i = 0; i < items.length; i += size) {
31
+ out.push(items.slice(i, i + size));
32
+ }
33
+ return out;
34
+ }
35
+ /**
36
+ * Merge several batch responses into one, as if the whole set had been sent
37
+ * in a single request. Useful after chunking.
38
+ */
39
+ function mergeBatchResponses(responses) {
40
+ const items = responses.flatMap((r) => r.items);
41
+ return {
42
+ items,
43
+ count: items.length,
44
+ succeeded: items.filter((i) => i.success).length,
45
+ failed: items.filter((i) => !i.success).length,
46
+ };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doover-js",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "TypeScript client for Doover.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",