@databricks/appkit 0.35.0 → 0.35.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.
@@ -1,8 +1,9 @@
1
1
  import { createLogger } from "../../logging/logger.js";
2
2
  import { AuthenticationError } from "../../errors/authentication.js";
3
3
  import { init_errors } from "../../errors/index.js";
4
+ import { ServiceContext } from "../../context/service-context.js";
4
5
  import { init_user_context, isUserContext } from "../../context/user-context.js";
5
- import { getCurrentUserId, getExecutionContext, getWorkspaceClient } from "../../context/execution-context.js";
6
+ import { getCurrentUserId, getExecutionContext, getWorkspaceClient, runInUserContext } from "../../context/execution-context.js";
6
7
  import { init_context } from "../../context/index.js";
7
8
  import { Plugin } from "../../plugin/plugin.js";
8
9
  import { ResourceType } from "../../registry/types.generated.js";
@@ -10,8 +11,8 @@ import "../../registry/index.js";
10
11
  import { toPlugin } from "../../plugin/to-plugin.js";
11
12
  import "../../plugin/index.js";
12
13
  import { PolicyDeniedError, policy } from "./policy.js";
13
- import { contentTypeFromPath, isSafeInlineContentType, validateCustomContentTypes } from "../../connectors/files/defaults.js";
14
- import { FilesConnector } from "../../connectors/files/client.js";
14
+ import { FILES_MAX_READ_SIZE, contentTypeFromPath, isSafeInlineContentType, validateCustomContentTypes } from "../../connectors/files/defaults.js";
15
+ import { FilesConnector, runWithFilesSpanAttributes } from "../../connectors/files/client.js";
15
16
  import "../../connectors/files/index.js";
16
17
  import { buildToolkitEntries } from "../../core/agent/build-toolkit.js";
17
18
  import { defineTool, executeFromRegistry, toolsFromRegistry } from "../../core/agent/tools/define-tool.js";
@@ -62,6 +63,27 @@ var FilesPlugin = class FilesPlugin extends Plugin {
62
63
  /**
63
64
  * Generates resource requirements dynamically from discovered + configured volumes.
64
65
  * Each volume key maps to a `DATABRICKS_VOLUME_{KEY_UPPERCASE}` env var.
66
+ *
67
+ * ## Per-volume permission scope (SP vs OBO)
68
+ *
69
+ * The returned manifest entries describe a single permission grant per
70
+ * volume, but the *grantee* depends on the volume's `auth` setting at
71
+ * runtime — and that distinction is **not** expressed in the manifest
72
+ * today:
73
+ *
74
+ * - **Service-principal volumes** (the default, `auth: "service-principal"`):
75
+ * the app's service principal needs `WRITE_VOLUME` (or read-equivalent)
76
+ * on the UC volume. This matches the manifest entry as written.
77
+ * - **On-behalf-of-user volumes** (`auth: "on-behalf-of-user"`): SDK calls
78
+ * execute as the **end user**, so the *user* — not the SP — must hold
79
+ * `WRITE_VOLUME` (or read-equivalent) on the UC volume. The SP only
80
+ * needs to be allowed to mint user-token requests; it does not need
81
+ * direct volume permissions.
82
+ *
83
+ * The static manifest cannot currently express this per-volume split, so
84
+ * callers configuring OBO volumes must communicate the per-user permission
85
+ * requirement out-of-band (docs, runbooks, deployment scripts) until the
86
+ * manifest schema gains a per-volume auth scope field.
65
87
  */
66
88
  static getResourceRequirements(config) {
67
89
  const volumes = FilesPlugin.discoverVolumes(config);
@@ -79,17 +101,65 @@ var FilesPlugin = class FilesPlugin extends Plugin {
79
101
  }));
80
102
  }
81
103
  /**
82
- * Extract user identity from the request.
83
- * Falls back to `getCurrentUserId()` in development mode.
104
+ * Extraction for `VolumeHandle.asUser(req)`. In production we require BOTH
105
+ * `x-forwarded-user` and `x-forwarded-access-token`, and throw
106
+ * `AuthenticationError.missingToken` if either is missing — otherwise a
107
+ * request with only `x-forwarded-user: alice` would let policies see Alice
108
+ * as a "real user" (`isServicePrincipal: false`) while the SDK call below
109
+ * falls through to the SP client because `_buildUserContextOrNull` returns
110
+ * `null`. Net effect: policy approves the user, SDK runs as SP, privilege
111
+ * confusion (CWE-639/863).
112
+ *
113
+ * In development (`NODE_ENV === "development"`) we keep a local-loop
114
+ * convenience: if either header is missing we emit a single warning and
115
+ * return a policy user explicitly marked `isServicePrincipal: true`, so
116
+ * even in dev a `usersOnly`-style policy that gates on
117
+ * `!user.isServicePrincipal` cannot be tricked. The matching SDK execution
118
+ * path also falls through to the SP client (no `runInUserContext` wrap),
119
+ * so the policy user and the SDK identity stay aligned.
84
120
  */
85
121
  _extractUser(req) {
86
122
  const userId = req.header("x-forwarded-user")?.trim();
87
- if (userId) return { id: userId };
123
+ const token = req.header("x-forwarded-access-token")?.trim();
124
+ if (userId && token) return {
125
+ id: userId,
126
+ isServicePrincipal: false
127
+ };
88
128
  if (process.env.NODE_ENV === "development") {
89
- logger.warn("No x-forwarded-user header — falling back to service principal identity for policy checks. Ensure your proxy forwards user headers to test per-user policies.");
90
- return { id: getCurrentUserId() };
129
+ logger.warn("asUser(req) called without complete x-forwarded-user + x-forwarded-access-token headers — falling back to service principal identity (dev mode). In production this request would 401.");
130
+ return {
131
+ id: getCurrentUserId(),
132
+ isServicePrincipal: true
133
+ };
134
+ }
135
+ if (!token) throw AuthenticationError.missingToken("Missing x-forwarded-access-token header for asUser(req). Both x-forwarded-user and x-forwarded-access-token are required.");
136
+ throw AuthenticationError.missingToken("Missing x-forwarded-user header. Cannot resolve user ID for asUser(req).");
137
+ }
138
+ /**
139
+ * Extraction for OBO (on-behalf-of-user) volumes on the HTTP path. Both the
140
+ * `x-forwarded-access-token` and `x-forwarded-user` headers must be present
141
+ * for a valid end-user identity. When the token is missing:
142
+ * - In production we throw `AuthenticationError.missingToken` so the route
143
+ * responds with 401 (no SDK call is made).
144
+ * - In development (`NODE_ENV === "development"`) we emit a single warning
145
+ * and fall back to the service principal identity so local testing
146
+ * without a reverse proxy continues to work.
147
+ */
148
+ _extractOboUser(req) {
149
+ const token = req.header("x-forwarded-access-token")?.trim();
150
+ const userId = req.header("x-forwarded-user")?.trim();
151
+ if (token && userId) return {
152
+ id: userId,
153
+ isServicePrincipal: false
154
+ };
155
+ if (!token && process.env.NODE_ENV === "development") {
156
+ logger.warn("OBO volume requested without x-forwarded-access-token — falling back to service principal identity (dev mode). In production this request would 401.");
157
+ return {
158
+ id: getCurrentUserId(),
159
+ isServicePrincipal: true
160
+ };
91
161
  }
92
- throw AuthenticationError.missingToken("Missing x-forwarded-user header. Cannot resolve user ID.");
162
+ throw AuthenticationError.missingToken(!token ? "Missing x-forwarded-access-token header for on-behalf-of-user volume." : "Missing x-forwarded-user header for on-behalf-of-user volume.");
93
163
  }
