@dbx-tools/appkit-mastra 0.1.4 → 0.1.12

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,210 @@
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 { logUtils } from "@dbx-tools/appkit-shared";
20
+ import { toAISdkV5Messages } from "@mastra/ai-sdk/ui";
21
+ import type { Agent } from "@mastra/core/agent";
22
+ import type { MastraDBMessage } from "@mastra/core/agent/message-list";
23
+ import {
24
+ MASTRA_RESOURCE_ID_KEY,
25
+ MASTRA_THREAD_ID_KEY,
26
+ } from "@mastra/core/request-context";
27
+ import { registerApiRoute } from "@mastra/core/server";
28
+ import type { ContextWithMastra } from "@mastra/core/server";
29
+ import type {
30
+ MastraHistoryResponse,
31
+ MastraHistoryUIMessage,
32
+ } from "@dbx-tools/appkit-mastra-shared";
33
+
34
+ const log = logUtils.logger("mastra/history");
35
+
36
+ /** Default history page size; matches the Mastra storage default. */
37
+ const DEFAULT_PER_PAGE = 20;
38
+ /** Hard cap so a misbehaving client can't fetch the whole thread at once. */
39
+ const MAX_PER_PAGE = 200;
40
+
41
+ /** Inputs accepted by {@link loadHistory}. */
42
+ export interface LoadHistoryOptions {
43
+ agent: Agent;
44
+ threadId: string;
45
+ resourceId?: string;
46
+ page?: number;
47
+ perPage?: number;
48
+ /** When true, returns the *oldest* page first (chronological). */
49
+ ascending?: boolean;
50
+ }
51
+
52
+ /**
53
+ * Fetch a page of UI-formatted messages for a thread.
54
+ *
55
+ * Uses the agent's resolved `Memory` (`getMemory()`) so per-agent
56
+ * storage namespaces (`mastra_<agentId>` schemas) and any future
57
+ * memory-side filters apply automatically. When the agent has no
58
+ * memory configured the response is a successful empty page so
59
+ * callers don't have to special-case stateless agents.
60
+ *
61
+ * Pagination is descending-by-default: page 0 is the most recent
62
+ * page, page 1 the page before that, etc. The returned `uiMessages`
63
+ * are always re-sorted into chronological order (oldest -> newest)
64
+ * so the client can prepend them above the existing transcript
65
+ * without sorting locally.
66
+ */
67
+ export async function loadHistory(
68
+ opts: LoadHistoryOptions,
69
+ ): Promise<MastraHistoryResponse> {
70
+ const perPage = clampPerPage(opts.perPage);
71
+ const page = Math.max(0, Math.trunc(opts.page ?? 0));
72
+ const memory = await opts.agent.getMemory();
73
+ if (!memory) {
74
+ log.debug("recall:no-memory", { agentId: opts.agent.id, threadId: opts.threadId });
75
+ return { uiMessages: [], page, perPage, total: 0, hasMore: false };
76
+ }
77
+ const startedAt = Date.now();
78
+ const result = await memory.recall({
79
+ threadId: opts.threadId,
80
+ ...(opts.resourceId ? { resourceId: opts.resourceId } : {}),
81
+ page,
82
+ perPage,
83
+ orderBy: {
84
+ field: "createdAt",
85
+ direction: opts.ascending ? "ASC" : "DESC",
86
+ },
87
+ });
88
+ const chronological = sortChronological(result.messages);
89
+ const uiMessages = toAISdkV5Messages(
90
+ chronological,
91
+ ) as unknown as MastraHistoryUIMessage[];
92
+ log.debug("recall:done", {
93
+ agentId: opts.agent.id,
94
+ threadId: opts.threadId,
95
+ page,
96
+ perPage,
97
+ returned: uiMessages.length,
98
+ total: result.total,
99
+ hasMore: result.hasMore,
100
+ elapsedMs: Date.now() - startedAt,
101
+ });
102
+ return {
103
+ uiMessages,
104
+ page,
105
+ perPage,
106
+ total: result.total,
107
+ hasMore: result.hasMore,
108
+ };
109
+ }
110
+
111
+ /** Options accepted by {@link historyRoute}. */
112
+ export type HistoryRouteOptions =
113
+ | { path: `${string}:agentId${string}`; agent?: never }
114
+ | { path: string; agent: string };
115
+
116
+ /**
117
+ * Register a `GET <path>` Mastra custom API route that returns a page
118
+ * of AI SDK V5 `UIMessage`s for the caller's current thread.
119
+ *
120
+ * Modeled after `chatRoute` from `@mastra/ai-sdk`: pass `agent` for a
121
+ * fixed-agent mount, or include `:agentId` in the path for dynamic
122
+ * routing. Pairs cleanly with the AppKit Mastra plugin's chat route
123
+ * layout (`/route/chat` + `/route/chat/:agentId`).
124
+ *
125
+ * The handler reads `threadId` and `resourceId` from `RequestContext`
126
+ * (populated upstream by `MastraServer.registerAuthMiddleware`), so
127
+ * no cookie or user lookups happen here.
128
+ */
129
+ export function historyRoute(options: HistoryRouteOptions) {
130
+ const { path } = options;
131
+ const fixedAgent = "agent" in options ? options.agent : undefined;
132
+ if (!fixedAgent && !path.includes(":agentId")) {
133
+ throw new Error(
134
+ "historyRoute path must include `:agentId` or `agent` must be passed explicitly",
135
+ );
136
+ }
137
+ return registerApiRoute(path, {
138
+ method: "GET",
139
+ handler: async (c: ContextWithMastra) => {
140
+ const mastra = c.get("mastra");
141
+ const requestContext = c.get("requestContext");
142
+ const agentId = fixedAgent ?? c.req.param("agentId");
143
+ if (!agentId) {
144
+ return c.json({ error: "agentId is required" }, 400);
145
+ }
146
+ const agent = mastra.getAgentById(agentId);
147
+ if (!agent) {
148
+ return c.json({ error: `Unknown agent "${agentId}"` }, 404);
149
+ }
150
+ const threadId = requestContext.get(MASTRA_THREAD_ID_KEY) as string | undefined;
151
+ if (!threadId) {
152
+ return c.json({ error: "thread id missing from request context" }, 400);
153
+ }
154
+ const resourceId = requestContext.get(MASTRA_RESOURCE_ID_KEY) as
155
+ | string
156
+ | undefined;
157
+ const payload = await loadHistory({
158
+ agent,
159
+ threadId,
160
+ ...(resourceId ? { resourceId } : {}),
161
+ page: parseIntParam(c.req.query("page")),
162
+ perPage: parseIntParam(c.req.query("perPage")),
163
+ });
164
+ return c.json(payload);
165
+ },
166
+ });
167
+ }
168
+
169
+ /** Coerce / clamp `perPage`; falls back to the page-size default. */
170
+ function clampPerPage(value: number | undefined): number {
171
+ if (value === undefined || Number.isNaN(value)) return DEFAULT_PER_PAGE;
172
+ const n = Math.trunc(value);
173
+ if (n <= 0) return DEFAULT_PER_PAGE;
174
+ return Math.min(n, MAX_PER_PAGE);
175
+ }
176
+
177
+ /**
178
+ * Sort messages oldest-first by `createdAt`, falling back to whatever
179
+ * order the storage returned them in. The native `recall` call honors
180
+ * `orderBy` but doesn't guarantee a stable secondary sort, so we
181
+ * normalize here before handing the page to the AI SDK converter.
182
+ */
183
+ function sortChronological(messages: MastraDBMessage[]): MastraDBMessage[] {
184
+ return [...messages].sort((a, b) => {
185
+ const ta = toEpoch(a.createdAt);
186
+ const tb = toEpoch(b.createdAt);
187
+ return ta - tb;
188
+ });
189
+ }
190
+
191
+ function toEpoch(value: unknown): number {
192
+ if (value instanceof Date) return value.getTime();
193
+ if (typeof value === "string" || typeof value === "number") {
194
+ const parsed = new Date(value).getTime();
195
+ return Number.isNaN(parsed) ? 0 : parsed;
196
+ }
197
+ return 0;
198
+ }
199
+
200
+ /**
201
+ * Coerce a Hono query value into a non-negative integer. Returns
202
+ * `undefined` for empty / non-numeric / negative inputs so
203
+ * {@link loadHistory} can apply its built-in defaults.
204
+ */
205
+ function parseIntParam(value: string | undefined): number | undefined {
206
+ if (!value) return undefined;
207
+ const n = Number(value);
208
+ if (!Number.isFinite(n) || n < 0) return undefined;
209
+ return Math.trunc(n);
210
+ }
package/src/memory.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import { lakebase } from "@databricks/appkit";
23
- import { pluginUtils } from "@dbx-tools/appkit-shared";
23
+ import { logUtils, pluginUtils } from "@dbx-tools/appkit-shared";
24
24
  import { fastembed } from "@mastra/fastembed";
