doover-js 0.6.0 → 0.6.2

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.
@@ -32,6 +32,14 @@ export declare class AgentsApi {
32
32
  private readonly controlApiUrl?;
33
33
  constructor(rest: RestClient, controlApiUrl?: string | undefined);
34
34
  listAgents(options?: GetAgentsOptions): Promise<AgentsResponse>;
35
+ /**
36
+ * Batch-fetch recent messages for many agents. Auto-chunks `agent_id`
37
+ * (and the parallel `agent_before` cursors, if given) at MULTI_AGENT_CHUNK_SIZE
38
+ * so each request's URL stays under CloudFront's 8,192-byte quota. Chunks
39
+ * are fetched in parallel and merged: `results`/`count` concat-and-sum,
40
+ * `next_cursors`/`at_limit_agent_ids` union, legacy `next` becomes the
41
+ * lexically-highest cursor across at-limit agents.
42
+ */
35
43
  getMultiAgentMessages(channelName: string, params: MultiAgentMessagesParams): Promise<BatchMessagesResponse>;
36
44
  /**
37
45
  * Batch-fetch aggregates for many agents. Auto-chunks `agent_id` so each
@@ -144,12 +144,65 @@ class AgentsApi {
144
144
  }
145
145
  return { ...raw, agents: merged, results: merged, count: merged.length };
146
146
  }
147
+ /**
148
+ * Batch-fetch recent messages for many agents. Auto-chunks `agent_id`
149
+ * (and the parallel `agent_before` cursors, if given) at MULTI_AGENT_CHUNK_SIZE
150
+ * so each request's URL stays under CloudFront's 8,192-byte quota. Chunks
151
+ * are fetched in parallel and merged: `results`/`count` concat-and-sum,
152
+ * `next_cursors`/`at_limit_agent_ids` union, legacy `next` becomes the
153
+ * lexically-highest cursor across at-limit agents.
154
+ */
147
155
  async getMultiAgentMessages(channelName, params) {
148
- const response = await this.rest.get(`/agents/channels/${channelName}/messages`, params);
149
- return {
156
+ const path = `/agents/channels/${channelName}/messages`;
157
+ const { agent_id, agent_before, ...rest } = params;
158
+ const stampTimestamps = (response) => ({
150
159
  ...response,
151
160
  results: response.results.map((message) => "timestamp" in message ? message : (0, snowflake_1.addTimestampToMessage)(message)),
161
+ });
162
+ if (agent_id.length <= MULTI_AGENT_CHUNK_SIZE) {
163
+ const response = await this.rest.get(path, params);
164
+ return stampTimestamps(response);
165
+ }
166
+ if (agent_before && agent_before.length !== agent_id.length) {
167
+ throw new Error("agent_before must be the same length as agent_id when set");
168
+ }
169
+ const chunks = [];
170
+ for (let i = 0; i < agent_id.length; i += MULTI_AGENT_CHUNK_SIZE) {
171
+ chunks.push({
172
+ agent_id: agent_id.slice(i, i + MULTI_AGENT_CHUNK_SIZE),
173
+ ...(agent_before
174
+ ? { agent_before: agent_before.slice(i, i + MULTI_AGENT_CHUNK_SIZE) }
175
+ : {}),
176
+ });
177
+ }
178
+ const responses = await Promise.all(chunks.map((chunk) => this.rest
179
+ .get(path, { ...rest, ...chunk })
180
+ .then(stampTimestamps)));
181
+ // Merge: results/count concat-and-sum; next_cursors merge; at_limit_agent_ids
182
+ // concat (both keyed by agent id, unique across chunks). Legacy `next` is the
183
+ // lexically-highest oldest-cursor across at-limit agents — snowflakes sort
184
+ // lexically since they're equal-width digit strings.
185
+ const merged = {
186
+ results: responses.flatMap((r) => r.results),
187
+ count: responses.reduce((acc, r) => acc + r.count, 0),
152
188
  };
189
+ const nexts = responses
190
+ .map((r) => r.next)
191
+ .filter((n) => typeof n === "string" && n.length > 0);
192
+ if (nexts.length > 0) {
193
+ merged.next = nexts.reduce((a, b) => (a > b ? a : b));
194
+ }
195
+ const cursors = {};
196
+ for (const r of responses) {
197
+ if (r.next_cursors)
198
+ Object.assign(cursors, r.next_cursors);
199
+ }
200
+ if (Object.keys(cursors).length > 0)
201
+ merged.next_cursors = cursors;
202
+ const atLimit = responses.flatMap((r) => r.at_limit_agent_ids ?? []);
203
+ if (atLimit.length > 0)
204
+ merged.at_limit_agent_ids = atLimit;
205
+ return merged;
153
206
  }
154
207
  /**
155
208
  * Batch-fetch aggregates for many agents. Auto-chunks `agent_id` so each
package/dist/index.d.ts CHANGED
@@ -47,4 +47,6 @@ export type * from "./types/common";
47
47
  export type * from "./types/connection";
48
48
  export type * from "./types/openapi";
49
49
  export type * from "./types/viewer";
50
+ export type * from "./types/audit";
51
+ export { DV_AUDIT_CHANNEL } from "./types/audit";
50
52
  export { addTimestampToMessage, extractSnowflakeId, generateSnowflakeIdAtTime, } from "./utils/snowflake";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.generateSnowflakeIdAtTime = exports.extractSnowflakeId = exports.addTimestampToMessage = 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.AmbiguousWriteError = exports.UnsupportedCapabilityError = exports.ALL_CAPABILITIES = exports.DooverRpcError = exports.RpcDispatcher = exports.UsersApi = exports.TurnApi = exports.ProcessorsApi = exports.PermissionsApi = 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.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.AmbiguousWriteError = exports.UnsupportedCapabilityError = exports.ALL_CAPABILITIES = exports.DooverRpcError = exports.RpcDispatcher = exports.UsersApi = exports.TurnApi = exports.ProcessorsApi = exports.PermissionsApi = 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
4
  var doover_client_1 = require("./client/doover-client");
5
5
  Object.defineProperty(exports, "DooverClient", { enumerable: true, get: function () { return doover_client_1.DooverClient; } });
6
6
  var local_agent_client_1 = require("./client/local-agent-client");
@@ -68,6 +68,8 @@ var doover_data_provider_1 = require("./viewer/doover-data-provider");
68
68
  Object.defineProperty(exports, "DooverDataProvider", { enumerable: true, get: function () { return doover_data_provider_1.DooverDataProvider; } });
69
69
  var path_parsing_1 = require("./viewer/path-parsing");
70
70
  Object.defineProperty(exports, "getIdentifierFromPath", { enumerable: true, get: function () { return path_parsing_1.getIdentifierFromPath; } });
71
+ var audit_1 = require("./types/audit");
72
+ Object.defineProperty(exports, "DV_AUDIT_CHANNEL", { enumerable: true, get: function () { return audit_1.DV_AUDIT_CHANNEL; } });
71
73
  var snowflake_1 = require("./utils/snowflake");
72
74
  Object.defineProperty(exports, "addTimestampToMessage", { enumerable: true, get: function () { return snowflake_1.addTimestampToMessage; } });
73
75
  Object.defineProperty(exports, "extractSnowflakeId", { enumerable: true, get: function () { return snowflake_1.extractSnowflakeId; } });
@@ -1,8 +1,23 @@
1
1
  import { type InfiniteData, type UseInfiniteQueryResult } from "@tanstack/react-query";
2
2
  import type { MessageStructure } from "../types/common";
3
- export declare function multiAgentChannelMessagesQueryKey(channelName: string, agentIds: string[], sources?: string[]): readonly ["doover", "channel", string, "messages", string, "src", string];
3
+ export declare function multiAgentChannelMessagesQueryKey(channelName: string, agentIds: string[], sources?: string[], scope?: {
4
+ after?: string;
5
+ fields?: readonly string[];
6
+ }): readonly ["doover", "channel", string, "messages", string, "src", string] | readonly ["doover", "channel", string, "messages", string, "src", string, Record<string, unknown>];
4
7
  export interface UseMultiAgentChannelMessagesOptions {
8
+ /**
9
+ * Global cap on the total number of messages returned across all agents
10
+ * for a single request. The server distributes this budget across agents
11
+ * in newest-first order, so a noisy agent can starve quieter ones.
12
+ * Prefer `agentMessageLimit` when you want a fair per-agent slice.
13
+ */
5
14
  limit?: number;
15
+ /**
16
+ * Per-agent cap on messages returned. Forwarded as `agent_message_limit`.
17
+ * Use this when fanning out across many agents that have wildly different
18
+ * message rates and you want each agent's window represented.
19
+ */
20
+ agentMessageLimit?: number;
6
21
  /** If false, skip live subscriptions per agent. Defaults true. */
7
22
  liveUpdates?: boolean;
8
23
  /**
@@ -12,6 +27,14 @@ export interface UseMultiAgentChannelMessagesOptions {
12
27
  fields?: string[];
13
28
  /** Optional first-page `before` cursor (snowflake id). */
14
29
  initialBefore?: string;
30
+ /**
31
+ * Optional lower-bound snowflake id. Forwarded server-side so each
32
+ * agent's pagination stops on its own once it walks past the bound —
33
+ * mirrors `useChannelMessages`'s `after`. Use this to fetch a bounded
34
+ * time window (e.g. last 24h) across many agents without filtering
35
+ * on the client.
36
+ */
37
+ after?: string;
15
38
  /**
16
39
  * Restrict to these source ids on a `MultiplexClient`. Ignored for a plain
17
40
  * `DooverClient` or `LocalAgentClient`. When set, the query key is
@@ -5,9 +5,9 @@ exports.useMultiAgentChannelMessages = useMultiAgentChannelMessages;
5
5
  const react_1 = require("react");
6
6
  const react_query_1 = require("@tanstack/react-query");
7
7
  const context_1 = require("./context");
8
- function multiAgentChannelMessagesQueryKey(channelName, agentIds, sources) {
8
+ function multiAgentChannelMessagesQueryKey(channelName, agentIds, sources, scope) {
9
9
  const sourceDim = sources && sources.length ? [...sources].sort().join(",") : "*";
10
- return [
10
+ const base = [
11
11
  "doover",
12
12
  "channel",
13
13
  channelName,
@@ -16,16 +16,34 @@ function multiAgentChannelMessagesQueryKey(channelName, agentIds, sources) {
16
16
  "src",
17
17
  sourceDim,
18
18
  ];
19
+ // Pagination cursors come from the first page's response and are scoped
20
+ // to *that* response's `after`/`fields`. Mixing them under one cache
21
+ // entry would cause `getNextPageParam` to anchor on an unrelated page,
22
+ // so a differently-bounded request must live under its own key.
23
+ const scopeKey = {};
24
+ if (scope?.after)
25
+ scopeKey.after = scope.after;
26
+ if (scope?.fields && scope.fields.length > 0) {
27
+ scopeKey.fields = [...scope.fields].sort();
28
+ }
29
+ return Object.keys(scopeKey).length > 0
30
+ ? [...base, scopeKey]
31
+ : base;
19
32
  }
20
33
  function useMultiAgentChannelMessages(channelName, agentIds, options) {
21
34
  const client = (0, context_1.useDooverClient)();
22
35
  const queryClient = (0, react_query_1.useQueryClient)();
23
36
  const limit = options?.limit;
37
+ const agentMessageLimit = options?.agentMessageLimit;
24
38
  const liveUpdates = options?.liveUpdates ?? true;
25
39
  const fields = options?.fields;
26
40
  const initialBefore = options?.initialBefore;
27
41
  const sources = options?.sources;
28
- const key = multiAgentChannelMessagesQueryKey(channelName, agentIds, sources);
42
+ const after = options?.after;
43
+ const key = multiAgentChannelMessagesQueryKey(channelName, agentIds, sources, {
44
+ after,
45
+ fields,
46
+ });
29
47
  const prependMessage = (0, react_1.useCallback)((message) => {
30
48
  queryClient.setQueryData(key, (current) => {
31
49
  if (!current)
@@ -42,8 +60,12 @@ function useMultiAgentChannelMessages(channelName, agentIds, options) {
42
60
  };
43
61
  });
44
62
  },
63
+ // `key` captures every scope dim (agentIds + sources + after + fields);
64
+ // serialise the whole thing so the closure refreshes when any dim
65
+ // changes — listing dims by hand drops live updates whenever the list
66
+ // grows (we used to omit `after`/`fields`).
45
67
  // eslint-disable-next-line react-hooks/exhaustive-deps
46
- [queryClient, channelName, agentIds.join(","), sources?.join(",")]);
68
+ [queryClient, JSON.stringify(key)]);
47
69
  (0, react_1.useEffect)(() => {
48
70
  if (!liveUpdates || agentIds.length === 0)
49
71
  return;
@@ -71,7 +93,11 @@ function useMultiAgentChannelMessages(channelName, agentIds, options) {
71
93
  agent_id: agentIds,
72
94
  ...(typeof pageParam === "string" ? { before: pageParam } : {}),
73
95
  ...(limit !== undefined ? { limit } : {}),
96
+ ...(agentMessageLimit !== undefined
97
+ ? { agent_message_limit: agentMessageLimit }
98
+ : {}),
74
99
  ...(fields && fields.length > 0 ? { field_name: fields } : {}),
100
+ ...(after !== undefined ? { after } : {}),
75
101
  };
76
102
  // Pass `{ sources }` as a trailing bag only when set — cast through
77
103
  // `never` since the TypeScript overloads don't declare it.
@@ -82,6 +82,82 @@ function setupRest(responseFactory) {
82
82
  const returnedIds = aggregates.results.map((r) => r.agent_id).sort();
83
83
  (0, chai_1.expect)(returnedIds).to.deep.equal([...agentIds].sort());
84
84
  });
85
+ (0, mocha_1.it)("chunks getMultiAgentMessages over the per-request agent cap", async () => {
86
+ const baseTs = new Date("2026-01-01T00:00:00Z");
87
+ const { rest, fetchMock } = setupRest((url) => {
88
+ const ids = [...new URL(url).searchParams.getAll("agent_id")];
89
+ return (0, helpers_1.createJsonResponse)({
90
+ results: ids.map((id, i) => ({
91
+ id: (0, snowflake_1.generateSnowflakeIdAtTime)(new Date(baseTs.getTime() + i)),
92
+ author_id: "u",
93
+ channel: { agent_id: id, name: "c1" },
94
+ data: {},
95
+ attachments: [],
96
+ })),
97
+ count: ids.length,
98
+ });
99
+ });
100
+ const api = new agents_api_1.AgentsApi(rest);
101
+ // 600 agents → 3 chunks of 250/250/100.
102
+ const agentIds = Array.from({ length: 600 }, (_, i) => `a${i}`);
103
+ const messages = await api.getMultiAgentMessages("c1", {
104
+ agent_id: agentIds,
105
+ agent_message_limit: 50,
106
+ });
107
+ (0, chai_1.expect)(fetchMock.callCount).to.equal(3);
108
+ (0, chai_1.expect)(messages.results).to.have.length(600);
109
+ (0, chai_1.expect)(messages.count).to.equal(600);
110
+ const returnedIds = messages.results
111
+ .map((m) => m.channel.agent_id)
112
+ .sort();
113
+ (0, chai_1.expect)(returnedIds).to.deep.equal([...agentIds].sort());
114
+ // Per-request param survives chunking.
115
+ for (let i = 0; i < 3; i++) {
116
+ const url = fetchMock.getCall(i).args[0];
117
+ (0, chai_1.expect)(url).to.include("agent_message_limit=50");
118
+ }
119
+ });
120
+ (0, mocha_1.it)("merges next_cursors and at_limit_agent_ids across chunks", async () => {
121
+ let call = 0;
122
+ const cursors = [
123
+ { "a0": "100", "a1": "200" },
124
+ { "a250": "150" },
125
+ ];
126
+ const atLimit = [["a0", "a1"], ["a250"]];
127
+ const { rest } = setupRest((url) => {
128
+ const idx = call++;
129
+ const ids = [...new URL(url).searchParams.getAll("agent_id")];
130
+ return (0, helpers_1.createJsonResponse)({
131
+ results: ids.map((id) => ({
132
+ id: (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2026-01-01T00:00:00Z")),
133
+ author_id: "u",
134
+ channel: { agent_id: id, name: "c1" },
135
+ data: {},
136
+ attachments: [],
137
+ })),
138
+ count: ids.length,
139
+ next: idx === 0 ? "200" : "150",
140
+ next_cursors: cursors[idx],
141
+ at_limit_agent_ids: atLimit[idx],
142
+ });
143
+ });
144
+ const api = new agents_api_1.AgentsApi(rest);
145
+ const agentIds = Array.from({ length: 300 }, (_, i) => `a${i}`);
146
+ const messages = await api.getMultiAgentMessages("c1", {
147
+ agent_id: agentIds,
148
+ });
149
+ (0, chai_1.expect)(messages.next).to.equal("200"); // max across chunks
150
+ (0, chai_1.expect)(messages.next_cursors).to.deep.equal({
151
+ a0: "100",
152
+ a1: "200",
153
+ a250: "150",
154
+ });
155
+ (0, chai_1.expect)(messages.at_limit_agent_ids?.sort()).to.deep.equal([
156
+ "a0",
157
+ "a1",
158
+ "a250",
159
+ ]);
160
+ });
85
161
  (0, mocha_1.it)("covers channels methods", async () => {
86
162
  const { rest, fetchMock } = setupRest(() => (0, helpers_1.createJsonResponse)([]));
87
163
  const api = new channels_api_1.ChannelsApi(rest);
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Audit event types — mirror of the records doover-control writes to the
3
+ * `dv-audit` channel (see ``doover-control/doover_control/events/audit.py``
4
+ * for the source of truth).
5
+ *
6
+ * Each record is read back via the standard channel-messages APIs: it lands
7
+ * inside a ``MessageStructure<AuditEvent>`` envelope (with ``id``, ``timestamp``,
8
+ * ``channel``, etc.), and the record itself is on ``message.data``.
9
+ */
10
+ /** Channel name on which audit records live in doover-data. */
11
+ export declare const DV_AUDIT_CHANNEL = "dv-audit";
12
+ /** The role this party plays in the record. */
13
+ export type AuditEntityType = "device" | "user" | "organisation" | "Tunnel" | "ApplicationInstallation" | "SolutionInstallation" | "ApplicationDeployment" | "ObjectGroup" | "PendingUser" | "User" | "fusionauth" | "system";
14
+ export interface AuditEntity {
15
+ type: AuditEntityType;
16
+ /** Stringified id (User pk, Device pk, FA UUID, etc.). May be absent for `system`. */
17
+ id?: string;
18
+ /** Display name. May be an email for users / pending invites. */
19
+ name?: string;
20
+ }
21
+ export interface AuditOrganisation {
22
+ id: string;
23
+ name: string;
24
+ }
25
+ export interface AuditRequestContext {
26
+ ip_address?: string;
27
+ user_agent?: string;
28
+ }
29
+ /** Origin system for the event. */
30
+ export type AuditSource = "control" | "auth" | "data" | "tunnels";
31
+ /**
32
+ * String-literal union of every wired audit action — kept in lock-step with
33
+ * ``doover_control/events/types.py``'s ``EventType`` enum. Future event types
34
+ * declared in Python but not yet emitted are intentionally included so the
35
+ * frontend mapping (icons, labels) can be filled in ahead of the wire-up.
36
+ */
37
+ export type AuditAction = "user.logged_in" | "user.login.failed" | "user.signed_up" | "user.created" | "user.updated" | "user.deleted" | "user.deactivated" | "user.reactivated" | "user.password_changed" | "user.password_reset" | "user.password_breached" | "user.mfa_changed" | "user.email_verified" | "user.email_changed" | "user.idp_linked" | "user.idp_unlinked" | "user.token_revoked" | "user.invited" | "user.removed" | "user.role_changed" | "app.installed" | "app.deployed" | "app.config_changed" | "app.uninstalled" | "solution.config_changed" | "group.created" | "group.edited" | "group.deleted" | "device.created" | "device.updated" | "device.config_changed" | "device.archived" | "device.unarchived" | "device.deleted" | "device.opened" | "tunnel.created" | "tunnel.deleted" | "tunnel.opened" | "tunnel.closed" | "tunnel.accessed" | "notification.sent" | "report.created" | "command.received";
38
+ /**
39
+ * The audit record payload as written by ``publish_audit`` — i.e. what you
40
+ * find on ``MessageStructure<AuditEvent>.data`` after reading the channel.
41
+ *
42
+ * The doover-data ``MessageStructure`` envelope provides ``id`` (snowflake)
43
+ * and ``timestamp`` (epoch ms) for ordering; this struct is the body only.
44
+ */
45
+ export interface AuditEvent {
46
+ action: AuditAction;
47
+ actor: AuditEntity | null;
48
+ subject: AuditEntity | null;
49
+ organisation: AuditOrganisation | null;
50
+ source: AuditSource;
51
+ metadata: Record<string, unknown>;
52
+ request_context: AuditRequestContext | null;
53
+ }
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ /**
3
+ * Audit event types — mirror of the records doover-control writes to the
4
+ * `dv-audit` channel (see ``doover-control/doover_control/events/audit.py``
5
+ * for the source of truth).
6
+ *
7
+ * Each record is read back via the standard channel-messages APIs: it lands
8
+ * inside a ``MessageStructure<AuditEvent>`` envelope (with ``id``, ``timestamp``,
9
+ * ``channel``, etc.), and the record itself is on ``message.data``.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.DV_AUDIT_CHANNEL = void 0;
13
+ /** Channel name on which audit records live in doover-data. */
14
+ exports.DV_AUDIT_CHANNEL = "dv-audit";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doover-js",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "TypeScript client for Doover.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",