94
164
  /**
95
165
  * Check the policy for a volume. No-op if no policy is configured.
@@ -110,17 +180,43 @@ var FilesPlugin = class FilesPlugin extends Plugin {
110
180
  }
111
181
  /**
112
182
  * HTTP-level wrapper around `_checkPolicy`.
113
- * Extracts user (401 on failure), runs policy (403 on denial).
183
+ * Selects the policy user based on the volume's auth mode (resolved via
184
+ * `_resolveAuth`):
185
+ * - `"service-principal"` (default): use the `x-forwarded-user` header when
186
+ * present, otherwise fall back to the SP identity (legacy behavior).
187
+ * - `"on-behalf-of-user"`: require `x-forwarded-access-token` (and the
188
+ * matching `x-forwarded-user`); 401 in production when missing,
189
+ * dev-fallback to SP identity in development.
190
+ * Then runs the volume policy (403 on denial, 500 on unexpected error).
114
191
  * Returns `true` if the request may proceed, `false` if a response was sent.
192
+ *
193
+ * NOTE: This method only selects which identity the *policy* sees. The
194
+ * matching SDK execution identity is selected separately by
195
+ * `_resolveAuthForRequest` and applied via `_runWithAuth` /
196
+ * `runInUserContext` in each handler. The two selections are designed to
197
+ * converge on the same identity per the policy-user matrix in the docs —
198
+ * see `docs/docs/plugins/files.md#policy-user-matrix`.
115
199
  */