25
25
  import { Memory } from "@mastra/memory";
26
26
  import { PgVector, PostgresStore } from "@mastra/pg";
@@ -33,6 +33,8 @@ import type {
33
33
  } from "./agents.js";
34
34
  import type { MastraPluginConfig } from "./config.js";
35
35
 
36
+ const log = logUtils.logger("mastra/memory");
37
+
36
38
  /** Pool handle returned by the AppKit `lakebase` plugin `exports().pool`. */
37
39
  export type LakebasePool = ReturnType<
38
40
  InstanceType<ReturnType<typeof lakebase>["plugin"]>["exports"]
@@ -109,7 +111,22 @@ export class MemoryBuilder {
109
111
 
110
112
  const storage = this.buildStorage(agentId, storageSetting);
111
113
  const vector = this.buildVector(memorySetting);
112
- if (!storage && !vector) return undefined;
114
+ if (!storage && !vector) {
115
+ log.debug("agent:stateless", { agentId });
116
+ return undefined;
117
+ }
118
+
119
+ log.debug("agent:configured", {
120
+ agentId,
121
+ storage: storage !== undefined,
122
+ vector: vector !== undefined,
123
+ vectorMode:
124
+ vector === undefined
125
+ ? "off"
126
+ : typeof memorySetting === "object"
127
+ ? "dedicated"
128
+ : "shared",
129
+ });
113
130
 
114
131
  return new Memory({
115
132
  ...(storage ? { storage } : {}),
package/src/model.ts CHANGED
@@ -306,6 +306,7 @@ async function pickModelId(
306
306
  user: User,
307
307
  host: string,
308
308
  ): Promise<string> {
309
+ const log = logUtils.logger(config);
309
310
  const serving = resolveServingConfig(config, FALLBACK_MODEL_IDS);
310
311
  const override = serving.allowOverride
311
312
  ? (requestContext.get(MASTRA_MODEL_OVERRIDE_KEY) as string | undefined)
@@ -315,7 +316,10 @@ async function pickModelId(
315
316
 
316
317
  // Cheap exit: when the caller named a specific model and fuzzy
317
318
  // matching is off, there's no reason to touch the catalogue at all.
318
- if (explicit !== undefined && !serving.fuzzy) return explicit;
319
+ if (explicit !== undefined && !serving.fuzzy) {
320
+ log.debug("model selected", { modelId: explicit, source: "explicit" });
321
+ return explicit;
322
+ }
319
323
 
320
324
  const endpoints = await listServingEndpoints(user.executionContext.client, host, {
321
325
  ttlMs: serving.ttlMs,
@@ -324,7 +328,11 @@ async function pickModelId(
324
328
  explicit !== undefined
325
329
  ? resolveModelId(explicit, endpoints, { threshold: serving.threshold }).modelId
326
330
  : pickFirstAvailable(serving.fallbacks, endpoints);
327
- //logUtils.logger(config).debug(`model selected: ${modelId}`);
331
+ log.debug("model selected", {
332
+ modelId,
333
+ source: explicit !== undefined ? "fuzzy-match" : "fallback",
334
+ requestedExplicit: explicit,
335
+ });
328
336
  return modelId;
329
337
  }
330
338
 
@@ -369,9 +377,9 @@ interface ChatMessage {
369
377
  * 1. Rewrites the outgoing `messages` array to repair Mastra/AI SDK
370
378
  * stream-replay quirks that Databricks-hosted Claude rejects (see
371
379
  * {@link sanitizeServingMessages}).
372
- * 2. When `MASTRA_DEBUG_LLM=1`, dumps the (post-sanitize) JSON body
373
- * to stderr so 4xx debugging doesn't have to fight AI SDK's
374
- * `[Array]` formatter.
380
+ * 2. At `LOG_LEVEL=debug`, dumps the (post-sanitize) JSON body so
381
+ * 4xx debugging doesn't have to fight AI SDK's `[Array]`
382
+ * formatter.
375
383
  *
376
384
  * Safe to call from any hot path: {@link commonUtils.memoize} ensures
377
385
  * the wrapper is installed at most once per process, so subsequent
@@ -379,7 +387,7 @@ interface ChatMessage {
379
387
  * {@link buildModel} fires on every agent step.
380
388
  */
381
389
  const setupFetchInterceptor = commonUtils.memoize((): void => {
382
- const debug = Boolean(process.env.MASTRA_DEBUG_LLM);
390
+ const log = logUtils.logger("mastra/llm");
383
391
  const original = globalThis.fetch.bind(globalThis);
384
392
  globalThis.fetch = (async (input, init) => {
385
393
  const url = httpUtils.toURL(input);
@@ -394,13 +402,10 @@ const setupFetchInterceptor = commonUtils.memoize((): void => {
394
402
  if (rewritten !== init.body) {
395
403
  init = { ...init, body: rewritten };
396
404
  }
397
- if (debug) {
398
- try {
399
- console.error("[mastra:llm-debug] -> POST", url.toString());
400
- console.error(JSON.stringify(JSON.parse(rewritten), null, 2));
401
- } catch {
402
- console.error("[mastra:llm-debug] -> POST", url.toString(), "(non-JSON body)");
403
- }
405
+ try {
406
+ log.debug("POST", { url: url.toString(), body: JSON.parse(rewritten) });
407
+ } catch {
408
+ log.debug("POST", { url: url.toString(), bodyType: "non-JSON" });
404
409
  }
405
410
  return original(input, init);
406
411
  }) as typeof globalThis.fetch;
package/src/plugin.ts CHANGED
@@ -46,6 +46,7 @@ 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";
49
50
  import { createMemoryBuilder, needsLakebase } from "./memory.js";
50
51
  import { attachRoutePatchMiddleware, MastraServer } from "./server.js";
51
52
  import {
@@ -187,6 +188,8 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
187
188
  chatPath: `${basePath}/route/chat`,
188
189
  chatPathTemplate: `${basePath}/route/chat/:agentId`,
189
190
  modelsPath: `${basePath}/models`,
191
+ historyPath: `${basePath}/route/history`,
192
+ historyPathTemplate: `${basePath}/route/history/:agentId`,
190
193
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
191
194
  agents: Object.keys(this.built?.agents ?? {}),
192
195
  };
@@ -200,7 +203,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
200
203
  // the Mastra subapp. Errors propagate to Express's default error
201
204
  // handler via `next(err)` so callers see the real SDK message.
202
205
  router.get("/models", (req, res, next) => {
203
- this.asUser(req)
206
+ this.userScopedSelf(req)
204
207
  .listModels()
205
208
  .then((endpoints) => res.json({ endpoints }))
206
209
  .catch(next);
@@ -208,10 +211,23 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
208
211
 
209
212
  router.use("", (req, res, next) => {
210
213
  if (!this.mastraApp) return res.status(503).end();
211
- return this.asUser(req).mastraApp!(req, res, next);
214
+ return this.userScopedSelf(req).mastraApp!(req, res, next);
212
215
  });
213
216
  }
214
217
 
218
+ /**
219
+ * Return `this.asUser(req)` when the request carries an OBO token,
220
+ * otherwise return `this` directly. Prevents the noisy AppKit warn
221
+ * (`asUser() called without user token in development mode. Skipping
222
+ * user impersonation.`) on every request in local dev where the
223
+ * browser never sends `x-forwarded-access-token`. Behavior is
224
+ * unchanged in production: a missing token always means a real OBO
225
+ * proxy call (and AppKit will throw upstream if that's wrong).
226
+ */
227
+ private userScopedSelf(req: express.Request): this {
228
+ return req.header("x-forwarded-access-token") ? (this.asUser(req) as this) : this;
229
+ }
230
+
215
231
  /**
216
232
  * Implementation backing both the `/models` route and the
217
233
  * `listModels` export. Runs inside the AppKit user-context proxy so
@@ -233,6 +249,11 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
233
249
  ? createMemoryBuilder(this.config, this.context)
234
250
  : undefined;
235
251
 
252
+ this.log.debug("build:start", {
253
+ lakebase: memoryBuilder !== undefined,
254
+ stripStaleCharts: this.config.stripStaleCharts !== false,
255
+ });
256
+
236
257
  // Build every agent declared in `config.agents` (or the built-in
237
258
  // fallback when none are declared). Each agent's `model` resolves
238
259
  // workspace URL + bearer at call time so concurrent requests get
@@ -260,9 +281,16 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
260
281
  customApiRoutes: [
261
282
  chatRoute({ path: "/route/chat", agent: this.built.defaultAgentId }),
262
283
  chatRoute({ path: "/route/chat/:agentId" }),
284
+ historyRoute({ path: "/route/history", agent: this.built.defaultAgentId }),
285
+ historyRoute({ path: "/route/history/:agentId" }),
263
286
  ],
264
287
  });
265
288
  await this.mastraServer.init();
289
+ this.log.debug("build:done", {
290
+ agents: Object.keys(this.built.agents),
291
+ defaultAgent: this.built.defaultAgentId,
292
+ routes: ["/route/chat", "/route/history", "/models"],
293
+ });
266
294
  }
267
295
  }
268
296
 
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Mastra input processor that strips `chartId` fields from every
3
+ * tool-invocation result in prior assistant messages before they
4
+ * reach the model.
5
+ *
6
+ * Why: chartIds are only meaningful within the assistant turn that
7
+ * minted them - the writer events backing them are gone after the
8
+ * stream closes. When the model sees old chartIds in memory recall
9
+ * (Mastra Memory persists tool results), it's tempted to type
10
+ * those ids into the new turn's `[[chart:<id>]]` markers, leaving
11
+ * the chat client's chart slots stuck with no matching event. This
12
+ * processor removes the temptation by deleting `chartId` keys from
13
+ * every assistant message's tool results before the prompt is
14
+ * built. The current turn's tool results don't exist yet at
15
+ * `processInput` time, so they pass through unmodified.
16
+ *
17
+ * The strip is recursive - any nested `chartId` field is removed,
18
+ * regardless of which tool produced the result. This covers Genie's
19
+ * `datasets[].chartId` and `render_data`'s top-level `chartId`
20
+ * uniformly without coupling to specific tool ids.
21
+ */
22
+
23
+ import { logUtils } from "@dbx-tools/appkit-shared";
24
+ import type {
25
+ InputProcessor,
26
+ ProcessInputArgs,
27
+ } from "@mastra/core/processors";
28
+
29
+ const log = logUtils.logger("mastra/processor/strip-stale-charts");
30
+
31
+ /**
32
+ * Recursively clone `value`, omitting any property whose key is
33
+ * `chartId`. Arrays are mapped element-wise; primitives are
34
+ * returned as-is. The result is structurally identical to the
35
+ * input minus chartIds, so downstream message-shape consumers
36
+ * keep working.
37
+ */
38
+ function stripChartIds(value: unknown): unknown {
39
+ if (Array.isArray(value)) {
40
+ return value.map(stripChartIds);
41
+ }
42
+ if (value && typeof value === "object") {
43
+ const obj = value as Record<string, unknown>;
44
+ const out: Record<string, unknown> = {};
45
+ for (const [key, val] of Object.entries(obj)) {
46
+ if (key === "chartId") continue;
47
+ out[key] = stripChartIds(val);
48
+ }
49
+ return out;
50
+ }
51
+ return value;
52
+ }
53
+
54
+ /**
55
+ * Input processor that scrubs `chartId` from every tool-invocation
56
+ * result in the message list. Wired onto every agent by default
57
+ * via {@link buildAgents}; opt out with
58
+ * `MastraPluginConfig.stripStaleCharts: false`.
59
+ */
60
+ export const stripStaleChartsProcessor: InputProcessor = {
61
+ id: "strip-stale-charts",
62
+ description:
63
+ "Removes chartId fields from prior tool-invocation results so the model can't reuse turn-scoped ids from memory.",
64
+ processInput(args: ProcessInputArgs) {
65
+ let stripped = 0;
66
+ for (const message of args.messages) {
67
+ if (message.role !== "assistant") continue;
68
+ const parts = message.content?.parts;
69
+ if (!Array.isArray(parts)) continue;
70
+ for (const part of parts) {
71
+ // Tool-invocation parts hold the persisted tool result.
72
+ // We don't scrub the input args (`rawInput` / `args`) because
73
+ // the chartId there is the model's outgoing claim, not
74
+ // anything it could re-reference; only `result` carries
75
+ // ids that subsequent turns might copy.
76
+ if (
77
+ (part as { type?: unknown }).type !== "tool-invocation"
78
+ ) {
79
+ continue;
80
+ }
81
+ const inv = (part as { toolInvocation?: { result?: unknown } })
82
+ .toolInvocation;
83
+ if (!inv || inv.result === undefined) continue;
84
+ const before = inv.result;
85
+ const after = stripChartIds(before);
86
+ // Cheap structural check via JSON length - the actual
87
+ // strip writes a fresh object only when chartId keys
88
+ // existed, so different stringification length is a
89
+ // reliable signal that something was removed.
90
+ if (
91
+ typeof before === "object" &&
92
+ before !== null &&
93
+ JSON.stringify(before).length !== JSON.stringify(after).length
94
+ ) {
95
+ inv.result = after;
96
+ stripped += 1;
97
+ }
98
+ }
99
+ }
100
+ if (stripped > 0) {
101
+ log.debug("stripped", { results: stripped });
102
+ }
103
+ return args.messages;
104
+ },
105
+ };
package/src/server.ts CHANGED
@@ -42,61 +42,86 @@ 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);
49
+ this.log.debug("auth:middleware", {
50
+ method: req.method,
51
+ path: req.path,
52
+ threadId: requestContext.get(MASTRA_THREAD_ID_KEY),
53
+ resourceId: requestContext.get(MASTRA_RESOURCE_ID_KEY),
54
+ modelOverride: requestContext.get(
55
+ // imported below; logged so a misrouted request shows
56
+ // up alongside its model selection in `LOG_LEVEL=debug`.
57
+ "mastra__model_override",
58
+ ),
59
+ });
97
60
  next();
98
61
  });
99
62
  }
63
+
64
+ configureRequestContextUser(requestContext: RequestContext) {
65
+ if (
66
+ [MASTRA_USER_KEY, MASTRA_RESOURCE_ID_KEY].every((key) => requestContext.get(key))
67
+ )
68
+ return;
69
+ const executionContext = getExecutionContext();
70
+ const user: User = {
71
+ id:
72
+ "userId" in executionContext
73
+ ? executionContext.userId
74
+ : executionContext.serviceUserId,
75
+ executionContext,
76
+ };
77
+ requestContext.set(MASTRA_USER_KEY, user);
78
+ requestContext.set(MASTRA_RESOURCE_ID_KEY, user.id);
79
+ }
80
+
81
+ configureRequestContextThreadId(
82
+ req: express.Request,
83
+ res: express.Response,
84
+ requestContext: RequestContext,
85
+ ) {
86
+ if (requestContext.get(MASTRA_THREAD_ID_KEY)) return;
87
+ const cookies = httpUtils.parseCookies(req.headers.cookie);
88
+ const cookieName = stringUtils.toIdentifierWithOptions(
89
+ { delimiter: "_", distinct: true },
90
+ "appkit",
91
+ this.config.name!,
92
+ "sessionId",
93
+ );
94
+ let sessionId = cookies[cookieName];
95
+ if (!sessionId) {
96
+ sessionId = randomUUID();
97
+ res.cookie(cookieName, sessionId, {
98
+ httpOnly: true,
99
+ sameSite: "lax",
100
+ secure: req.secure,
101
+ path: "/",
102
+ });
103
+ }
104
+ requestContext.set(MASTRA_THREAD_ID_KEY, sessionId);
105
+ }
106
+
107
+ configureRequestContextModelOverride(
108
+ req: express.Request,
109
+ requestContext: RequestContext,
110
+ ) {
111
+ // Per-request model override: only honored when the plugin
112
+ // opts in (default). Sources, in priority order, are
113
+ // `X-Mastra-Model` header, `?model=` query, and `model` /
114
+ // `modelId` body field; see `serving.ts`.
115
+ const serving = resolveServingConfig(this.config);
116
+ if (serving.allowOverride) {
117
+ const override = extractModelOverride({
118
+ headers: req.headers as Record<string, string | string[] | undefined>,
119
+ query: req.query as Record<string, unknown>,
120
+ body: req.body,
121
+ });
122
+ if (override) requestContext.set(MASTRA_MODEL_OVERRIDE_KEY, override);
123
+ }
124
+ }
100
125
  }
101
126
 
102
127
  /**