@databricks/appkit 0.17.0 → 0.19.0

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.
Files changed (98) hide show
  1. package/CLAUDE.md +9 -1
  2. package/dist/appkit/package.js +1 -1
  3. package/dist/cache/index.d.ts.map +1 -1
  4. package/dist/cache/index.js +2 -2
  5. package/dist/cache/index.js.map +1 -1
  6. package/dist/cli/commands/plugin/create/scaffold.js +2 -8
  7. package/dist/cli/commands/plugin/create/scaffold.js.map +1 -1
  8. package/dist/connectors/files/client.js +223 -0
  9. package/dist/connectors/files/client.js.map +1 -0
  10. package/dist/connectors/files/defaults.js +131 -0
  11. package/dist/connectors/files/defaults.js.map +1 -0
  12. package/dist/connectors/files/index.js +4 -0
  13. package/dist/connectors/genie/client.js.map +1 -1
  14. package/dist/connectors/genie/types.d.ts +1 -1
  15. package/dist/connectors/genie/types.d.ts.map +1 -1
  16. package/dist/connectors/index.js +3 -0
  17. package/dist/context/execution-context.js +7 -1
  18. package/dist/context/execution-context.js.map +1 -1
  19. package/dist/context/index.js +1 -1
  20. package/dist/core/appkit.d.ts.map +1 -1
  21. package/dist/core/appkit.js +24 -4
  22. package/dist/core/appkit.js.map +1 -1
  23. package/dist/index.d.ts +3 -2
  24. package/dist/index.js +2 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/plugin/plugin.d.ts +24 -5
  27. package/dist/plugin/plugin.d.ts.map +1 -1
  28. package/dist/plugin/plugin.js +43 -10
  29. package/dist/plugin/plugin.js.map +1 -1
  30. package/dist/plugin/to-plugin.d.ts +5 -2
  31. package/dist/plugin/to-plugin.d.ts.map +1 -1
  32. package/dist/plugin/to-plugin.js +5 -2
  33. package/dist/plugin/to-plugin.js.map +1 -1
  34. package/dist/plugins/analytics/analytics.d.ts +1 -2
  35. package/dist/plugins/analytics/analytics.d.ts.map +1 -1
  36. package/dist/plugins/analytics/analytics.js +1 -2
  37. package/dist/plugins/analytics/analytics.js.map +1 -1
  38. package/dist/plugins/files/defaults.d.ts +1 -0
  39. package/dist/plugins/files/defaults.js +56 -0
  40. package/dist/plugins/files/defaults.js.map +1 -0
  41. package/dist/plugins/files/helpers.js +30 -0
  42. package/dist/plugins/files/helpers.js.map +1 -0
  43. package/dist/plugins/files/index.d.ts +3 -0
  44. package/dist/plugins/files/index.js +5 -0
  45. package/dist/plugins/files/manifest.js +40 -0
  46. package/dist/plugins/files/manifest.js.map +1 -0
  47. package/dist/plugins/files/plugin.d.ts +105 -0
  48. package/dist/plugins/files/plugin.d.ts.map +1 -0
  49. package/dist/plugins/files/plugin.js +714 -0
  50. package/dist/plugins/files/plugin.js.map +1 -0
  51. package/dist/plugins/files/types.d.ts +105 -0
  52. package/dist/plugins/files/types.d.ts.map +1 -0
  53. package/dist/plugins/genie/genie.d.ts +1 -2
  54. package/dist/plugins/genie/genie.d.ts.map +1 -1
  55. package/dist/plugins/genie/genie.js +1 -2
  56. package/dist/plugins/genie/genie.js.map +1 -1
  57. package/dist/plugins/index.d.ts +3 -0
  58. package/dist/plugins/index.js +4 -0
  59. package/dist/plugins/lakebase/lakebase.d.ts +1 -2
  60. package/dist/plugins/lakebase/lakebase.d.ts.map +1 -1
  61. package/dist/plugins/lakebase/lakebase.js +1 -2
  62. package/dist/plugins/lakebase/lakebase.js.map +1 -1
  63. package/dist/plugins/server/index.d.ts +2 -2
  64. package/dist/plugins/server/index.d.ts.map +1 -1
  65. package/dist/plugins/server/index.js +9 -4
  66. package/dist/plugins/server/index.js.map +1 -1
  67. package/dist/registry/manifest-loader.js +1 -1
  68. package/dist/registry/manifest-loader.js.map +1 -1
  69. package/dist/registry/types.d.ts +3 -3
  70. package/dist/registry/types.d.ts.map +1 -1
  71. package/dist/registry/types.js.map +1 -1
  72. package/dist/shared/src/genie.d.ts +16 -2
  73. package/dist/shared/src/genie.d.ts.map +1 -1
  74. package/dist/shared/src/index.d.ts +1 -1
  75. package/dist/shared/src/plugin.d.ts +12 -4
  76. package/dist/shared/src/plugin.d.ts.map +1 -1
  77. package/docs/api/appkit/Class.Plugin.md +60 -12
  78. package/docs/api/appkit/Class.ResourceRegistry.md +3 -3
  79. package/docs/api/appkit/Function.createApp.md +3 -3
  80. package/docs/api/appkit/Interface.PluginManifest.md +9 -3
  81. package/docs/api/appkit/TypeAlias.PluginData.md +45 -0
  82. package/docs/api/appkit/TypeAlias.ToPlugin.md +1 -1
  83. package/docs/api/appkit-ui/files/DirectoryList.md +36 -0
  84. package/docs/api/appkit-ui/files/FileBreadcrumb.md +27 -0
  85. package/docs/api/appkit-ui/files/FileEntry.md +27 -0
  86. package/docs/api/appkit-ui/files/FilePreviewPanel.md +32 -0
  87. package/docs/api/appkit-ui/files/NewFolderInput.md +30 -0
  88. package/docs/api/appkit-ui/genie/GenieQueryVisualization.md +29 -0
  89. package/docs/api/appkit.md +1 -0
  90. package/docs/configuration.md +15 -0
  91. package/docs/plugins/custom-plugins.md +4 -13
  92. package/docs/plugins/files.md +350 -0
  93. package/docs/plugins.md +2 -1
  94. package/llms.txt +9 -1
  95. package/package.json +1 -1
  96. package/dist/plugins/server/remote-tunnel/denied.html/denied.html +0 -68
  97. package/dist/plugins/server/remote-tunnel/index.html/index.html +0 -165
  98. package/dist/plugins/server/remote-tunnel/wait.html/wait.html +0 -158