116
200
  async _enforcePolicy(req, res, volumeKey, action, path, resourceOverrides) {
117
201
  let user;
118
202
  try {
119
- user = this._extractUser(req);
203
+ if (this._resolveAuth(volumeKey) === "on-behalf-of-user") user = this._extractOboUser(req);
204
+ else {
205
+ const headerUserId = req.header("x-forwarded-user")?.trim();
206
+ if (headerUserId) user = { id: headerUserId };
207
+ else {
208
+ logger.debug("No x-forwarded-user header — proceeding with service principal identity for policy evaluation.");
209
+ user = {
210
+ id: getCurrentUserId(),
211
+ isServicePrincipal: true
212
+ };
213
+ }
214
+ }
120
215
  } catch (error) {
121
216
  if (error instanceof AuthenticationError) {
217
+ logger.warn("Authentication failed during policy evaluation for volume %s: %O", volumeKey, error);
122
218
  res.status(401).json({
123
- error: error.message,
219
+ error: "Unauthorized",
124
220
  plugin: this.name
125
221
  });
126
222
  return false;
@@ -158,8 +254,10 @@ var FilesPlugin = class FilesPlugin extends Plugin {
158
254
  const volumePath = process.env[envVar];
159
255
  const mergedConfig = {
160
256
  maxUploadSize: volumeCfg.maxUploadSize ?? config.maxUploadSize,
257
+ maxReadSize: volumeCfg.maxReadSize ?? config.maxReadSize,
161
258
  customContentTypes: volumeCfg.customContentTypes ?? config.customContentTypes,
162
- policy: volumeCfg.policy ?? policy.publicRead()
259
+ policy: volumeCfg.policy ?? policy.publicRead(),
260
+ auth: volumeCfg.auth
163
261
  };
164
262
  this.volumeConfigs[key] = mergedConfig;
165
263
  this.volumeConnectors[key] = new FilesConnector({
@@ -169,7 +267,7 @@ var FilesPlugin = class FilesPlugin extends Plugin {
169
267
  customContentTypes: mergedConfig.customContentTypes
170
268
  });
171
269
  Object.assign(this.tools, this._defineVolumeTools(key));
172
- if (!volumeCfg.policy) logger.warn("Volume \"%s\" has no explicit policy — defaulting to publicRead(). Set a policy in files({ volumes: { %s: { policy: ... } } }) to silence this warning.", key, key);
270
+ if (!volumeCfg.policy) logger.warn("Volume \"%s\" has no explicit policy — defaulting to publicRead(). This also matches header-less HTTP requests (which run as the service principal). Set a policy in files({ volumes: { %s: { policy: ... } } }) to silence this warning.", key, key);
173
271
  }
174
272
  }
175
273
  injectRoutes(router) {
@@ -307,6 +405,27 @@ var FilesPlugin = class FilesPlugin extends Plugin {
307
405
  };
308
406
  }
309
407
  /**
408
+ * Extract `req.query.path` as a single string when present.
409
+ *
410
+ * Express coerces repeated query parameters (`?path=a&path=b`) to a string
411
+ * array and dotted/nested params (`?path[k]=v`) to an object. Reject those
412
+ * with `400` instead of letting non-string values reach `_isValidPath` /
413
+ * `connector.resolvePath`, which would misbehave on arrays or objects.
414
+ *
415
+ * Returns `{ path }` (with `path` either a string or `undefined` when the
416
+ * query parameter was absent) on success. Returns `undefined` and writes a
417
+ * `400` response when the value is not a single string — callers must
418
+ * check the return for `undefined` before continuing.
419
+ */
420
+ _readPathQuery(req, res) {
421
+ const value = req.query.path;
422
+ if (value === void 0 || typeof value === "string") return { path: value };
423
+ res.status(400).json({
424
+ error: "path query parameter must be a single string",
425
+ plugin: this.name
426
+ });
427
+ }
428
+ /**
310
429
  * Validate a file/directory path from user input.
311
430
  * Returns `true` if valid, or an error message string if invalid.
312
431
  */
@@ -316,29 +435,104 @@ var FilesPlugin = class FilesPlugin extends Plugin {
316
435
  if (path.includes("\0")) return "path must not contain null bytes";
317
436
  return true;
318
437
  }
319
- _readSettings(cacheKey) {
438
+ _readSettings(cacheKey, authMode) {
439
+ const cache = authMode === "on-behalf-of-user" ? {
440
+ ...FILES_READ_DEFAULTS.cache,
441
+ enabled: false,
442
+ cacheKey
443
+ } : {
444
+ ...FILES_READ_DEFAULTS.cache,
445
+ cacheKey
446
+ };
320
447
  return { default: {
321
448
  ...FILES_READ_DEFAULTS,
322
- cache: {
323
- ...FILES_READ_DEFAULTS.cache,
324
- cacheKey
325
- }
449
+ cache,
450
+ telemetryInterceptor: { attributes: this._authModeAttributes(authMode) }
451
+ } };
452
+ }
453
+ _writeSettings(authMode) {
454
+ return { default: {
455
+ ...FILES_WRITE_DEFAULTS,
456
+ telemetryInterceptor: { attributes: this._authModeAttributes(authMode) }
457
+ } };
458
+ }
459
+ _downloadSettings(authMode) {
460
+ return { default: {
461
+ ...FILES_DOWNLOAD_DEFAULTS,
462
+ telemetryInterceptor: { attributes: this._authModeAttributes(authMode) }
326
463
  } };
327
464
  }
328
465
  /**
329
466
  * Invalidate cached list entries for a directory after a write operation.
330
- * Uses the same cache-key format as `_handleList`: resolved path for
331
- * subdirectories, `"__root__"` for the volume root.
467
+ * Must produce the SAME cache-key shape that `_handleList` stored under.
468
+ * `_handleList` builds its key from `req.query.path`: when `path` is
469
+ * provided it uses `connector.resolvePath(path)`, otherwise it uses the
470
+ * sentinel `"__root__"`. The invalidation here must derive the matching
471
+ * directory from the FILE path being written:
472
+ *
473
+ * - `"/Volumes/c/s/v/foo/bar.txt"` → `parentDirectory` returns
474
+ * `"/Volumes/c/s/v/foo"` → resolved path key.
475
+ * - `"/bar.txt"` and `"bar.txt"` → root-level files: matching list cache
476
+ * was a rootless `list()` call → `"__root__"` sentinel.
477
+ * - `"/Volumes/c/s/v/bar.txt"` → `parentDirectory` returns the UC
478
+ * volume path (`"/Volumes/c/s/v"`). That's also root-level — a
479
+ * rootless `list()` would have cached under `"__root__"`, while
480
+ * `list("/Volumes/c/s/v")` and `list("/Volumes/c/s/v/")` would have
481
+ * cached under the volume path with and without trailing slash. All
482
+ * three are invalidated.
332
483
  *
333
- * Cache keys include `getCurrentUserId()` must match the identity used
334
- * by `this.execute()` in `_handleList`. Both run in service-principal
335
- * context; wrapping either in `runInUserContext` would break invalidation.
484
+ * On OBO volumes the read cache is disabled (see `_readSettings`), so
485
+ * invalidation is a no-op here for `mode === "on-behalf-of-user"`. The
486
+ * cache layer is keyed by `getCurrentUserId()`, so user A's writes can
487
+ * only invalidate user A's cache entry — user B would otherwise see stale
488
+ * data for the same volume/path until TTL. Disabling the cache on OBO
489
+ * trades read performance for correctness; the alternative is a
490
+ * per-(volume, path) generation counter folded into the cache key on
491
+ * writes (a future enhancement).
492
+ *
493
+ * Best-effort: a thrown `connector.resolvePath` (e.g. on malformed input)
494
+ * is swallowed here. Invalidation is purely an optimization signal — a
495
+ * missed delete only costs read freshness, not correctness, and
496
+ * propagating the error would convert a successful write into an HTTP
497
+ * 500.
498
+ *
499
+ * Returns a `Promise<void>`; callers MUST `await` this before sending the
500
+ * HTTP success response so a follow-up `GET /list` issued in the same tick
501
+ * cannot race the underlying `cache.delete()` and observe stale data.
336
502
  */
337
- _invalidateListCache(volumeKey, parentPath, connector) {
338
- const parent = parentDirectory(parentPath);
339
- const cachePathSegment = parent ? connector.resolvePath(parent) : "__root__";
340
- const listKey = this.cache.generateKey([`files:${volumeKey}:list`, cachePathSegment], getCurrentUserId());
341
- this.cache.delete(listKey);
503
+ async _invalidateListCache(volumeKey, writtenPath, connector, mode = "service-principal") {
504
+ if (mode === "on-behalf-of-user") return;
505
+ const parent = parentDirectory(writtenPath);
506
+ const userKey = getCurrentUserId();
507
+ const tryDelete = async (segment) => {
508
+ try {
509
+ await this.cache.delete(this.cache.generateKey([`files:${volumeKey}:list`, segment], userKey));
510
+ } catch (err) {
511
+ logger.debug("List-cache invalidation failed for volume=%s segment=%s: %O", volumeKey, segment, err);
512
+ }
513
+ };
514
+ let volumeRoot = null;
515
+ try {
516
+ volumeRoot = connector.resolvePath("").replace(/\/+$/, "");
517
+ } catch (err) {
518
+ logger.debug("List-cache invalidation: resolvePath(\"\") failed for volume=%s: %O", volumeKey, err);
519
+ }
520
+ if (!parent || parent === "/" || volumeRoot !== null && parent === volumeRoot) {
521
+ await tryDelete("__root__");
522
+ if (volumeRoot !== null) {
523
+ await tryDelete(volumeRoot);
524
+ await tryDelete(`${volumeRoot}/`);
525
+ }
526
+ return;
527
+ }
528
+ let resolved;
529
+ try {
530
+ resolved = connector.resolvePath(parent);
531
+ } catch (err) {
532
+ logger.debug("List-cache invalidation: resolvePath(%s) failed for volume=%s: %O", parent, volumeKey, err);
533
+ return;
534
+ }
535
+ await tryDelete(resolved);
342
536
  }
343
537
  _handleApiError(res, error, fallbackMessage) {
344
538
  if (error instanceof PolicyDeniedError) {
@@ -349,8 +543,9 @@ var FilesPlugin = class FilesPlugin extends Plugin {
349
543
  return;
350
544
  }
351
545
  if (error instanceof AuthenticationError) {
546
+ logger.warn("Authentication failed in %s: %O", this.name, error);
352
547
  res.status(401).json({
353
- error: error.message,
548
+ error: "Unauthorized",
354
549
  plugin: this.name
355
550
  });
356
551
  return;
@@ -358,8 +553,9 @@ var FilesPlugin = class FilesPlugin extends Plugin {
358
553
  if (error instanceof ApiError) {
359
554
  const status = error.statusCode ?? 500;
360
555
  if (status >= 400 && status < 500) {
556
+ logger.warn("Upstream %d in %s: %O", status, this.name, error);
361
557
  res.status(status).json({
362
- error: error.message,
558
+ error: STATUS_CODES[status] ?? "Client Error",
363
559
  statusCode: status,
364
560
  plugin: this.name
365
561
  });
@@ -385,22 +581,29 @@ var FilesPlugin = class FilesPlugin extends Plugin {
385
581
  });
386
582
  }
387
583
  async _handleList(req, res, connector, volumeKey) {
388
- const path = req.query.path;
584
+ const query = this._readPathQuery(req, res);
585
+ if (!query) return;
586
+ const path = query.path;
389
587
  if (!await this._enforcePolicy(req, res, volumeKey, "list", path ?? "/")) return;
390
- try {
391
- const result = await this.execute(async () => connector.list(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:list`, path ? connector.resolvePath(path) : "__root__"]));
392
- if (!result.ok) {
393
- this._sendStatusError(res, result.status);
394
- return;
588
+ const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
589
+ await this._runWithAuth(userCtx, async () => {
590
+ try {
591
+ const result = await this.execute(async () => connector.list(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:list`, path ? connector.resolvePath(path) : "__root__"], mode));
592
+ if (!result.ok) {
593
+ this._sendStatusError(res, result.status);
594
+ return;
595
+ }
596
+ res.json(result.data);
597
+ } catch (error) {
598
+ this._handleApiError(res, error, "List failed");
395
599
  }
396
- res.json(result.data);
397
- } catch (error) {
398
- this._handleApiError(res, error, "List failed");
399
- }
600
+ });
400
601
  }
401
602
  async _handleRead(req, res, connector, volumeKey) {
402
- const path = req.query.path;
403
- const valid = this._isValidPath(path);
603
+ const query = this._readPathQuery(req, res);
604
+ if (!query) return;
605
+ const rawPath = query.path;
606
+ const valid = this._isValidPath(rawPath);
404
607
  if (valid !== true) {
405
608
  res.status(400).json({
406
609
  error: valid,
@@ -408,17 +611,51 @@ var FilesPlugin = class FilesPlugin extends Plugin {
408
611
  });
409
612
  return;
410
613
  }
614
+ const path = rawPath;
411
615
  if (!await this._enforcePolicy(req, res, volumeKey, "read", path)) return;
412
- try {
413
- const result = await this.execute(async () => connector.read(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:read`, connector.resolvePath(path)]));
414
- if (!result.ok) {
415
- this._sendStatusError(res, result.status);
416
- return;
616
+ const maxReadSize = this.volumeConfigs[volumeKey].maxReadSize ?? FILES_MAX_READ_SIZE;
617
+ const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
618
+ await this._runWithAuth(userCtx, async () => {
619
+ try {
620
+ const response = await this.execute(async () => connector.download(getWorkspaceClient(), path), this._downloadSettings(mode));
621
+ if (!response.ok) {
622
+ this._sendStatusError(res, response.status);
623
+ return;
624
+ }
625
+ if (!response.data.contents) {
626
+ res.type("text/plain").send("");
627
+ return;
628
+ }
629
+ const reader = response.data.contents.getReader();
630
+ const chunks = [];
631
+ let bytesRead = 0;
632
+ try {
633
+ while (true) {
634
+ const { value, done } = await reader.read();
635
+ if (done) break;
636
+ bytesRead += value.byteLength;
637
+ if (bytesRead > maxReadSize) {
638
+ res.status(413).json({
639
+ error: `File exceeds maxReadSize (${maxReadSize} bytes). Use /download for large files.`,
640
+ plugin: this.name
641
+ });
642
+ try {
643
+ await reader.cancel();
644
+ } catch {}
645
+ return;
646
+ }
647
+ chunks.push(value);
648
+ }
649
+ } finally {
650
+ try {
651
+ reader.releaseLock();
652
+ } catch {}
653
+ }
654
+ res.type("text/plain").send(Buffer.concat(chunks, bytesRead));
655
+ } catch (error) {
656
+ this._handleApiError(res, error, "Read failed");
417
657
  }
418
- res.type("text/plain").send(result.data);
419
- } catch (error) {
420
- this._handleApiError(res, error, "Read failed");
421
- }
658
+ });
422
659
  }
423
660
  async _handleDownload(req, res, connector, volumeKey) {
424
661
  return this._serveFile(req, res, connector, volumeKey, { mode: "download" });
@@ -432,8 +669,10 @@ var FilesPlugin = class FilesPlugin extends Plugin {
432
669
  * - `raw`: adds CSP sandbox; forces attachment only for unsafe content types.
433
670
  */
434
671
  async _serveFile(req, res, connector, volumeKey, opts) {
435
- const path = req.query.path;
436
- const valid = this._isValidPath(path);
672
+ const query = this._readPathQuery(req, res);
673
+ if (!query) return;
674
+ const rawPath = query.path;
675
+ const valid = this._isValidPath(rawPath);
437
676
  if (valid !== true) {
438
677
  res.status(400).json({
439
678
  error: valid,
@@ -441,40 +680,46 @@ var FilesPlugin = class FilesPlugin extends Plugin {
441
680
  });
442
681
  return;
443
682
  }
683
+ const path = rawPath;
444
684
  if (!await this._enforcePolicy(req, res, volumeKey, opts.mode, path)) return;
445
685
  const label = opts.mode === "download" ? "Download" : "Raw fetch";
446
686
  const volumeCfg = this.volumeConfigs[volumeKey];
447
- try {
448
- const settings = { default: FILES_DOWNLOAD_DEFAULTS };
449
- const response = await this.execute(async () => connector.download(getWorkspaceClient(), path), settings);
450
- if (!response.ok) {
451
- this._sendStatusError(res, response.status);
452
- return;
687
+ const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
688
+ await this._runWithAuth(userCtx, async () => {
689
+ try {
690
+ const settings = this._downloadSettings(mode);
691
+ const response = await this.execute(async () => connector.download(getWorkspaceClient(), path), settings);
692
+ if (!response.ok) {
693
+ this._sendStatusError(res, response.status);
694
+ return;
695
+ }
696
+ const resolvedType = contentTypeFromPath(path, void 0, volumeCfg.customContentTypes);
697
+ const fileName = sanitizeFilename(path.split("/").pop() ?? "download");
698
+ res.setHeader("Content-Type", resolvedType);
699
+ res.setHeader("X-Content-Type-Options", "nosniff");
700
+ if (opts.mode === "raw") {
701
+ res.setHeader("Content-Security-Policy", "sandbox");
702
+ if (!isSafeInlineContentType(resolvedType)) res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
703
+ } else res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
704
+ if (response.data.contents) {
705
+ const nodeStream = Readable.fromWeb(response.data.contents);
706
+ nodeStream.on("error", (err) => {
707
+ logger.error("Stream error during %s: %O", opts.mode, err);
708
+ if (!res.headersSent) this._sendStatusError(res, 500);
709
+ else res.destroy();
710
+ });
711
+ nodeStream.pipe(res);
712
+ } else res.end();
713
+ } catch (error) {
714
+ this._handleApiError(res, error, `${label} failed`);
453
715
  }
454
- const resolvedType = contentTypeFromPath(path, void 0, volumeCfg.customContentTypes);
455
- const fileName = sanitizeFilename(path.split("/").pop() ?? "download");
456
- res.setHeader("Content-Type", resolvedType);
457
- res.setHeader("X-Content-Type-Options", "nosniff");
458
- if (opts.mode === "raw") {
459
- res.setHeader("Content-Security-Policy", "sandbox");
460
- if (!isSafeInlineContentType(resolvedType)) res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
461
- } else res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
462
- if (response.data.contents) {
463
- const nodeStream = Readable.fromWeb(response.data.contents);
464
- nodeStream.on("error", (err) => {
465
- logger.error("Stream error during %s: %O", opts.mode, err);
466
- if (!res.headersSent) this._sendStatusError(res, 500);
467
- else res.destroy();
468
- });
469
- nodeStream.pipe(res);
470
- } else res.end();
471
- } catch (error) {
472
- this._handleApiError(res, error, `${label} failed`);
473
- }
716
+ });
474
717
  }
475
718
  async _handleExists(req, res, connector, volumeKey) {
476
- const path = req.query.path;
477
- const valid = this._isValidPath(path);
719
+ const query = this._readPathQuery(req, res);
720
+ if (!query) return;
721
+ const rawPath = query.path;
722
+ const valid = this._isValidPath(rawPath);
478
723
  if (valid !== true) {
479
724
  res.status(400).json({
480
725
  error: valid,
@@ -482,21 +727,27 @@ var FilesPlugin = class FilesPlugin extends Plugin {
482
727
  });
483
728
  return;
484
729
  }
730
+ const path = rawPath;
485
731
  if (!await this._enforcePolicy(req, res, volumeKey, "exists", path)) return;
486
- try {
487
- const result = await this.execute(async () => connector.exists(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:exists`, connector.resolvePath(path)]));
488
- if (!result.ok) {
489
- this._sendStatusError(res, result.status);
490
- return;
732
+ const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
733
+ await this._runWithAuth(userCtx, async () => {
734
+ try {
735
+ const result = await this.execute(async () => connector.exists(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:exists`, connector.resolvePath(path)], mode));
736
+ if (!result.ok) {
737
+ this._sendStatusError(res, result.status);
738
+ return;
739
+ }
740
+ res.json({ exists: result.data });
741
+ } catch (error) {
742
+ this._handleApiError(res, error, "Exists check failed");
491
743
  }
492
- res.json({ exists: result.data });
493
- } catch (error) {
494
- this._handleApiError(res, error, "Exists check failed");
495
- }
744
+ });
496
745
  }
497
746
  async _handleMetadata(req, res, connector, volumeKey) {
498
- const path = req.query.path;
499
- const valid = this._isValidPath(path);
747
+ const query = this._readPathQuery(req, res);
748
+ if (!query) return;
749
+ const rawPath = query.path;
750
+ const valid = this._isValidPath(rawPath);
500
751
  if (valid !== true) {
501
752
  res.status(400).json({
502
753
  error: valid,
@@ -504,21 +755,27 @@ var FilesPlugin = class FilesPlugin extends Plugin {
504
755
  });
505
756
  return;
506
757
  }
758
+ const path = rawPath;
507
759
  if (!await this._enforcePolicy(req, res, volumeKey, "metadata", path)) return;
508
- try {
509
- const result = await this.execute(async () => connector.metadata(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:metadata`, connector.resolvePath(path)]));
510
- if (!result.ok) {
511
- this._sendStatusError(res, result.status);
512
- return;
760
+ const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
761
+ await this._runWithAuth(userCtx, async () => {
762
+ try {
763
+ const result = await this.execute(async () => connector.metadata(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:metadata`, connector.resolvePath(path)], mode));
764
+ if (!result.ok) {
765
+ this._sendStatusError(res, result.status);
766
+ return;
767
+ }
768
+ res.json(result.data);
769
+ } catch (error) {
770
+ this._handleApiError(res, error, "Metadata fetch failed");
513
771
  }
514
- res.json(result.data);
515
- } catch (error) {
516
- this._handleApiError(res, error, "Metadata fetch failed");
517
- }
772
+ });
518
773
  }
519
774
  async _handlePreview(req, res, connector, volumeKey) {
520
- const path = req.query.path;
521
- const valid = this._isValidPath(path);
775
+ const query = this._readPathQuery(req, res);
776
+ if (!query) return;
777
+ const rawPath = query.path;
778
+ const valid = this._isValidPath(rawPath);
522
779
  if (valid !== true) {
523
780
  res.status(400).json({
524
781
  error: valid,
@@ -526,21 +783,27 @@ var FilesPlugin = class FilesPlugin extends Plugin {
526
783
  });
527
784
  return;
528
785
  }
786
+ const path = rawPath;
529
787
  if (!await this._enforcePolicy(req, res, volumeKey, "preview", path)) return;
530
- try {
531
- const result = await this.execute(async () => connector.preview(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:preview`, connector.resolvePath(path)]));
532
- if (!result.ok) {
533
- this._sendStatusError(res, result.status);
534
- return;
788
+ const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
789
+ await this._runWithAuth(userCtx, async () => {
790
+ try {
791
+ const result = await this.execute(async () => connector.preview(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:preview`, connector.resolvePath(path)], mode));
792
+ if (!result.ok) {
793
+ this._sendStatusError(res, result.status);
794
+ return;
795
+ }
796
+ res.json(result.data);
797
+ } catch (error) {
798
+ this._handleApiError(res, error, "Preview failed");
535
799
  }
536
- res.json(result.data);
537
- } catch (error) {
538
- this._handleApiError(res, error, "Preview failed");
539
- }
800
+ });
540
801
  }
541
802
  async _handleUpload(req, res, connector, volumeKey) {
542
- const path = req.query.path;
543
- const valid = this._isValidPath(path);
803
+ const query = this._readPathQuery(req, res);
804
+ if (!query) return;
805
+ const rawPath = query.path;
806
+ const valid = this._isValidPath(rawPath);
544
807
  if (valid !== true) {
545
808
  res.status(400).json({
546
809
  error: valid,
@@ -548,6 +811,7 @@ var FilesPlugin = class FilesPlugin extends Plugin {
548
811
  });
549
812
  return;
550
813
  }
814
+ const path = rawPath;
551
815
  const maxSize = this.volumeConfigs[volumeKey].maxUploadSize ?? FILES_MAX_UPLOAD_SIZE;
552
816
  const rawContentLength = req.headers["content-length"];
553
817
  let contentLength;
@@ -570,41 +834,48 @@ var FilesPlugin = class FilesPlugin extends Plugin {
570
834
  return;
571
835
  }
572
836
  logger.debug(req, "Upload started: volume=%s path=%s", volumeKey, path);
573
- try {
574
- const rawStream = Readable.toWeb(req);
575
- let bytesReceived = 0;
576
- const webStream = rawStream.pipeThrough(new TransformStream({ transform(chunk, controller) {
577
- bytesReceived += chunk.byteLength;
578
- if (bytesReceived > maxSize) {
579
- controller.error(/* @__PURE__ */ new Error(`Upload stream exceeds maximum allowed size (${maxSize} bytes)`));
837
+ const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
838
+ await this._runWithAuth(userCtx, async () => {
839
+ try {
840
+ const rawStream = Readable.toWeb(req);
841
+ let bytesReceived = 0;
842
+ const webStream = rawStream.pipeThrough(new TransformStream({ transform(chunk, controller) {
843
+ bytesReceived += chunk.byteLength;
844
+ if (bytesReceived > maxSize) {
845
+ controller.error(/* @__PURE__ */ new Error(`Upload stream exceeds maximum allowed size (${maxSize} bytes)`));
846
+ return;
847
+ }
848
+ if (contentLength !== void 0 && bytesReceived > contentLength) {
849
+ controller.error(/* @__PURE__ */ new Error(`Upload stream exceeds declared Content-Length (${contentLength} bytes)`));
850
+ return;
851
+ }
852
+ controller.enqueue(chunk);
853
+ } }));
854
+ logger.debug(req, "Upload body received: volume=%s path=%s, size=%d bytes", volumeKey, path, contentLength ?? 0);
855
+ const settings = this._writeSettings(mode);
856
+ const result = await this.trackWrite(() => this.execute(async () => {
857
+ await connector.upload(getWorkspaceClient(), path, webStream);
858
+ return { success: true };
859
+ }, settings));
860
+ await this._invalidateListCache(volumeKey, path, connector, mode);
861
+ if (!result.ok) {
862
+ logger.error(req, "Upload failed: volume=%s path=%s, size=%d bytes", volumeKey, path, contentLength ?? 0);
863
+ this._sendStatusError(res, result.status);
580
864
  return;
581
865
  }
582
- controller.enqueue(chunk);
583
- } }));
584
- logger.debug(req, "Upload body received: volume=%s path=%s, size=%d bytes", volumeKey, path, contentLength ?? 0);
585
- const settings = { default: FILES_WRITE_DEFAULTS };
586
- const result = await this.trackWrite(() => this.execute(async () => {
587
- await connector.upload(getWorkspaceClient(), path, webStream);
588
- return { success: true };
589
- }, settings));
590
- this._invalidateListCache(volumeKey, path, connector);
591
- if (!result.ok) {
592
- logger.error(req, "Upload failed: volume=%s path=%s, size=%d bytes", volumeKey, path, contentLength ?? 0);
593
- this._sendStatusError(res, result.status);
594
- return;
595
- }
596
- logger.debug(req, "Upload complete: volume=%s path=%s", volumeKey, path);
597
- res.json(result.data);
598
- } catch (error) {
599
- if (error instanceof Error && error.message.includes("exceeds maximum allowed size")) {
600
- res.status(413).json({
601
- error: error.message,
602
- plugin: this.name
603
- });
604
- return;
866
+ logger.debug(req, "Upload complete: volume=%s path=%s", volumeKey, path);
867
+ res.json(result.data);
868
+ } catch (error) {
869
+ if (error instanceof Error && (error.message.includes("exceeds maximum allowed size") || error.message.includes("exceeds declared Content-Length"))) {
870
+ res.status(413).json({
871
+ error: error.message,
872
+ plugin: this.name
873
+ });
874
+ return;
875
+ }
876
+ this._handleApiError(res, error, "Upload failed");
605
877
  }
606
- this._handleApiError(res, error, "Upload failed");
607
- }
878
+ });
608
879
  }
609
880
  async _handleMkdir(req, res, connector, volumeKey) {
610
881
  const dirPath = typeof req.body?.path === "string" ? req.body.path : void 0;
@@ -617,24 +888,29 @@ var FilesPlugin = class FilesPlugin extends Plugin {
617
888
  return;
618
889
  }
619
890
  if (!await this._enforcePolicy(req, res, volumeKey, "mkdir", dirPath)) return;
620
- try {
621
- const settings = { default: FILES_WRITE_DEFAULTS };
622
- const result = await this.trackWrite(() => this.execute(async () => {
623
- await connector.createDirectory(getWorkspaceClient(), dirPath);
624
- return { success: true };
625
- }, settings));
626
- this._invalidateListCache(volumeKey, dirPath, connector);
627
- if (!result.ok) {
628
- this._sendStatusError(res, result.status);
629
- return;
891
+ const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
892
+ await this._runWithAuth(userCtx, async () => {
893
+ try {
894
+ const settings = this._writeSettings(mode);
895
+ const result = await this.trackWrite(() => this.execute(async () => {
896
+ await connector.createDirectory(getWorkspaceClient(), dirPath);
897
+ return { success: true };
898
+ }, settings));
899
+ await this._invalidateListCache(volumeKey, dirPath, connector, mode);
900
+ if (!result.ok) {
901
+ this._sendStatusError(res, result.status);
902
+ return;
903
+ }
904
+ res.json(result.data);
905
+ } catch (error) {
906
+ this._handleApiError(res, error, "Create directory failed");
630
907
  }
631
- res.json(result.data);
632
- } catch (error) {
633
- this._handleApiError(res, error, "Create directory failed");
634
- }
908
+ });
635
909
  }
636
910
  async _handleDelete(req, res, connector, volumeKey) {
637
- const rawPath = req.query.path;
911
+ const query = this._readPathQuery(req, res);
912
+ if (!query) return;
913
+ const rawPath = query.path;
638
914
  const valid = this._isValidPath(rawPath);
639
915
  if (valid !== true) {
640
916
  res.status(400).json({
@@ -645,36 +921,177 @@ var FilesPlugin = class FilesPlugin extends Plugin {
645
921
  }
646
922
  const path = rawPath;
647
923
  if (!await this._enforcePolicy(req, res, volumeKey, "delete", path)) return;
648
- try {
649
- const settings = { default: FILES_WRITE_DEFAULTS };
650
- const result = await this.trackWrite(() => this.execute(async () => {
651
- await connector.delete(getWorkspaceClient(), path);
652
- return { success: true };
653
- }, settings));
654
- this._invalidateListCache(volumeKey, path, connector);
655
- if (!result.ok) {
656
- this._sendStatusError(res, result.status);
657
- return;
924
+ const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
925
+ await this._runWithAuth(userCtx, async () => {
926
+ try {
927
+ const settings = this._writeSettings(mode);
928
+ const result = await this.trackWrite(() => this.execute(async () => {
929
+ await connector.delete(getWorkspaceClient(), path);
930
+ return { success: true };
931
+ }, settings));
932
+ await this._invalidateListCache(volumeKey, path, connector, mode);
933
+ if (!result.ok) {
934
+ this._sendStatusError(res, result.status);
935
+ return;
936
+ }
937
+ res.json(result.data);
938
+ } catch (error) {
939
+ this._handleApiError(res, error, "Delete failed");
658
940
  }
659
- res.json(result.data);
660
- } catch (error) {
661
- this._handleApiError(res, error, "Delete failed");
662
- }
941
+ });
942
+ }
943
+ _resolveAuth(volumeKey) {
944
+ return this.volumeConfigs[volumeKey]?.auth ?? this.config.auth ?? "service-principal";
663
945
  }
664
946
  /**
665
- * Creates a VolumeAPI for a specific volume key.
947
+ * Build a `UserContext` from request headers when both
948
+ * `x-forwarded-access-token` and `x-forwarded-user` are present, otherwise
949
+ * return `null`. Used by OBO route handlers to wrap SDK calls in the
950
+ * end-user's identity. A `null` result means "fall back to the service
951
+ * principal client" — for OBO volumes in production, `_enforcePolicy` will
952
+ * already have responded 401 before we get here, so `null` is reachable
953
+ * only on the dev-fallback path.
954
+ */
955
+ _buildUserContextOrNull(req) {
956
+ const token = req.header("x-forwarded-access-token")?.trim();
957
+ const userId = req.header("x-forwarded-user")?.trim();
958
+ if (!token || !userId) return null;
959
+ return ServiceContext.createUserContext(token, userId);
960
+ }
961
+ /**
962
+ * Build the telemetry attribute hash for the `files.auth_mode` span
963
+ * attribute. The value reflects what operationally happened — i.e.
964
+ * whether `runInUserContext` actually wrapped the SDK call:
965
+ * - HTTP route on OBO volume + valid token → `"on-behalf-of-user"`.
966
+ * - HTTP route on OBO volume + dev-fallback (no token) →
967
+ * `"service-principal"` (the route falls through to the SP client).
968
+ * - HTTP route on SP volume → `"service-principal"`.
969
+ * - `asUser(req)` programmatic calls with a real user context →
970
+ * `"on-behalf-of-user"`.
971
+ * - Any unwrapped path → `"service-principal"`.
972
+ */
973
+ _authModeAttributes(authMode) {
974
+ return { "files.auth_mode": authMode };
975
+ }
976
+ /**
977
+ * One-shot resolver for HTTP route handlers. Builds the request's
978
+ * `UserContext` AT MOST ONCE (when the volume is OBO and the headers are
979
+ * present) and returns both the operationally-effective auth mode and the
980
+ * pre-built `UserContext`.
666
981
  *
667
- * By default, enforces the volume's policy before each operation.
668
- * Pass `bypassPolicy: true` to skip policy checks useful for
669
- * background jobs or migrations that should bypass user-facing policies.
982
+ * Handlers thread the `userCtx` into `_runWithAuth(userCtx, fn)` to avoid
983
+ * a second `ServiceContext.createUserContext()` allocationthat call
984
+ * builds a fresh `WorkspaceClient` per invocation, so doing it twice per
985
+ * request was pure throwaway overhead.
986
+ */
987
+ _resolveAuthForRequest(req, volumeKey) {
988
+ if (this._resolveAuth(volumeKey) !== "on-behalf-of-user") return {
989
+ mode: "service-principal",
990
+ userCtx: null
991
+ };
992
+ const userCtx = this._buildUserContextOrNull(req);
993
+ return userCtx ? {
994
+ mode: "on-behalf-of-user",
995
+ userCtx
996
+ } : {
997
+ mode: "service-principal",
998
+ userCtx: null
999
+ };
1000
+ }
1001
+ /**
1002
+ * Run `fn` under the correct execution context.
1003
+ * - `userCtx` is `null`: invokes `fn` directly so the service-principal
1004
+ * `WorkspaceClient` and `getCurrentUserId()` are used — identical
1005
+ * behavior to pre-OBO releases. This covers both SP volumes and the
1006
+ * OBO dev-fallback path (where headers were missing).
1007
+ * - `userCtx` is a `UserContext`: wraps `fn` in `runInUserContext(userCtx)`,
1008
+ * so SDK calls execute as the end user and `getCurrentUserId()` (and
1009
+ * therefore cache keys) resolve to the user's ID.
670
1010
  *
671
- * @security When `bypassPolicy` is `true`, no policy enforcement runs.
672
- * Do not expose bypassed APIs to HTTP routes or end-user code paths.
1011
+ * The caller is responsible for building `userCtx` exactly once per
1012
+ * request via `_resolveAuthForRequest`; this signature deliberately does
1013
+ * NOT take a `req` so it cannot accidentally re-build the context.
673
1014
  */
674
- createVolumeAPI(volumeKey, user, options) {
1015
+ async _runWithAuth(userCtx, fn) {
1016
+ if (userCtx) return runInUserContext(userCtx, fn);
1017
+ return fn();
1018
+ }
1019
+ /**
1020
+ * Tag the span that `FilesConnector.<operation>` opens with
1021
+ * `files.auth_mode`. Programmatic VolumeAPI methods bypass
1022
+ * `this.execute(...)` (and therefore the `TelemetryInterceptor`), so the
1023
+ * connector's own `files.<operation>` span is the natural place to land
1024
+ * this attribute. Rather than opening a parent `files.<operation>` span
1025
+ * (which would duplicate the connector's span — same name, doubled
1026
+ * allocation/export), we propagate the attribute via AsyncLocalStorage
1027
+ * and let the connector merge it into its existing span at creation
1028
+ * time.
1029
+ *
1030
+ * The `operation` parameter is unused by the propagation mechanism (the
1031
+ * connector knows its own operation), but kept in the signature for API
1032
+ * stability with the previous span-creation form.
1033
+ */
1034
+ _withAuthModeAttributes(_operation, authMode, fn) {
1035
+ return runWithFilesSpanAttributes(this._authModeAttributes(authMode), fn);
1036
+ }
1037
+ /**
1038
+ * Wrap each `VolumeAPI` method so the `FilesConnector` span it produces is
1039
+ * tagged with `files.auth_mode = "service-principal"`. Used for
1040
+ * programmatic calls that don't go through `asUser(req)`.
1041
+ *
1042
+ * The attribute is attached to the connector's existing span via
1043
+ * AsyncLocalStorage propagation (see `_withAuthModeAttributes`); no
1044
+ * additional parent span is opened, so each call produces exactly one
1045
+ * `files.<operation>` span instead of two.
1046
+ */
1047
+ _wrapVolumeAPIWithSPSpan(api) {
1048
+ const wrap = (operation, fn) => (...args) => this._withAuthModeAttributes(operation, "service-principal", () => fn(...args));
1049
+ return {
1050
+ list: wrap("list", api.list),
1051
+ read: wrap("read", api.read),
1052
+ download: wrap("download", api.download),
1053
+ exists: wrap("exists", api.exists),
1054
+ metadata: wrap("metadata", api.metadata),
1055
+ upload: wrap("upload", api.upload),
1056
+ createDirectory: wrap("createDirectory", api.createDirectory),
1057
+ delete: wrap("delete", api.delete),
1058
+ preview: wrap("preview", api.preview)
1059
+ };
1060
+ }
1061
+ /**
1062
+ * Wrap each `VolumeAPI` method so its execution runs inside
1063
+ * `runInUserContext(userCtx, ...)`. Used by `VolumeHandle.asUser(req)` to
1064
+ * force the SDK identity to the end user regardless of the volume's
1065
+ * `auth` setting. The policy check baked into each method (via
1066
+ * `createVolumeAPI`) runs inside the same scope, so `getCurrentUserId()`
1067
+ * and any cache `userKey` derived from it also resolve to the user.
1068
+ *
1069
+ * Each wrapped invocation tags the connector's span with
1070
+ * `files.auth_mode = "on-behalf-of-user"` via AsyncLocalStorage
1071
+ * propagation — no additional parent span is opened.
1072
+ */
1073
+ _wrapVolumeAPIInUserContext(api, userCtx) {
1074
+ const wrap = (operation, fn) => (...args) => this._withAuthModeAttributes(operation, "on-behalf-of-user", () => runInUserContext(userCtx, () => fn(...args)));
1075
+ return {
1076
+ list: wrap("list", api.list),
1077
+ read: wrap("read", api.read),
1078
+ download: wrap("download", api.download),
1079
+ exists: wrap("exists", api.exists),
1080
+ metadata: wrap("metadata", api.metadata),
1081
+ upload: wrap("upload", api.upload),
1082
+ createDirectory: wrap("createDirectory", api.createDirectory),
1083
+ delete: wrap("delete", api.delete),
1084
+ preview: wrap("preview", api.preview)
1085
+ };
1086
+ }
1087
+ /**
1088
+ * Creates a VolumeAPI for a specific volume key.
1089
+ *
1090
+ * Enforces the volume's policy before each operation.
1091
+ */
1092
+ createVolumeAPI(volumeKey, user) {
675
1093
  const connector = this.volumeConnectors[volumeKey];
676
- const noop = () => Promise.resolve();
677
- const check = options?.bypassPolicy ? noop : (action, path, overrides) => this._checkPolicy(volumeKey, action, path, user, overrides);
1094
+ const check = (action, path, overrides) => this._checkPolicy(volumeKey, action, path, user, overrides);
678
1095
  return {
679
1096
  list: async (directoryPath) => {
680
1097
  await check("list", directoryPath ?? "/");
@@ -845,15 +1262,22 @@ var FilesPlugin = class FilesPlugin extends Plugin {
845
1262
  * Returns the programmatic API for the Files plugin.
846
1263
  * Callable with a volume key to get a volume-scoped handle.
847
1264
  *
848
- * All operations execute as the service principal.
849
- * Use policies to control per-user access.
1265
+ * SP volumes (`auth: "service-principal"`, the default) execute as the
1266
+ * service principal. OBO volumes (`auth: "on-behalf-of-user"`) executed
1267
+ * through the HTTP routes run as the end user; for programmatic calls
1268
+ * outside a route, use `asUser(req)` to opt into per-user execution.
1269
+ * `asUser(req)` is a hard override at the SDK level: it forces every
1270
+ * subsequent call to execute as the end user inside `runInUserContext`,
1271
+ * regardless of the volume's `auth` setting. Policies control per-user
1272
+ * access in either mode.
850
1273
  *
851
1274
  * @example
852
1275
  * ```ts
853
1276
  * // Service principal access
854
1277
  * appKit.files("uploads").list()
855
1278
  *
856
- * // With policy: pass user identity for access control
1279
+ * // With policy: pass user identity for access control. The SDK call
1280
+ * // also executes as the user (not the service principal).
857
1281
  * appKit.files("uploads").asUser(req).list()
858
1282
  * ```
859
1283
  */
@@ -861,15 +1285,18 @@ var FilesPlugin = class FilesPlugin extends Plugin {
861
1285
  const resolveVolume = (volumeKey) => {
862
1286
  if (!this.volumeKeys.includes(volumeKey)) throw new Error(`Unknown volume "${volumeKey}". Available volumes: ${this.volumeKeys.join(", ")}`);
863
1287
  return {
864
- ...this.createVolumeAPI(volumeKey, {
1288
+ ...this._wrapVolumeAPIWithSPSpan(this.createVolumeAPI(volumeKey, {
865
1289
  get id() {
866
1290
  return getCurrentUserId();
867
1291
  },
868
1292
  isServicePrincipal: true
869
- }),
1293
+ })),
870
1294
  asUser: (req) => {
871
1295
  const user = this._extractUser(req);
872
- return this.createVolumeAPI(volumeKey, user);
1296
+ const api = this.createVolumeAPI(volumeKey, user);
1297
+ const userCtx = this._buildUserContextOrNull(req);
1298
+ if (!userCtx) return this._wrapVolumeAPIWithSPSpan(api);
1299
+ return this._wrapVolumeAPIInUserContext(api, userCtx);
873
1300
  }
874
1301
  };
875
1302
  };