@dbx-tools/appkit-mastra 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/history.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Thread history loader exposed as a Mastra custom API route.
3
+ *
4
+ * Backed entirely by native Mastra: looks up the active agent by id,
5
+ * asks its `Memory` instance to `recall` a page of `MastraDBMessage`s,
6
+ * and converts the result to AI SDK V5 `UIMessage`s with the official
7
+ * {@link toAISdkV5Messages} helper from `@mastra/ai-sdk/ui`. No direct
8
+ * database reads.
9
+ *
10
+ * The route is registered through {@link historyRoute} as a Mastra
11
+ * `registerApiRoute` so it sits in the same dispatcher pipeline as
12
+ * `chatRoute`. That means the `MastraServer` auth middleware (in
13
+ * `./server.ts`) has already populated `RequestContext` with
14
+ * `MASTRA_THREAD_ID_KEY` and `MASTRA_RESOURCE_ID_KEY` by the time
15
+ * the handler runs - no cookie or user lookups happen here, and the
16
+ * session-cookie logic stays the single source of truth in `server.ts`.
17
+ */
18
+
19
+ import { toAISdkV5Messages } from "@mastra/ai-sdk/ui";
20
+ import type { Agent } from "@mastra/core/agent";
21
+ import type {
22
+ MastraDBMessage,
23
+ } from "@mastra/core/agent/message-list";
24
+ import {
25
+ MASTRA_RESOURCE_ID_KEY,
26
+ MASTRA_THREAD_ID_KEY,
27
+ } from "@mastra/core/request-context";
28
+ import { registerApiRoute } from "@mastra/core/server";
29
+ import type {
30
+ MastraHistoryResponse,
31
+ MastraHistoryUIMessage,
32
+ } from "@dbx-tools/appkit-mastra-shared";
33
+
34
+ /** Default history page size; matches the Mastra storage default. */
35
+ const DEFAULT_PER_PAGE = 20;
36
+ /** Hard cap so a misbehaving client can't fetch the whole thread at once. */
37
+ const MAX_PER_PAGE = 200;
38
+
39
+ /** Inputs accepted by {@link loadHistory}. */
40
+ export interface LoadHistoryOptions {
41
+ agent: Agent;
42
+ threadId: string;
43
+ resourceId?: string;
44
+ page?: number;
45
+ perPage?: number;
46
+ /** When true, returns the *oldest* page first (chronological). */
47
+ ascending?: boolean;
48
+ }
49
+
50
+ /**
51
+ * Fetch a page of UI-formatted messages for a thread.
52
+ *
53
+ * Uses the agent's resolved `Memory` (`getMemory()`) so per-agent
54
+ * storage namespaces (`mastra_<agentId>` schemas) and any future
55
+ * memory-side filters apply automatically. When the agent has no
56
+ * memory configured the response is a successful empty page so
57
+ * callers don't have to special-case stateless agents.
58
+ *
59
+ * Pagination is descending-by-default: page 0 is the most recent
60
+ * page, page 1 the page before that, etc. The returned `uiMessages`
61
+ * are always re-sorted into chronological order (oldest -> newest)
62
+ * so the client can prepend them above the existing transcript
63
+ * without sorting locally.
64
+ */
65
+ export async function loadHistory(
66
+ opts: LoadHistoryOptions,
67
+ ): Promise<MastraHistoryResponse> {
68
+ const perPage = clampPerPage(opts.perPage);
69
+ const page = Math.max(0, Math.trunc(opts.page ?? 0));
70
+ const memory = await opts.agent.getMemory();
71
+ if (!memory) {
72
+ return { uiMessages: [], page, perPage, total: 0, hasMore: false };
73
+ }
74
+ const result = await memory.recall({
75
+ threadId: opts.threadId,
76
+ ...(opts.resourceId ? { resourceId: opts.resourceId } : {}),
77
+ page,
78
+ perPage,
79
+ orderBy: {
80
+ field: "createdAt",
81
+ direction: opts.ascending ? "ASC" : "DESC",
82
+ },
83
+ });
84
+ const chronological = sortChronological(result.messages);
85
+ const uiMessages = toAISdkV5Messages(
86
+ chronological,
87
+ ) as unknown as MastraHistoryUIMessage[];
88
+ return {
89
+ uiMessages,
90
+ page,
91
+ perPage,
92
+ total: result.total,
93
+ hasMore: result.hasMore,
94
+ };
95
+ }
96
+
97
+ /** Options accepted by {@link historyRoute}. */
98
+ export type HistoryRouteOptions =
99
+ | { path: `${string}:agentId${string}`; agent?: never }
100
+ | { path: string; agent: string };
101
+
102
+ /**
103
+ * Register a `GET <path>` Mastra custom API route that returns a page
104
+ * of AI SDK V5 `UIMessage`s for the caller's current thread.
105
+ *
106
+ * Modeled after `chatRoute` from `@mastra/ai-sdk`: pass `agent` for a
107
+ * fixed-agent mount, or include `:agentId` in the path for dynamic
108
+ * routing. Pairs cleanly with the AppKit Mastra plugin's chat route
109
+ * layout (`/route/chat` + `/route/chat/:agentId`).
110
+ *
111
+ * The handler reads `threadId` and `resourceId` from `RequestContext`
112
+ * (populated upstream by `MastraServer.registerAuthMiddleware`), so
113
+ * no cookie or user lookups happen here.
114
+ */
115
+ export function historyRoute(options: HistoryRouteOptions) {
116
+ const { path } = options;
117
+ const fixedAgent = "agent" in options ? options.agent : undefined;
118
+ if (!fixedAgent && !path.includes(":agentId")) {
119
+ throw new Error(
120
+ "historyRoute path must include `:agentId` or `agent` must be passed explicitly",
121
+ );
122
+ }
123
+ return registerApiRoute(path, {
124
+ method: "GET",
125
+ handler: async (c) => {
126
+ const mastra = c.get("mastra");
127
+ const requestContext = c.get("requestContext");
128
+ const agentId = fixedAgent ?? c.req.param("agentId");
129
+ if (!agentId) {
130
+ return c.json({ error: "agentId is required" }, 400);
131
+ }
132
+ const agent = mastra.getAgentById(agentId);
133
+ if (!agent) {
134
+ return c.json({ error: `Unknown agent "${agentId}"` }, 404);
135
+ }
136
+ const threadId = requestContext.get(MASTRA_THREAD_ID_KEY) as
137
+ | string
138
+ | undefined;
139
+ if (!threadId) {
140
+ return c.json({ error: "thread id missing from request context" }, 400);
141
+ }
142
+ const resourceId = requestContext.get(MASTRA_RESOURCE_ID_KEY) as
143
+ | string
144
+ | undefined;
145
+ const payload = await loadHistory({
146
+ agent,
147
+ threadId,
148
+ ...(resourceId ? { resourceId } : {}),
149
+ page: parseIntParam(c.req.query("page")),
150
+ perPage: parseIntParam(c.req.query("perPage")),
151
+ });
152
+ return c.json(payload);
153
+ },
154
+ });
155
+ }
156
+
157
+ /** Coerce / clamp `perPage`; falls back to the page-size default. */
158
+ function clampPerPage(value: number | undefined): number {
159
+ if (value === undefined || Number.isNaN(value)) return DEFAULT_PER_PAGE;
160
+ const n = Math.trunc(value);
161
+ if (n <= 0) return DEFAULT_PER_PAGE;
162
+ return Math.min(n, MAX_PER_PAGE);
163
+ }
164
+
165
+ /**
166
+ * Sort messages oldest-first by `createdAt`, falling back to whatever
167
+ * order the storage returned them in. The native `recall` call honors
168
+ * `orderBy` but doesn't guarantee a stable secondary sort, so we
169
+ * normalize here before handing the page to the AI SDK converter.
170
+ */
171
+ function sortChronological(messages: MastraDBMessage[]): MastraDBMessage[] {
172
+ return [...messages].sort((a, b) => {
173
+ const ta = toEpoch(a.createdAt);
174
+ const tb = toEpoch(b.createdAt);
175
+ return ta - tb;
176
+ });
177
+ }
178
+
179
+ function toEpoch(value: unknown): number {
180
+ if (value instanceof Date) return value.getTime();
181
+ if (typeof value === "string" || typeof value === "number") {
182
+ const parsed = new Date(value).getTime();
183
+ return Number.isNaN(parsed) ? 0 : parsed;
184
+ }
185
+ return 0;
186
+ }
187
+
188
+ /**
189
+ * Coerce a Hono query value into a non-negative integer. Returns
190
+ * `undefined` for empty / non-numeric / negative inputs so
191
+ * {@link loadHistory} can apply its built-in defaults.
192
+ */
193
+ function parseIntParam(value: string | undefined): number | undefined {
194
+ if (!value) return undefined;
195
+ const n = Number(value);
196
+ if (!Number.isFinite(n) || n < 0) return undefined;
197
+ return Math.trunc(n);
198
+ }
package/src/plugin.ts CHANGED
@@ -46,6 +46,8 @@ import express from "express";
46
46
  import { buildAgents, FALLBACK_AGENT_ID, type BuiltAgents } from "./agents.js";