@@ -0,0 +1,714 @@
1
+ import { createLogger } from "../../logging/logger.js";
2
+ import { AuthenticationError } from "../../errors/authentication.js";
3
+ import { init_errors } from "../../errors/index.js";
4
+ import { getWorkspaceClient, isInUserContext } from "../../context/execution-context.js";
5
+ import { init_context } from "../../context/index.js";
6
+ import { ResourceType } from "../../registry/types.generated.js";
7
+ import "../../registry/index.js";
8
+ import { Plugin } from "../../plugin/plugin.js";
9
+ import { toPlugin } from "../../plugin/to-plugin.js";
10
+ import "../../plugin/index.js";
11
+ import { contentTypeFromPath, isSafeInlineContentType, validateCustomContentTypes } from "../../connectors/files/defaults.js";
12
+ import { FilesConnector } from "../../connectors/files/client.js";
13
+ import "../../connectors/files/index.js";
14
+ import { FILES_DOWNLOAD_DEFAULTS, FILES_MAX_UPLOAD_SIZE, FILES_READ_DEFAULTS, FILES_WRITE_DEFAULTS } from "./defaults.js";
15
+ import { parentDirectory, sanitizeFilename } from "./helpers.js";
16
+ import manifest_default from "./manifest.js";
17
+ import { ApiError } from "@databricks/sdk-experimental";
18
+ import { Readable } from "node:stream";
19
+
20
+ //#region src/plugins/files/plugin.ts
21
+ init_context();
22
+ init_errors();
23
+ const logger = createLogger("files");
24
+ var FilesPlugin = class FilesPlugin extends Plugin {
25
+ name = "files";
26
+ /** Plugin manifest declaring metadata and resource requirements. */
27
+ static manifest = manifest_default;
28
+ static description = "Files plugin for Databricks file operations";
29
+ volumeConnectors = {};
30
+ volumeConfigs = {};
31
+ volumeKeys = [];
32
+ /**
33
+ * Scans `process.env` for `DATABRICKS_VOLUME_*` keys and merges them with
34
+ * any explicitly configured volumes. Explicit config wins for per-volume
35
+ * overrides; auto-discovered volumes get default `{}` config.
36
+ */
37
+ static discoverVolumes(config) {
38
+ const explicit = config.volumes ?? {};
39
+ const discovered = {};
40
+ const prefix = "DATABRICKS_VOLUME_";
41
+ for (const key of Object.keys(process.env)) {
42
+ if (!key.startsWith(prefix)) continue;
43
+ const suffix = key.slice(18);
44
+ if (!suffix) continue;
45
+ if (!process.env[key]) continue;
46
+ const volumeKey = suffix.toLowerCase();
47
+ if (!(volumeKey in explicit)) discovered[volumeKey] = {};
48
+ }
49
+ return {
50
+ ...discovered,
51
+ ...explicit
52
+ };
53
+ }
54
+ /**
55
+ * Generates resource requirements dynamically from discovered + configured volumes.
56
+ * Each volume key maps to a `DATABRICKS_VOLUME_{KEY_UPPERCASE}` env var.
57
+ */
58
+ static getResourceRequirements(config) {
59
+ const volumes = FilesPlugin.discoverVolumes(config);
60
+ return Object.keys(volumes).map((key) => ({
61
+ type: ResourceType.VOLUME,
62
+ alias: `volume-${key}`,
63
+ resourceKey: `volume-${key}`,
64
+ description: `Unity Catalog Volume for "${key}" file storage`,
65
+ permission: "WRITE_VOLUME",
66
+ fields: { path: {
67
+ env: `DATABRICKS_VOLUME_${key.toUpperCase()}`,
68
+ description: `Volume path for "${key}" (e.g. /Volumes/catalog/schema/volume_name)`
69
+ } },
70
+ required: true
71
+ }));
72
+ }
73
+ /**
74
+ * Warns when a method is called without a user context (i.e. as service principal).
75
+ * OBO access via `asUser(req)` is strongly recommended.
76
+ */
77
+ warnIfNoUserContext(volumeKey, method) {
78
+ if (!isInUserContext()) logger.warn(`app.files("${volumeKey}").${method}() called without user context (service principal). Please use OBO instead: app.files("${volumeKey}").asUser(req).${method}()`);
79
+ }
80
+ /**
81
+ * Throws when a method is called without a user context (i.e. as service principal).
82
+ * OBO access via `asUser(req)` is enforced for now.
83
+ */
84
+ throwIfNoUserContext(volumeKey, method) {
85
+ if (!isInUserContext()) throw new Error(`app.files("${volumeKey}").${method}() called without user context (service principal). Use OBO instead: app.files("${volumeKey}").asUser(req).${method}()`);
86
+ }
87
+ constructor(config) {
88
+ super(config);
89
+ this.config = config;
90
+ if (config.customContentTypes) validateCustomContentTypes(config.customContentTypes);
91
+ const volumes = FilesPlugin.discoverVolumes(config);
92
+ this.volumeKeys = Object.keys(volumes);
93
+ for (const key of this.volumeKeys) {
94
+ const volumeCfg = volumes[key];
95
+ const envVar = `DATABRICKS_VOLUME_${key.toUpperCase()}`;
96
+ const volumePath = process.env[envVar];
97
+ const mergedConfig = {
98
+ maxUploadSize: volumeCfg.maxUploadSize ?? config.maxUploadSize,
99
+ customContentTypes: volumeCfg.customContentTypes ?? config.customContentTypes
100
+ };
101
+ this.volumeConfigs[key] = mergedConfig;
102
+ this.volumeConnectors[key] = new FilesConnector({
103
+ defaultVolume: volumePath,
104
+ timeout: config.timeout,
105
+ telemetry: config.telemetry,
106
+ customContentTypes: mergedConfig.customContentTypes
107
+ });
108
+ }
109
+ }
110
+ /**
111
+ * Creates a VolumeAPI for a specific volume key.
112
+ * Each method warns if called outside a user context (service principal).
113
+ */
114
+ createVolumeAPI(volumeKey) {
115
+ const connector = this.volumeConnectors[volumeKey];
116
+ return {
117
+ list: (directoryPath) => {
118
+ this.throwIfNoUserContext(volumeKey, `list`);
119
+ return connector.list(getWorkspaceClient(), directoryPath);
120
+ },
121
+ read: (filePath, options) => {
122
+ this.throwIfNoUserContext(volumeKey, `read`);
123
+ return connector.read(getWorkspaceClient(), filePath, options);
124
+ },
125
+ download: (filePath) => {
126
+ this.throwIfNoUserContext(volumeKey, `download`);
127
+ return connector.download(getWorkspaceClient(), filePath);
128
+ },
129
+ exists: (filePath) => {
130
+ this.throwIfNoUserContext(volumeKey, `exists`);
131
+ return connector.exists(getWorkspaceClient(), filePath);
132
+ },
133
+ metadata: (filePath) => {
134
+ this.throwIfNoUserContext(volumeKey, `metadata`);
135
+ return connector.metadata(getWorkspaceClient(), filePath);
136
+ },
137
+ upload: (filePath, contents, options) => {
138
+ this.throwIfNoUserContext(volumeKey, `upload`);
139
+ return connector.upload(getWorkspaceClient(), filePath, contents, options);
140
+ },
141
+ createDirectory: (directoryPath) => {
142
+ this.throwIfNoUserContext(volumeKey, `createDirectory`);
143
+ return connector.createDirectory(getWorkspaceClient(), directoryPath);
144
+ },
145
+ delete: (filePath) => {
146
+ this.throwIfNoUserContext(volumeKey, `delete`);
147
+ return connector.delete(getWorkspaceClient(), filePath);
148
+ },
149
+ preview: (filePath) => {
150
+ this.throwIfNoUserContext(volumeKey, `preview`);
151
+ return connector.preview(getWorkspaceClient(), filePath);
152
+ }
153
+ };
154
+ }
155
+ injectRoutes(router) {
156
+ this.route(router, {
157
+ name: "volumes",
158
+ method: "get",
159
+ path: "/volumes",
160
+ handler: async (_req, res) => {
161
+ res.json({ volumes: this.volumeKeys });
162
+ }
163
+ });
164
+ this.route(router, {
165
+ name: "list",
166
+ method: "get",
167
+ path: "/:volumeKey/list",
168
+ handler: async (req, res) => {
169
+ const { connector, volumeKey } = this._resolveVolume(req, res);
170
+ if (!connector) return;
171
+ await this._handleList(req, res, connector, volumeKey);
172
+ }
173
+ });
174
+ this.route(router, {
175
+ name: "read",
176
+ method: "get",
177
+ path: "/:volumeKey/read",
178
+ handler: async (req, res) => {
179
+ const { connector, volumeKey } = this._resolveVolume(req, res);
180
+ if (!connector) return;
181
+ await this._handleRead(req, res, connector, volumeKey);
182
+ }
183
+ });
184
+ this.route(router, {
185
+ name: "download",
186
+ method: "get",
187
+ path: "/:volumeKey/download",
188
+ handler: async (req, res) => {
189
+ const { connector, volumeKey } = this._resolveVolume(req, res);
190
+ if (!connector) return;
191
+ await this._handleDownload(req, res, connector, volumeKey);
192
+ }
193
+ });
194
+ this.route(router, {
195
+ name: "raw",
196
+ method: "get",
197
+ path: "/:volumeKey/raw",
198
+ handler: async (req, res) => {
199
+ const { connector, volumeKey } = this._resolveVolume(req, res);
200
+ if (!connector) return;
201
+ await this._handleRaw(req, res, connector, volumeKey);
202
+ }
203
+ });
204
+ this.route(router, {
205
+ name: "exists",
206
+ method: "get",
207
+ path: "/:volumeKey/exists",
208
+ handler: async (req, res) => {
209
+ const { connector, volumeKey } = this._resolveVolume(req, res);
210
+ if (!connector) return;
211
+ await this._handleExists(req, res, connector, volumeKey);
212
+ }
213
+ });
214
+ this.route(router, {
215
+ name: "metadata",
216
+ method: "get",
217
+ path: "/:volumeKey/metadata",
218
+ handler: async (req, res) => {
219
+ const { connector, volumeKey } = this._resolveVolume(req, res);
220
+ if (!connector) return;
221
+ await this._handleMetadata(req, res, connector, volumeKey);
222
+ }
223
+ });
224
+ this.route(router, {
225
+ name: "preview",
226
+ method: "get",
227
+ path: "/:volumeKey/preview",
228
+ handler: async (req, res) => {
229
+ const { connector, volumeKey } = this._resolveVolume(req, res);
230
+ if (!connector) return;
231
+ await this._handlePreview(req, res, connector, volumeKey);
232
+ }
233
+ });
234
+ this.route(router, {
235
+ name: "upload",
236
+ method: "post",
237
+ path: "/:volumeKey/upload",
238
+ skipBodyParsing: true,
239
+ handler: async (req, res) => {
240
+ const { connector, volumeKey } = this._resolveVolume(req, res);
241
+ if (!connector) return;
242
+ await this._handleUpload(req, res, connector, volumeKey);
243
+ }
244
+ });
245
+ this.route(router, {
246
+ name: "mkdir",
247
+ method: "post",
248
+ path: "/:volumeKey/mkdir",
249
+ handler: async (req, res) => {
250
+ const { connector, volumeKey } = this._resolveVolume(req, res);
251
+ if (!connector) return;
252
+ await this._handleMkdir(req, res, connector, volumeKey);
253
+ }
254
+ });
255
+ this.route(router, {
256
+ name: "delete",
257
+ method: "delete",
258
+ path: "/:volumeKey",
259
+ handler: async (req, res) => {
260
+ const { connector, volumeKey } = this._resolveVolume(req, res);
261
+ if (!connector) return;
262
+ await this._handleDelete(req, res, connector, volumeKey);
263
+ }
264
+ });
265
+ }
266
+ /**
267
+ * Resolve `:volumeKey` from the request. Returns the connector and key,
268
+ * or sends a 404 and returns `{ connector: undefined }`.
269
+ */
270
+ _resolveVolume(req, res) {
271
+ const volumeKey = req.params.volumeKey;
272
+ const connector = this.volumeConnectors[volumeKey];
273
+ if (!connector) {
274
+ const safeKey = volumeKey.replace(/[^a-zA-Z0-9_-]/g, "");
275
+ res.status(404).json({
276
+ error: `Unknown volume "${safeKey}"`,
277
+ plugin: this.name
278
+ });
279
+ return {
280
+ connector: void 0,
281
+ volumeKey: void 0
282
+ };
283
+ }
284
+ return {
285
+ connector,
286
+ volumeKey
287
+ };
288
+ }
289
+ /**
290
+ * Validate a file/directory path from user input.
291
+ * Returns `true` if valid, or an error message string if invalid.
292
+ */
293
+ _isValidPath(path) {
294
+ if (!path) return "path is required";
295
+ if (path.length > 4096) return `path exceeds maximum length of 4096 characters (got ${path.length})`;
296
+ if (path.includes("\0")) return "path must not contain null bytes";
297
+ return true;
298
+ }
299
+ _readSettings(cacheKey) {
300
+ return { default: {
301
+ ...FILES_READ_DEFAULTS,
302
+ cache: {
303
+ ...FILES_READ_DEFAULTS.cache,
304
+ cacheKey
305
+ }
306
+ } };
307
+ }
308
+ /**
309
+ * Invalidate cached list entries for a directory after a write operation.
310
+ * Uses the same cache-key format as `_handleList`: resolved path for
311
+ * subdirectories, `"__root__"` for the volume root.
312
+ */
313
+ _invalidateListCache(volumeKey, parentPath, userId, connector) {
314
+ const parent = parentDirectory(parentPath);
315
+ const cachePathSegment = parent ? connector.resolvePath(parent) : "__root__";
316
+ const listKey = this.cache.generateKey([`files:${volumeKey}:list`, cachePathSegment], userId);
317
+ this.cache.delete(listKey);
318
+ }
319
+ _handleApiError(res, error, fallbackMessage) {
320
+ if (error instanceof AuthenticationError) {
321
+ res.status(401).json({
322
+ error: error.message,
323
+ plugin: this.name
324
+ });
325
+ return;
326
+ }
327
+ if (error instanceof ApiError) {
328
+ const status = error.statusCode ?? 500;
329
+ if (status >= 400 && status < 500) {
330
+ res.status(status).json({
331
+ error: error.message,
332
+ statusCode: status,
333
+ plugin: this.name
334
+ });
335
+ return;
336
+ }
337
+ logger.error("Upstream server error in %s: %O", this.name, error);
338
+ res.status(500).json({
339
+ error: fallbackMessage,
340
+ plugin: this.name
341
+ });
342
+ return;
343
+ }
344
+ logger.error("Unhandled error in %s: %O", this.name, error);
345
+ res.status(500).json({
346
+ error: fallbackMessage,
347
+ plugin: this.name
348
+ });
349
+ }
350
+ async _handleList(req, res, connector, volumeKey) {
351
+ const path = req.query.path;
352
+ try {
353
+ const result = await this.asUser(req).execute(async () => {
354
+ this.warnIfNoUserContext(volumeKey, `list`);
355
+ return connector.list(getWorkspaceClient(), path);
356
+ }, this._readSettings([`files:${volumeKey}:list`, path ? connector.resolvePath(path) : "__root__"]));
357
+ if (result === void 0) {
358
+ res.status(500).json({
359
+ error: "List failed",
360
+ plugin: this.name
361
+ });
362
+ return;
363
+ }
364
+ res.json(result);
365
+ } catch (error) {
366
+ this._handleApiError(res, error, "List failed");
367
+ }
368
+ }
369
+ async _handleRead(req, res, connector, volumeKey) {
370
+ const path = req.query.path;
371
+ const valid = this._isValidPath(path);
372
+ if (valid !== true) {
373
+ res.status(400).json({
374
+ error: valid,
375
+ plugin: this.name
376
+ });
377
+ return;
378
+ }
379
+ try {
380
+ const result = await this.asUser(req).execute(async () => {
381
+ this.warnIfNoUserContext(volumeKey, `read`);
382
+ return connector.read(getWorkspaceClient(), path);
383
+ }, this._readSettings([`files:${volumeKey}:read`, connector.resolvePath(path)]));
384
+ if (result === void 0) {
385
+ res.status(500).json({
386
+ error: "Read failed",
387
+ plugin: this.name
388
+ });
389
+ return;
390
+ }
391
+ res.type("text/plain").send(result);
392
+ } catch (error) {
393
+ this._handleApiError(res, error, "Read failed");
394
+ }
395
+ }
396
+ async _handleDownload(req, res, connector, volumeKey) {
397
+ return this._serveFile(req, res, connector, volumeKey, { mode: "download" });
398
+ }
399
+ async _handleRaw(req, res, connector, volumeKey) {
400
+ return this._serveFile(req, res, connector, volumeKey, { mode: "raw" });
401
+ }
402
+ /**
403
+ * Shared handler for `/download` and `/raw` endpoints.
404
+ * - `download`: always forces `Content-Disposition: attachment`.
405
+ * - `raw`: adds CSP sandbox; forces attachment only for unsafe content types.
406
+ */
407
+ async _serveFile(req, res, connector, volumeKey, opts) {
408
+ const path = req.query.path;
409
+ const valid = this._isValidPath(path);
410
+ if (valid !== true) {
411
+ res.status(400).json({
412
+ error: valid,
413
+ plugin: this.name
414
+ });
415
+ return;
416
+ }
417
+ const label = opts.mode === "download" ? "Download" : "Raw fetch";
418
+ const volumeCfg = this.volumeConfigs[volumeKey];
419
+ try {
420
+ const userPlugin = this.asUser(req);
421
+ const settings = { default: FILES_DOWNLOAD_DEFAULTS };
422
+ const response = await userPlugin.execute(async () => {
423
+ this.warnIfNoUserContext(volumeKey, `download`);
424
+ return connector.download(getWorkspaceClient(), path);
425
+ }, settings);
426
+ if (response === void 0) {
427
+ res.status(500).json({
428
+ error: `${label} failed`,
429
+ plugin: this.name
430
+ });
431
+ return;
432
+ }
433
+ const resolvedType = contentTypeFromPath(path, void 0, volumeCfg.customContentTypes);
434
+ const fileName = sanitizeFilename(path.split("/").pop() ?? "download");
435
+ res.setHeader("Content-Type", resolvedType);
436
+ res.setHeader("X-Content-Type-Options", "nosniff");
437
+ if (opts.mode === "raw") {
438
+ res.setHeader("Content-Security-Policy", "sandbox");
439
+ if (!isSafeInlineContentType(resolvedType)) res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
440
+ } else res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
441
+ if (response.contents) {
442
+ const nodeStream = Readable.fromWeb(response.contents);
443
+ nodeStream.on("error", (err) => {
444
+ logger.error("Stream error during %s: %O", opts.mode, err);
445
+ if (!res.headersSent) res.status(500).json({
446
+ error: `${label} failed`,
447
+ plugin: this.name
448
+ });
449
+ else res.destroy();
450
+ });
451
+ nodeStream.pipe(res);
452
+ } else res.end();
453
+ } catch (error) {
454
+ this._handleApiError(res, error, `${label} failed`);
455
+ }
456
+ }
457
+ async _handleExists(req, res, connector, volumeKey) {
458
+ const path = req.query.path;
459
+ const valid = this._isValidPath(path);
460
+ if (valid !== true) {
461
+ res.status(400).json({
462
+ error: valid,
463
+ plugin: this.name
464
+ });
465
+ return;
466
+ }
467
+ try {
468
+ const result = await this.asUser(req).execute(async () => {
469
+ this.warnIfNoUserContext(volumeKey, `exists`);
470
+ return connector.exists(getWorkspaceClient(), path);
471
+ }, this._readSettings([`files:${volumeKey}:exists`, connector.resolvePath(path)]));
472
+ if (result === void 0) {
473
+ res.status(500).json({
474
+ error: "Exists check failed",
475
+ plugin: this.name
476
+ });
477
+ return;
478
+ }
479
+ res.json({ exists: result });
480
+ } catch (error) {
481
+ this._handleApiError(res, error, "Exists check failed");
482
+ }
483
+ }
484
+ async _handleMetadata(req, res, connector, volumeKey) {
485
+ const path = req.query.path;
486
+ const valid = this._isValidPath(path);
487
+ if (valid !== true) {
488
+ res.status(400).json({
489
+ error: valid,
490
+ plugin: this.name
491
+ });
492
+ return;
493
+ }
494
+ try {
495
+ const result = await this.asUser(req).execute(async () => {
496
+ this.warnIfNoUserContext(volumeKey, `metadata`);
497
+ return connector.metadata(getWorkspaceClient(), path);
498
+ }, this._readSettings([`files:${volumeKey}:metadata`, connector.resolvePath(path)]));
499
+ if (result === void 0) {
500
+ res.status(500).json({
501
+ error: "Metadata fetch failed",
502
+ plugin: this.name
503
+ });
504
+ return;
505
+ }
506
+ res.json(result);
507
+ } catch (error) {
508
+ this._handleApiError(res, error, "Metadata fetch failed");
509
+ }
510
+ }
511
+ async _handlePreview(req, res, connector, volumeKey) {
512
+ const path = req.query.path;
513
+ const valid = this._isValidPath(path);
514
+ if (valid !== true) {
515
+ res.status(400).json({
516
+ error: valid,
517
+ plugin: this.name
518
+ });
519
+ return;
520
+ }
521
+ try {
522
+ const result = await this.asUser(req).execute(async () => {
523
+ this.warnIfNoUserContext(volumeKey, `preview`);
524
+ return connector.preview(getWorkspaceClient(), path);
525
+ }, this._readSettings([`files:${volumeKey}:preview`, connector.resolvePath(path)]));
526
+ if (result === void 0) {
527
+ res.status(500).json({
528
+ error: "Preview failed",
529
+ plugin: this.name
530
+ });
531
+ return;
532
+ }
533
+ res.json(result);
534
+ } catch (error) {
535
+ this._handleApiError(res, error, "Preview failed");
536
+ }
537
+ }
538
+ async _handleUpload(req, res, connector, volumeKey) {
539
+ const path = req.query.path;
540
+ const valid = this._isValidPath(path);
541
+ if (valid !== true) {
542
+ res.status(400).json({
543
+ error: valid,
544
+ plugin: this.name
545
+ });
546
+ return;
547
+ }
548
+ const maxSize = this.volumeConfigs[volumeKey].maxUploadSize ?? FILES_MAX_UPLOAD_SIZE;
549
+ const rawContentLength = req.headers["content-length"];
550
+ const contentLength = rawContentLength ? parseInt(rawContentLength, 10) : void 0;
551
+ if (contentLength !== void 0 && !Number.isNaN(contentLength) && contentLength > maxSize) {
552
+ res.status(413).json({
553
+ error: `File size (${contentLength} bytes) exceeds maximum allowed size (${maxSize} bytes).`,
554
+ plugin: this.name
555
+ });
556
+ return;
557
+ }
558
+ logger.debug(req, "Upload started: volume=%s path=%s", volumeKey, path);
559
+ try {
560
+ const rawStream = Readable.toWeb(req);
561
+ let bytesReceived = 0;
562
+ const webStream = rawStream.pipeThrough(new TransformStream({ transform(chunk, controller) {
563
+ bytesReceived += chunk.byteLength;
564
+ if (bytesReceived > maxSize) {
565
+ controller.error(/* @__PURE__ */ new Error(`Upload stream exceeds maximum allowed size (${maxSize} bytes)`));
566
+ return;
567
+ }
568
+ controller.enqueue(chunk);
569
+ } }));
570
+ logger.debug(req, "Upload body received: volume=%s path=%s, size=%d bytes", volumeKey, path, contentLength ?? 0);
571
+ const userPlugin = this.asUser(req);
572
+ const settings = { default: FILES_WRITE_DEFAULTS };
573
+ const result = await this.trackWrite(() => userPlugin.execute(async () => {
574
+ this.warnIfNoUserContext(volumeKey, `upload`);
575
+ await connector.upload(getWorkspaceClient(), path, webStream);
576
+ return { success: true };
577
+ }, settings));
578
+ this._invalidateListCache(volumeKey, path, this.resolveUserId(req), connector);
579
+ if (result === void 0) {
580
+ logger.error(req, "Upload failed: volume=%s path=%s, size=%d bytes", volumeKey, path, contentLength ?? 0);
581
+ res.status(500).json({
582
+ error: "Upload failed",
583
+ plugin: this.name
584
+ });
585
+ return;
586
+ }
587
+ logger.debug(req, "Upload complete: volume=%s path=%s", volumeKey, path);
588
+ res.json(result);
589
+ } catch (error) {
590
+ if (error instanceof Error && error.message.includes("exceeds maximum allowed size")) {
591
+ res.status(413).json({
592
+ error: error.message,
593
+ plugin: this.name
594
+ });
595
+ return;
596
+ }
597
+ this._handleApiError(res, error, "Upload failed");
598
+ }
599
+ }
600
+ async _handleMkdir(req, res, connector, volumeKey) {
601
+ const dirPath = typeof req.body?.path === "string" ? req.body.path : void 0;
602
+ const valid = this._isValidPath(dirPath);
603
+ if (valid !== true) {
604
+ res.status(400).json({
605
+ error: valid,
606
+ plugin: this.name
607
+ });
608
+ return;
609
+ }
610
+ try {
611
+ const userPlugin = this.asUser(req);
612
+ const settings = { default: FILES_WRITE_DEFAULTS };
613
+ const result = await this.trackWrite(() => userPlugin.execute(async () => {
614
+ this.warnIfNoUserContext(volumeKey, `createDirectory`);
615
+ await connector.createDirectory(getWorkspaceClient(), dirPath);
616
+ return { success: true };
617
+ }, settings));
618
+ this._invalidateListCache(volumeKey, dirPath, this.resolveUserId(req), connector);
619
+ if (result === void 0) {
620
+ res.status(500).json({
621
+ error: "Create directory failed",
622
+ plugin: this.name
623
+ });
624
+ return;
625
+ }
626
+ res.json(result);
627
+ } catch (error) {
628
+ this._handleApiError(res, error, "Create directory failed");
629
+ }
630
+ }
631
+ async _handleDelete(req, res, connector, volumeKey) {
632
+ const rawPath = req.query.path;
633
+ const valid = this._isValidPath(rawPath);
634
+ if (valid !== true) {
635
+ res.status(400).json({
636
+ error: valid,
637
+ plugin: this.name
638
+ });
639
+ return;
640
+ }
641
+ const path = rawPath;
642
+ try {
643
+ const userPlugin = this.asUser(req);
644
+ const settings = { default: FILES_WRITE_DEFAULTS };
645
+ const result = await this.trackWrite(() => userPlugin.execute(async () => {
646
+ this.warnIfNoUserContext(volumeKey, `delete`);
647
+ await connector.delete(getWorkspaceClient(), path);
648
+ return { success: true };
649
+ }, settings));
650
+ this._invalidateListCache(volumeKey, path, this.resolveUserId(req), connector);
651
+ if (result === void 0) {
652
+ res.status(500).json({
653
+ error: "Delete failed",
654
+ plugin: this.name
655
+ });
656
+ return;
657
+ }
658
+ res.json(result);
659
+ } catch (error) {
660
+ this._handleApiError(res, error, "Delete failed");
661
+ }
662
+ }
663
+ inflightWrites = 0;
664
+ trackWrite(fn) {
665
+ this.inflightWrites++;
666
+ return fn().finally(() => {
667
+ this.inflightWrites--;
668
+ });
669
+ }
670
+ async shutdown() {
671
+ const deadline = Date.now() + 1e4;
672
+ while (this.inflightWrites > 0 && Date.now() < deadline) {
673
+ logger.info("Waiting for %d in-flight write(s) to complete before shutdown…", this.inflightWrites);
674
+ await new Promise((resolve) => setTimeout(resolve, 500));
675
+ }
676
+ if (this.inflightWrites > 0) logger.warn("Shutdown deadline reached with %d in-flight write(s) still pending.", this.inflightWrites);
677
+ this.streamManager.abortAll();
678
+ }
679
+ /**
680
+ * Returns the programmatic API for the Files plugin.
681
+ * Callable with a volume key to get a volume-scoped handle.
682
+ *
683
+ * @example
684
+ * ```ts
685
+ * // OBO access (recommended)
686
+ * appKit.files("uploads").asUser(req).list()
687
+ *
688
+ * // Service principal access (logs a warning)
689
+ * appKit.files("uploads").list()
690
+ * ```
691
+ */
692
+ exports() {
693
+ const resolveVolume = (volumeKey) => {
694
+ if (!this.volumeKeys.includes(volumeKey)) throw new Error(`Unknown volume "${volumeKey}". Available volumes: ${this.volumeKeys.join(", ")}`);
695
+ return {
696
+ ...this.createVolumeAPI(volumeKey),
697
+ asUser: (req) => {
698
+ return this.asUser(req).createVolumeAPI(volumeKey);
699
+ }
700
+ };
701
+ };
702
+ const filesExport = ((volumeKey) => resolveVolume(volumeKey));
703
+ filesExport.volume = resolveVolume;
704
+ return filesExport;
705
+ }
706
+ };
707
+ /**
708
+ * @internal
709
+ */
710
+ const files$1 = toPlugin(FilesPlugin);
711
+
712
+ //#endregion
713
+ export { FilesPlugin, files$1 as files };
714
+ //# sourceMappingURL=plugin.js.map