47
47
  import type { MastraClientConfig } from "@dbx-tools/appkit-mastra-shared";
48
48
  import type { MastraPluginConfig } from "./config.js";
49
+ import { historyRoute } from "./history.js";
50
+ import { renderChartRoute } from "./render-chart-route.js";
49
51
  import { createMemoryBuilder, needsLakebase } from "./memory.js";
50
52
  import { attachRoutePatchMiddleware, MastraServer } from "./server.js";
51
53
  import {
@@ -187,6 +189,9 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
187
189
  chatPath: `${basePath}/route/chat`,
188
190
  chatPathTemplate: `${basePath}/route/chat/:agentId`,
189
191
  modelsPath: `${basePath}/models`,
192
+ historyPath: `${basePath}/route/history`,
193
+ historyPathTemplate: `${basePath}/route/history/:agentId`,
194
+ renderChartPath: `${basePath}/route/render-chart`,
190
195
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
191
196
  agents: Object.keys(this.built?.agents ?? {}),
192
197
  };
@@ -200,7 +205,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
200
205
  // the Mastra subapp. Errors propagate to Express's default error
201
206
  // handler via `next(err)` so callers see the real SDK message.
202
207
  router.get("/models", (req, res, next) => {
203
- this.asUser(req)
208
+ this.userScopedSelf(req)
204
209
  .listModels()
205
210
  .then((endpoints) => res.json({ endpoints }))
206
211
  .catch(next);
@@ -208,10 +213,23 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
208
213
 
209
214
  router.use("", (req, res, next) => {
210
215
  if (!this.mastraApp) return res.status(503).end();
211
- return this.asUser(req).mastraApp!(req, res, next);
216
+ return this.userScopedSelf(req).mastraApp!(req, res, next);
212
217
  });
213
218
  }
214
219
 
220
+ /**
221
+ * Return `this.asUser(req)` when the request carries an OBO token,
222
+ * otherwise return `this` directly. Prevents the noisy AppKit warn
223
+ * (`asUser() called without user token in development mode. Skipping
224
+ * user impersonation.`) on every request in local dev where the
225
+ * browser never sends `x-forwarded-access-token`. Behavior is
226
+ * unchanged in production: a missing token always means a real OBO
227
+ * proxy call (and AppKit will throw upstream if that's wrong).
228
+ */
229
+ private userScopedSelf(req: express.Request): this {
230
+ return req.header("x-forwarded-access-token") ? (this.asUser(req) as this) : this;
231
+ }
232
+
215
233
  /**
216
234
  * Implementation backing both the `/models` route and the
217
235
  * `listModels` export. Runs inside the AppKit user-context proxy so
@@ -260,6 +278,9 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
260
278
  customApiRoutes: [
261
279
  chatRoute({ path: "/route/chat", agent: this.built.defaultAgentId }),
262
280
  chatRoute({ path: "/route/chat/:agentId" }),
281
+ historyRoute({ path: "/route/history", agent: this.built.defaultAgentId }),
282
+ historyRoute({ path: "/route/history/:agentId" }),
283
+ renderChartRoute({ path: "/route/render-chart", config: this.config }),
263
284
  ],
264
285
  });
265
286
  await this.mastraServer.init();
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Chart-render HTTP endpoint for the Mastra plugin.
3
+ *
4
+ * The `render_data` tool returns immediately with a `chartId` and
5
+ * emits the dataset over `ctx.writer`; the client then POSTs that
6
+ * dataset to this endpoint to actually run the chart-planner
7
+ * agent and get back an Echarts `EChartsOption` JSON. Planning
8
+ * happens out-of-band so the calling agent's response stream
9
+ * doesn't sit idle waiting for it - the model can finish the
10
+ * report while the client is still rendering charts.
11
+ *
12
+ * Auth flows through the standard Mastra middleware: the route
13
+ * sits in the same dispatcher pipeline as `chatRoute` /
14
+ * `historyRoute`, so by the time the handler runs the
15
+ * `RequestContext` is populated with the workspace user and
16
+ * the chart-planner's model resolver has the OBO token it
17
+ * needs.
18
+ */
19
+
20
+ import type {
21
+ RenderChartRequest,
22
+ RenderChartResponse,
23
+ } from "@dbx-tools/appkit-mastra-shared";
24
+ import { registerApiRoute } from "@mastra/core/server";
25
+
26
+ import { runChartPlanner } from "./chart.js";
27
+ import type { MastraPluginConfig } from "./config.js";
28
+
29
+ /** Hard cap so a misbehaving client can't hand us a million-row payload. */
30
+ const MAX_ROWS = 5_000;
31
+ /**
32
+ * Hard cap on the JSON body the route accepts (in bytes). Mirrors
33
+ * the same intent as {@link MAX_ROWS}: bound the chart-planner's
34
+ * prompt size and protect against accidental denial-of-service
35
+ * from a runaway tool that ships an enormous payload.
36
+ */
37
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
38
+
39
+ /** Options accepted by {@link renderChartRoute}. */
40
+ export interface RenderChartRouteOptions {
41
+ path: string;
42
+ config: MastraPluginConfig;
43
+ }
44
+
45
+ /**
46
+ * Register a `POST <path>` Mastra custom API route that runs the
47
+ * chart-planner agent against a dataset and returns an Echarts
48
+ * `EChartsOption` JSON.
49
+ *
50
+ * Body shape: {@link RenderChartRequest}; response:
51
+ * {@link RenderChartResponse}.
52
+ */
53
+ export function renderChartRoute(options: RenderChartRouteOptions) {
54
+ const { path, config } = options;
55
+ return registerApiRoute(path, {
56
+ method: "POST",
57
+ handler: async (c) => {
58
+ const requestContext = c.get("requestContext");
59
+
60
+ // Hono parses the body as JSON; we still validate shape /
61
+ // size since the tool's structured output is a contract,
62
+ // not a guarantee, and the route is publicly mountable.
63
+ const raw = (await c.req.json().catch(() => null)) as unknown;
64
+ const validation = validateBody(raw);
65
+ if ("error" in validation) {
66
+ return c.json({ error: validation.error }, 400);
67
+ }
68
+ const { title, description, data } = validation.body;
69
+
70
+ try {
71
+ const result = await runChartPlanner({
72
+ config,
73
+ ...(requestContext ? { requestContext } : {}),
74
+ title,
75
+ ...(description ? { description } : {}),
76
+ data,
77
+ });
78
+ const payload: RenderChartResponse = {
79
+ option: result.option,
80
+ chartType: result.chartType,
81
+ };
82
+ return c.json(payload);
83
+ } catch (err) {
84
+ const message = err instanceof Error ? err.message : String(err);
85
+ return c.json({ error: message }, 500);
86
+ }
87
+ },
88
+ });
89
+ }
90
+
91
+ type ValidationResult =
92
+ | { body: RenderChartRequest }
93
+ | { error: string };
94
+
95
+ /**
96
+ * Best-effort body validation. Surfaces a 400 for malformed input
97
+ * instead of letting a downstream `.map` / `.length` blow up
98
+ * inside the planner agent. Field-level shape mirrors
99
+ * {@link RenderChartRequest}.
100
+ */
101
+ function validateBody(raw: unknown): ValidationResult {
102
+ if (!raw || typeof raw !== "object") {
103
+ return { error: "request body must be a JSON object" };
104
+ }
105
+ const r = raw as Record<string, unknown>;
106
+ const title = r.title;
107
+ if (typeof title !== "string" || title.length === 0) {
108
+ return { error: "`title` must be a non-empty string" };
109
+ }
110
+ if (r.description !== undefined && typeof r.description !== "string") {
111
+ return { error: "`description` must be a string when provided" };
112
+ }
113
+ if (!Array.isArray(r.data)) {
114
+ return { error: "`data` must be an array of row objects" };
115
+ }
116
+ if (r.data.length === 0) {
117
+ return { error: "`data` must contain at least one row" };
118
+ }
119
+ if (r.data.length > MAX_ROWS) {
120
+ return { error: `\`data\` exceeds the per-request limit of ${MAX_ROWS} rows` };
121
+ }
122
+ for (const [i, row] of r.data.entries()) {
123
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
124
+ return { error: `data[${i}] must be a plain object` };
125
+ }
126
+ }
127
+ // Approximate body-size check; spares us pulling Buffer in.
128
+ const approximateBytes = JSON.stringify(r.data).length;
129
+ if (approximateBytes > MAX_BODY_BYTES) {
130
+ return {
131
+ error: `\`data\` exceeds the per-request size limit of ${MAX_BODY_BYTES} bytes`,
132
+ };
133
+ }
134
+ return {
135
+ body: {
136
+ title,
137
+ ...(typeof r.description === "string" ? { description: r.description } : {}),
138
+ data: r.data as Array<Record<string, unknown>>,
139
+ },
140
+ };
141
+ }
package/src/server.ts CHANGED
@@ -42,61 +42,75 @@ export class MastraServer extends MastraServerExpress {
42
42
  override registerAuthMiddleware(): void {
43
43
  super.registerAuthMiddleware();
44
44
  this.app.use((req, res, next) => {
45
- const executionContext = getExecutionContext();
46
- const user: User = {
47
- id:
48
- "userId" in executionContext
49
- ? executionContext.userId
50
- : executionContext.serviceUserId,
51
- executionContext,
52
- };
53
45
  const requestContext = res.locals.requestContext! as RequestContext;
54
- requestContext.set(MASTRA_USER_KEY, user);
55
- if (!requestContext.get(MASTRA_RESOURCE_ID_KEY)) {
56
- this.log.debug(`Setting resource id: ${user.id}`);
57
- requestContext.set(MASTRA_RESOURCE_ID_KEY, user.id);
58
- }
59
- const cookies = httpUtils.parseCookies(req.headers.cookie);
60
- const cookieName = stringUtils.toIdentifierWithOptions(
61
- { delimiter: "_", distinct: true },
62
- "appkit",
63
- this.config.name!,
64
- "sessionId",
65
- );
66
- let sessionId = cookies[cookieName];
67
- if (!sessionId) {
68
- sessionId = randomUUID();
69
- res.cookie(cookieName, sessionId, {
70
- httpOnly: true,
71
- sameSite: "lax",
72
- secure: req.secure,
73
- path: "/",
74
- });
75
- }
76
- res.locals.sessionId = sessionId;
77
- if (!requestContext.get(MASTRA_THREAD_ID_KEY)) {
78
- this.log.debug(`Setting thread id: ${sessionId}`);
79
- requestContext.set(MASTRA_THREAD_ID_KEY, sessionId);
80
- }
81
- // Per-request model override: only honored when the plugin
82
- // opts in (default). Sources, in priority order, are
83
- // `X-Mastra-Model` header, `?model=` query, and `model` /
84
- // `modelId` body field; see `serving.ts`.
85
- const serving = resolveServingConfig(this.config);
86
- if (serving.allowOverride) {
87
- const override = extractModelOverride({
88
- headers: req.headers as Record<string, string | string[] | undefined>,
89
- query: req.query as Record<string, unknown>,
90
- body: req.body,
91
- });
92
- if (override) {
93
- this.log.debug(`Model override: ${override}`);
94
- requestContext.set(MASTRA_MODEL_OVERRIDE_KEY, override);
95
- }
96
- }
46
+ this.configureRequestContextUser(requestContext);
47
+ this.configureRequestContextThreadId(req, res, requestContext);
48
+ this.configureRequestContextModelOverride(req, requestContext);
97
49
  next();
98
50
  });
99
51
  }
52
+
53
+ configureRequestContextUser(requestContext: RequestContext) {
54
+ if (
55
+ [MASTRA_USER_KEY, MASTRA_RESOURCE_ID_KEY].every((key) => requestContext.get(key))
56
+ )
57
+ return;
58
+ const executionContext = getExecutionContext();
59
+ const user: User = {
60
+ id:
61
+ "userId" in executionContext
62
+ ? executionContext.userId
63
+ : executionContext.serviceUserId,
64
+ executionContext,
65
+ };
66
+ requestContext.set(MASTRA_USER_KEY, user);
67
+ requestContext.set(MASTRA_RESOURCE_ID_KEY, user.id);
68
+ }
69
+
70
+ configureRequestContextThreadId(
71
+ req: express.Request,
72
+ res: express.Response,
73
+ requestContext: RequestContext,
74
+ ) {
75
+ if (requestContext.get(MASTRA_THREAD_ID_KEY)) return;
76
+ const cookies = httpUtils.parseCookies(req.headers.cookie);
77
+ const cookieName = stringUtils.toIdentifierWithOptions(
78
+ { delimiter: "_", distinct: true },
79
+ "appkit",
80
+ this.config.name!,
81
+ "sessionId",
82
+ );
83
+ let sessionId = cookies[cookieName];
84
+ if (!sessionId) {
85
+ sessionId = randomUUID();
86
+ res.cookie(cookieName, sessionId, {
87
+ httpOnly: true,
88
+ sameSite: "lax",
89
+ secure: req.secure,
90
+ path: "/",
91
+ });
92
+ }
93
+ requestContext.set(MASTRA_THREAD_ID_KEY, sessionId);
94
+ }
95
+
96
+ configureRequestContextModelOverride(
97
+ req: express.Request,
98
+ requestContext: RequestContext,
99
+ ) {
100
+ // Per-request model override: only honored when the plugin
101
+ // opts in (default). Sources, in priority order, are
102
+ // `X-Mastra-Model` header, `?model=` query, and `model` /
103
+ // `modelId` body field; see `serving.ts`.
104
+ const serving = resolveServingConfig(this.config);
105
+ if (serving.allowOverride) {
106
+ const override = extractModelOverride({
107
+ headers: req.headers as Record<string, string | string[] | undefined>,
108
+ query: req.query as Record<string, unknown>,
109
+ body: req.body,
110
+ });
111
+ if (override) requestContext.set(MASTRA_MODEL_OVERRIDE_KEY, override);
112
+ }
113
+ }
100
114
  }
101
115
 
102
116
  /**