@databricks/appkit 0.23.0 → 0.25.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.
- package/CLAUDE.md +9 -1
- package/dist/appkit/package.js +1 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/cli/commands/docs.js +7 -1
- package/dist/cli/commands/docs.js.map +1 -1
- package/dist/cli/commands/generate-types.js +20 -10
- package/dist/cli/commands/generate-types.js.map +1 -1
- package/dist/cli/commands/lint.js +3 -1
- package/dist/cli/commands/lint.js.map +1 -1
- package/dist/cli/commands/plugin/add-resource/add-resource.js +73 -8
- package/dist/cli/commands/plugin/add-resource/add-resource.js.map +1 -1
- package/dist/cli/commands/plugin/create/create.js +164 -20
- package/dist/cli/commands/plugin/create/create.js.map +1 -1
- package/dist/cli/commands/plugin/create/resource-defaults.js +5 -1
- package/dist/cli/commands/plugin/create/resource-defaults.js.map +1 -1
- package/dist/cli/commands/plugin/index.js +7 -1
- package/dist/cli/commands/plugin/index.js.map +1 -1
- package/dist/cli/commands/plugin/list/list.js +7 -1
- package/dist/cli/commands/plugin/list/list.js.map +1 -1
- package/dist/cli/commands/plugin/sync/sync.js +27 -14
- package/dist/cli/commands/plugin/sync/sync.js.map +1 -1
- package/dist/cli/commands/plugin/validate/validate.js +39 -9
- package/dist/cli/commands/plugin/validate/validate.js.map +1 -1
- package/dist/cli/commands/setup.js +6 -5
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/connectors/index.js +1 -0
- package/dist/connectors/lakebase/index.js.map +1 -1
- package/dist/connectors/lakebase-v1/client.js.map +1 -1
- package/dist/connectors/vector-search/client.js +9 -0
- package/dist/connectors/vector-search/client.js.map +1 -0
- package/dist/connectors/vector-search/index.js +3 -0
- package/dist/context/execution-context.js +1 -7
- package/dist/context/execution-context.js.map +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/index.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin/dev-reader.js.map +1 -1
- package/dist/plugins/files/plugin.d.ts +46 -15
- package/dist/plugins/files/plugin.d.ts.map +1 -1
- package/dist/plugins/files/plugin.js +182 -103
- package/dist/plugins/files/plugin.js.map +1 -1
- package/dist/plugins/files/policy.d.ts +45 -0
- package/dist/plugins/files/policy.d.ts.map +1 -0
- package/dist/plugins/files/policy.js +63 -0
- package/dist/plugins/files/policy.js.map +1 -0
- package/dist/plugins/files/types.d.ts +16 -8
- package/dist/plugins/files/types.d.ts.map +1 -1
- package/dist/plugins/server/vite-dev-server.js.map +1 -1
- package/dist/plugins/serving/serving.d.ts.map +1 -1
- package/dist/plugins/serving/serving.js +22 -8
- package/dist/plugins/serving/serving.js.map +1 -1
- package/dist/plugins/serving/types.d.ts +11 -10
- package/dist/plugins/serving/types.d.ts.map +1 -1
- package/dist/type-generator/index.js +13 -1
- package/dist/type-generator/index.js.map +1 -1
- package/dist/type-generator/migration.js +155 -0
- package/dist/type-generator/migration.js.map +1 -0
- package/dist/type-generator/serving/generator.js +22 -1
- package/dist/type-generator/serving/generator.js.map +1 -1
- package/dist/type-generator/serving/vite-plugin.d.ts +1 -1
- package/dist/type-generator/serving/vite-plugin.js +2 -2
- package/dist/type-generator/serving/vite-plugin.js.map +1 -1
- package/dist/type-generator/vite-plugin.d.ts.map +1 -1
- package/dist/type-generator/vite-plugin.js +3 -4
- package/dist/type-generator/vite-plugin.js.map +1 -1
- package/docs/api/appkit/Class.PolicyDeniedError.md +52 -0
- package/docs/api/appkit/Interface.FilePolicyUser.md +23 -0
- package/docs/api/appkit/Interface.FileResource.md +36 -0
- package/docs/api/appkit/TypeAlias.FileAction.md +18 -0
- package/docs/api/appkit/TypeAlias.FilePolicy.md +20 -0
- package/docs/api/appkit/TypeAlias.ServingFactory.md +9 -5
- package/docs/api/appkit/Variable.READ_ACTIONS.md +8 -0
- package/docs/api/appkit/Variable.WRITE_ACTIONS.md +8 -0
- package/docs/api/appkit.md +19 -12
- package/docs/development/type-generation.md +6 -5
- package/docs/faq.md +8 -8
- package/docs/plugins/analytics.md +1 -1
- package/docs/plugins/custom-plugins.md +4 -0
- package/docs/plugins/execution-context.md +0 -1
- package/docs/plugins/files.md +150 -2
- package/docs/plugins/{serving.md → model-serving.md} +1 -1
- package/docs/plugins/plugin-management.md +22 -6
- package/docs/plugins/vector-search.md +247 -0
- package/llms.txt +9 -1
- package/package.json +1 -1
- package/sbom.cdx.json +1 -1
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { Plugin } from "./plugin/plugin.js";
|
|
|
24
24
|
import { toPlugin } from "./plugin/to-plugin.js";
|
|
25
25
|
import "./plugin/index.js";
|
|
26
26
|
import { analytics } from "./plugins/analytics/analytics.js";
|
|
27
|
+
import { PolicyDeniedError, READ_ACTIONS, WRITE_ACTIONS } from "./plugins/files/policy.js";
|
|
27
28
|
import { files } from "./plugins/files/plugin.js";
|
|
28
29
|
import { genie } from "./plugins/genie/genie.js";
|
|
29
30
|
import { lakebase } from "./plugins/lakebase/lakebase.js";
|
|
@@ -39,5 +40,5 @@ init_context();
|
|
|
39
40
|
init_errors();
|
|
40
41
|
|
|
41
42
|
//#endregion
|
|
42
|
-
export { AppKitError, AuthenticationError, CacheManager, ConfigurationError, ConnectionError, ExecutionError, InitializationError, Plugin, RequestedClaimsPermissionSet, ResourceRegistry, ResourceType, ServerError, SeverityNumber, SpanStatusCode, TunnelError, ValidationError, analytics, appKitServingTypesPlugin, appKitTypesPlugin, createApp, createLakebasePool, extractServingEndpoints, files, findServerFile, generateDatabaseCredential, genie, getExecutionContext, getLakebaseOrmConfig, getLakebasePgConfig, getPluginManifest, getResourceRequirements, getUsernameWithApiLookup, getWorkspaceClient, isSQLTypeMarker, lakebase, server, serving, sql, toPlugin };
|
|
43
|
+
export { AppKitError, AuthenticationError, CacheManager, ConfigurationError, ConnectionError, ExecutionError, InitializationError, Plugin, PolicyDeniedError, READ_ACTIONS, RequestedClaimsPermissionSet, ResourceRegistry, ResourceType, ServerError, SeverityNumber, SpanStatusCode, TunnelError, ValidationError, WRITE_ACTIONS, analytics, appKitServingTypesPlugin, appKitTypesPlugin, createApp, createLakebasePool, extractServingEndpoints, files, findServerFile, generateDatabaseCredential, genie, getExecutionContext, getLakebaseOrmConfig, getLakebasePgConfig, getPluginManifest, getResourceRequirements, getUsernameWithApiLookup, getWorkspaceClient, isSQLTypeMarker, lakebase, server, serving, sql, toPlugin };
|
|
43
44
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n *\n * Core library for building Databricks applications with type-safe SQL queries,\n * plugin architecture, and React integration.\n */\n\n// Types from shared\nexport type {\n BasePluginConfig,\n CacheConfig,\n IAppRouter,\n PluginData,\n StreamExecutionSettings,\n} from \"shared\";\nexport { isSQLTypeMarker, sql } from \"shared\";\nexport { CacheManager } from \"./cache\";\nexport type {\n DatabaseCredential,\n GenerateDatabaseCredentialRequest,\n LakebasePoolConfig,\n RequestedClaims,\n RequestedResource,\n} from \"./connectors/lakebase\";\n// Lakebase Autoscaling connector\nexport {\n createLakebasePool,\n generateDatabaseCredential,\n getLakebaseOrmConfig,\n getLakebasePgConfig,\n getUsernameWithApiLookup,\n getWorkspaceClient,\n RequestedClaimsPermissionSet,\n} from \"./connectors/lakebase\";\nexport { getExecutionContext } from \"./context\";\nexport { createApp } from \"./core\";\n// Errors\nexport {\n AppKitError,\n AuthenticationError,\n ConfigurationError,\n ConnectionError,\n ExecutionError,\n InitializationError,\n ServerError,\n TunnelError,\n ValidationError,\n} from \"./errors\";\n// Plugin authoring\nexport {\n type ExecutionResult,\n Plugin,\n type ToPlugin,\n toPlugin,\n} from \"./plugin\";\nexport { analytics, files, genie, lakebase, server, serving } from \"./plugins\";\nexport type {\n EndpointConfig,\n ServingEndpointEntry,\n ServingEndpointRegistry,\n ServingFactory,\n} from \"./plugins/serving/types\";\n// Registry types and utilities for plugin manifests\nexport type {\n ConfigSchema,\n PluginManifest,\n ResourceEntry,\n ResourceFieldEntry,\n ResourcePermission,\n ResourceRequirement,\n ValidationResult,\n} from \"./registry\";\nexport {\n getPluginManifest,\n getResourceRequirements,\n ResourceRegistry,\n ResourceType,\n} from \"./registry\";\n// Telemetry (for advanced custom telemetry)\nexport {\n type Counter,\n type Histogram,\n type ITelemetry,\n SeverityNumber,\n type Span,\n SpanStatusCode,\n type TelemetryConfig,\n} from \"./telemetry\";\nexport {\n extractServingEndpoints,\n findServerFile,\n} from \"./type-generator/serving/server-file-extractor\";\nexport { appKitServingTypesPlugin } from \"./type-generator/serving/vite-plugin\";\n// Vite plugin and type generation\nexport { appKitTypesPlugin } from \"./type-generator/vite-plugin\";\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n *\n * Core library for building Databricks applications with type-safe SQL queries,\n * plugin architecture, and React integration.\n */\n\n// Types from shared\nexport type {\n BasePluginConfig,\n CacheConfig,\n IAppRouter,\n PluginData,\n StreamExecutionSettings,\n} from \"shared\";\nexport { isSQLTypeMarker, sql } from \"shared\";\nexport { CacheManager } from \"./cache\";\nexport type {\n DatabaseCredential,\n GenerateDatabaseCredentialRequest,\n LakebasePoolConfig,\n RequestedClaims,\n RequestedResource,\n} from \"./connectors/lakebase\";\n// Lakebase Autoscaling connector\nexport {\n createLakebasePool,\n generateDatabaseCredential,\n getLakebaseOrmConfig,\n getLakebasePgConfig,\n getUsernameWithApiLookup,\n getWorkspaceClient,\n RequestedClaimsPermissionSet,\n} from \"./connectors/lakebase\";\nexport { getExecutionContext } from \"./context\";\nexport { createApp } from \"./core\";\n// Errors\nexport {\n AppKitError,\n AuthenticationError,\n ConfigurationError,\n ConnectionError,\n ExecutionError,\n InitializationError,\n ServerError,\n TunnelError,\n ValidationError,\n} from \"./errors\";\n// Plugin authoring\nexport {\n type ExecutionResult,\n Plugin,\n type ToPlugin,\n toPlugin,\n} from \"./plugin\";\nexport { analytics, files, genie, lakebase, server, serving } from \"./plugins\";\n// Files plugin types (for custom policy authoring)\nexport type {\n FileAction,\n FilePolicy,\n FilePolicyUser,\n FileResource,\n} from \"./plugins/files/policy\";\nexport {\n PolicyDeniedError,\n READ_ACTIONS,\n WRITE_ACTIONS,\n} from \"./plugins/files/policy\";\nexport type {\n EndpointConfig,\n ServingEndpointEntry,\n ServingEndpointRegistry,\n ServingFactory,\n} from \"./plugins/serving/types\";\n// Registry types and utilities for plugin manifests\nexport type {\n ConfigSchema,\n PluginManifest,\n ResourceEntry,\n ResourceFieldEntry,\n ResourcePermission,\n ResourceRequirement,\n ValidationResult,\n} from \"./registry\";\nexport {\n getPluginManifest,\n getResourceRequirements,\n ResourceRegistry,\n ResourceType,\n} from \"./registry\";\n// Telemetry (for advanced custom telemetry)\nexport {\n type Counter,\n type Histogram,\n type ITelemetry,\n SeverityNumber,\n type Span,\n SpanStatusCode,\n type TelemetryConfig,\n} from \"./telemetry\";\nexport {\n extractServingEndpoints,\n findServerFile,\n} from \"./type-generator/serving/server-file-extractor\";\nexport { appKitServingTypesPlugin } from \"./type-generator/serving/vite-plugin\";\n// Vite plugin and type generation\nexport { appKitTypesPlugin } from \"./type-generator/vite-plugin\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAkCgD;aAa9B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dev-reader.js","names":[],"sources":["../../src/plugin/dev-reader.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport type { TunnelConnection } from \"shared\";\nimport {
|
|
1
|
+
{"version":3,"file":"dev-reader.js","names":[],"sources":["../../src/plugin/dev-reader.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport type { TunnelConnection } from \"shared\";\nimport { TunnelError } from \"../errors\";\nimport { createLogger } from \"../logging/logger\";\nimport { isRemoteTunnelAllowedByEnv } from \"../plugins/server/remote-tunnel/gate\";\n\nconst logger = createLogger(\"plugin:dev-reader\");\n\ntype TunnelConnectionGetter = (\n req: import(\"express\").Request,\n) => TunnelConnection | null;\n\n/**\n * This class is used to read files from the local filesystem in dev mode\n * through the WebSocket tunnel.\n */\nexport class DevFileReader {\n private static instance: DevFileReader | null = null;\n private getTunnelForRequest: TunnelConnectionGetter | null = null;\n\n private constructor() {}\n\n static getInstance(): DevFileReader {\n if (!DevFileReader.instance) {\n DevFileReader.instance = new Proxy(new DevFileReader(), {\n /**\n * We proxy the reader to return a noop function if the remote server is disabled.\n */\n get(target, prop, receiver) {\n if (isRemoteTunnelAllowedByEnv()) {\n return Reflect.get(target, prop, receiver);\n }\n\n const value = Reflect.get(target, prop, receiver);\n\n if (typeof value === \"function\") {\n return function noop() {\n logger.debug(\"Noop: %s (remote server disabled)\", String(prop));\n return Promise.resolve(\"\");\n };\n }\n\n return value;\n },\n set(target, prop, value, receiver) {\n return Reflect.set(target, prop, value, receiver);\n },\n });\n }\n\n return DevFileReader.instance;\n }\n\n registerTunnelGetter(getter: TunnelConnectionGetter) {\n this.getTunnelForRequest = getter;\n }\n\n async readFile(\n filePath: string,\n req: import(\"express\").Request,\n ): Promise<string> {\n if (!this.getTunnelForRequest) {\n throw TunnelError.getterNotRegistered();\n }\n const tunnel = this.getTunnelForRequest(req);\n\n if (!tunnel) {\n throw TunnelError.noConnection();\n }\n\n const { ws, pendingFileReads } = tunnel;\n const requestId = randomUUID();\n\n return new Promise((resolve, reject) => {\n const timeout = setTimeout(() => {\n pendingFileReads.delete(requestId);\n reject(new Error(`File read timeout: ${filePath}`));\n }, 10000);\n\n pendingFileReads.set(requestId, { resolve, reject, timeout });\n\n ws.send(\n JSON.stringify({\n type: \"file:read\",\n requestId,\n path: filePath,\n }),\n );\n });\n }\n\n async readdir(\n dirPath: string,\n req: import(\"express\").Request,\n ): Promise<string[]> {\n if (!this.getTunnelForRequest) {\n throw TunnelError.getterNotRegistered();\n }\n const tunnel = this.getTunnelForRequest(req);\n\n if (!tunnel) {\n throw TunnelError.noConnection();\n }\n\n const { ws, pendingFileReads } = tunnel;\n const requestId = randomUUID();\n\n return new Promise((resolve, reject) => {\n const timeout = setTimeout(() => {\n pendingFileReads.delete(requestId);\n reject(new Error(`Directory read timeout: ${dirPath}`));\n }, 10000);\n\n pendingFileReads.set(requestId, {\n resolve: (data: string) => {\n try {\n const files = JSON.parse(data);\n // Validate it's an array of strings\n if (!Array.isArray(files)) {\n reject(\n new Error(\n \"Invalid directory listing format: expected array, got \" +\n typeof files,\n ),\n );\n return;\n }\n if (!files.every((f) => typeof f === \"string\")) {\n reject(\n new Error(\n \"Invalid directory listing format: expected array of strings\",\n ),\n );\n return;\n }\n resolve(files);\n } catch (error) {\n reject(\n new Error(\n `Failed to parse directory listing: ${(error as Error).message}`,\n ),\n );\n }\n },\n reject,\n timeout,\n });\n\n ws.send(\n JSON.stringify({\n type: \"dir:list\",\n requestId,\n path: dirPath,\n }),\n );\n });\n }\n}\n"],"mappings":";;;;;;;aAEwC;AAIxC,MAAM,SAAS,aAAa,oBAAoB;;;;;AAUhD,IAAa,gBAAb,MAAa,cAAc;CACzB,OAAe,WAAiC;CAChD,AAAQ,sBAAqD;CAE7D,AAAQ,cAAc;CAEtB,OAAO,cAA6B;AAClC,MAAI,CAAC,cAAc,SACjB,eAAc,WAAW,IAAI,MAAM,IAAI,eAAe,EAAE;GAItD,IAAI,QAAQ,MAAM,UAAU;AAC1B,QAAI,4BAA4B,CAC9B,QAAO,QAAQ,IAAI,QAAQ,MAAM,SAAS;IAG5C,MAAM,QAAQ,QAAQ,IAAI,QAAQ,MAAM,SAAS;AAEjD,QAAI,OAAO,UAAU,WACnB,QAAO,SAAS,OAAO;AACrB,YAAO,MAAM,qCAAqC,OAAO,KAAK,CAAC;AAC/D,YAAO,QAAQ,QAAQ,GAAG;;AAI9B,WAAO;;GAET,IAAI,QAAQ,MAAM,OAAO,UAAU;AACjC,WAAO,QAAQ,IAAI,QAAQ,MAAM,OAAO,SAAS;;GAEpD,CAAC;AAGJ,SAAO,cAAc;;CAGvB,qBAAqB,QAAgC;AACnD,OAAK,sBAAsB;;CAG7B,MAAM,SACJ,UACA,KACiB;AACjB,MAAI,CAAC,KAAK,oBACR,OAAM,YAAY,qBAAqB;EAEzC,MAAM,SAAS,KAAK,oBAAoB,IAAI;AAE5C,MAAI,CAAC,OACH,OAAM,YAAY,cAAc;EAGlC,MAAM,EAAE,IAAI,qBAAqB;EACjC,MAAM,YAAY,YAAY;AAE9B,SAAO,IAAI,SAAS,SAAS,WAAW;GACtC,MAAM,UAAU,iBAAiB;AAC/B,qBAAiB,OAAO,UAAU;AAClC,2BAAO,IAAI,MAAM,sBAAsB,WAAW,CAAC;MAClD,IAAM;AAET,oBAAiB,IAAI,WAAW;IAAE;IAAS;IAAQ;IAAS,CAAC;AAE7D,MAAG,KACD,KAAK,UAAU;IACb,MAAM;IACN;IACA,MAAM;IACP,CAAC,CACH;IACD;;CAGJ,MAAM,QACJ,SACA,KACmB;AACnB,MAAI,CAAC,KAAK,oBACR,OAAM,YAAY,qBAAqB;EAEzC,MAAM,SAAS,KAAK,oBAAoB,IAAI;AAE5C,MAAI,CAAC,OACH,OAAM,YAAY,cAAc;EAGlC,MAAM,EAAE,IAAI,qBAAqB;EACjC,MAAM,YAAY,YAAY;AAE9B,SAAO,IAAI,SAAS,SAAS,WAAW;GACtC,MAAM,UAAU,iBAAiB;AAC/B,qBAAiB,OAAO,UAAU;AAClC,2BAAO,IAAI,MAAM,2BAA2B,UAAU,CAAC;MACtD,IAAM;AAET,oBAAiB,IAAI,WAAW;IAC9B,UAAU,SAAiB;AACzB,SAAI;MACF,MAAM,QAAQ,KAAK,MAAM,KAAK;AAE9B,UAAI,CAAC,MAAM,QAAQ,MAAM,EAAE;AACzB,8BACE,IAAI,MACF,2DACE,OAAO,MACV,CACF;AACD;;AAEF,UAAI,CAAC,MAAM,OAAO,MAAM,OAAO,MAAM,SAAS,EAAE;AAC9C,8BACE,IAAI,MACF,8DACD,CACF;AACD;;AAEF,cAAQ,MAAM;cACP,OAAO;AACd,6BACE,IAAI,MACF,sCAAuC,MAAgB,UACxD,CACF;;;IAGL;IACA;IACD,CAAC;AAEF,MAAG,KACD,KAAK,UAAU;IACb,MAAM;IACN;IACA,MAAM;IACP,CAAC,CACH;IACD"}
|
|
@@ -4,6 +4,7 @@ import { Plugin } from "../../plugin/plugin.js";
|
|
|
4
4
|
import "../../plugin/index.js";
|
|
5
5
|
import { PluginManifest, ResourceRequirement } from "../../registry/types.js";
|
|
6
6
|
import "../../registry/index.js";
|
|
7
|
+
import { FilePolicy, FilePolicyUser } from "./policy.js";
|
|
7
8
|
import { FilesExport, IFilesConfig, VolumeAPI, VolumeConfig } from "./types.js";
|
|
8
9
|
|
|
9
10
|
//#region src/plugins/files/plugin.d.ts
|
|
@@ -28,21 +29,22 @@ declare class FilesPlugin extends Plugin {
|
|
|
28
29
|
*/
|
|
29
30
|
static getResourceRequirements(config: IFilesConfig): ResourceRequirement[];
|
|
30
31
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
32
|
+
* Extract user identity from the request.
|
|
33
|
+
* Falls back to `getCurrentUserId()` in development mode.
|
|
33
34
|
*/
|
|
34
|
-
private
|
|
35
|
+
private _extractUser;
|
|
35
36
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
37
|
+
* Check the policy for a volume. No-op if no policy is configured.
|
|
38
|
+
* Throws `PolicyDeniedError` if denied.
|
|
38
39
|
*/
|
|
39
|
-
private
|
|
40
|
-
constructor(config: IFilesConfig);
|
|
40
|
+
private _checkPolicy;
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
* HTTP-level wrapper around `_checkPolicy`.
|
|
43
|
+
* Extracts user (401 on failure), runs policy (403 on denial).
|
|
44
|
+
* Returns `true` if the request may proceed, `false` if a response was sent.
|
|
44
45
|
*/
|
|
45
|
-
|
|
46
|
+
private _enforcePolicy;
|
|
47
|
+
constructor(config: IFilesConfig);
|
|
46
48
|
injectRoutes(router: IAppRouter): void;
|
|
47
49
|
/**
|
|
48
50
|
* Resolve `:volumeKey` from the request. Returns the connector and key,
|
|
@@ -59,6 +61,10 @@ declare class FilesPlugin extends Plugin {
|
|
|
59
61
|
* Invalidate cached list entries for a directory after a write operation.
|
|
60
62
|
* Uses the same cache-key format as `_handleList`: resolved path for
|
|
61
63
|
* subdirectories, `"__root__"` for the volume root.
|
|
64
|
+
*
|
|
65
|
+
* Cache keys include `getCurrentUserId()` — must match the identity used
|
|
66
|
+
* by `this.execute()` in `_handleList`. Both run in service-principal
|
|
67
|
+
* context; wrapping either in `runInUserContext` would break invalidation.
|
|
62
68
|
*/
|
|
63
69
|
private _invalidateListCache;
|
|
64
70
|
private _handleApiError;
|
|
@@ -79,6 +85,19 @@ declare class FilesPlugin extends Plugin {
|
|
|
79
85
|
private _handleUpload;
|
|
80
86
|
private _handleMkdir;
|
|
81
87
|
private _handleDelete;
|
|
88
|
+
/**
|
|
89
|
+
* Creates a VolumeAPI for a specific volume key.
|
|
90
|
+
*
|
|
91
|
+
* By default, enforces the volume's policy before each operation.
|
|
92
|
+
* Pass `bypassPolicy: true` to skip policy checks — useful for
|
|
93
|
+
* background jobs or migrations that should bypass user-facing policies.
|
|
94
|
+
*
|
|
95
|
+
* @security When `bypassPolicy` is `true`, no policy enforcement runs.
|
|
96
|
+
* Do not expose bypassed APIs to HTTP routes or end-user code paths.
|
|
97
|
+
*/
|
|
98
|
+
protected createVolumeAPI(volumeKey: string, user: FilePolicyUser, options?: {
|
|
99
|
+
bypassPolicy?: boolean;
|
|
100
|
+
}): VolumeAPI;
|
|
82
101
|
private inflightWrites;
|
|
83
102
|
private trackWrite;
|
|
84
103
|
shutdown(): Promise<void>;
|
|
@@ -86,13 +105,16 @@ declare class FilesPlugin extends Plugin {
|
|
|
86
105
|
* Returns the programmatic API for the Files plugin.
|
|
87
106
|
* Callable with a volume key to get a volume-scoped handle.
|
|
88
107
|
*
|
|
108
|
+
* All operations execute as the service principal.
|
|
109
|
+
* Use policies to control per-user access.
|
|
110
|
+
*
|
|
89
111
|
* @example
|
|
90
112
|
* ```ts
|
|
91
|
-
* //
|
|
92
|
-
* appKit.files("uploads").asUser(req).list()
|
|
93
|
-
*
|
|
94
|
-
* // Service principal access (logs a warning)
|
|
113
|
+
* // Service principal access
|
|
95
114
|
* appKit.files("uploads").list()
|
|
115
|
+
*
|
|
116
|
+
* // With policy: pass user identity for access control
|
|
117
|
+
* appKit.files("uploads").asUser(req).list()
|
|
96
118
|
* ```
|
|
97
119
|
*/
|
|
98
120
|
exports(): FilesExport;
|
|
@@ -101,7 +123,16 @@ declare class FilesPlugin extends Plugin {
|
|
|
101
123
|
/**
|
|
102
124
|
* @internal
|
|
103
125
|
*/
|
|
104
|
-
declare const files: ToPlugin<typeof FilesPlugin, IFilesConfig, string
|
|
126
|
+
declare const files: ToPlugin<typeof FilesPlugin, IFilesConfig, string> & {
|
|
127
|
+
policy: {
|
|
128
|
+
readonly all: (...policies: FilePolicy[]) => FilePolicy;
|
|
129
|
+
readonly any: (...policies: FilePolicy[]) => FilePolicy;
|
|
130
|
+
readonly not: (p: FilePolicy) => FilePolicy;
|
|
131
|
+
readonly publicRead: () => FilePolicy;
|
|
132
|
+
readonly denyAll: () => FilePolicy;
|
|
133
|
+
readonly allowAll: () => FilePolicy;
|
|
134
|
+
};
|
|
135
|
+
};
|
|
105
136
|
//#endregion
|
|
106
137
|
export { FilesPlugin, files };
|
|
107
138
|
//# sourceMappingURL=plugin.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","names":[],"sources":["../../../src/plugins/files/plugin.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","names":[],"sources":["../../../src/plugins/files/plugin.ts"],"mappings":";;;;;;;;;;cA2Ca,WAAA,SAAoB,MAAA;EAC/B,IAAA;;SAGO,QAAA,EAAuB,cAAA;EAAA,iBACb,WAAA;EAAA,UACC,MAAA,EAAQ,YAAA;EAAA,QAElB,gBAAA;EAAA,QACA,aAAA;EAAA,QACA,UAAA;EAJkB;;;;;EAAA,OAWnB,eAAA,CAAgB,MAAA,EAAQ,YAAA,GAAe,MAAA,SAAe,YAAA;EAuIzC;;;;EAAA,OAhHb,uBAAA,CAAwB,MAAA,EAAQ,YAAA,GAAe,mBAAA;EAs9B3C;;;;EAAA,QAh8BH,YAAA;EA9DuB;;;;EAAA,QAiFjB,YAAA;EA3EI;;;;;EAAA,QA4GJ,cAAA;cAsCF,MAAA,EAAQ,YAAA;EA8CpB,YAAA,CAAa,MAAA,EAAQ,UAAA;EArLyB;;;;EAAA,QAmTtC,cAAA;EA5R8C;;;;EAAA,QAmT9C,YAAA;EAAA,QAQA,aAAA;EA3MI;;;;;;;;;EAAA,QA+NJ,oBAAA;EAAA,QAgBA,eAAA;EAAA,QAqCA,gBAAA;EAAA,QAOM,WAAA;EAAA,QA8BA,WAAA;EAAA,QAmCA,eAAA;EAAA,QAWA,UAAA;EAqIA;;;;;EAAA,QArHA,UAAA;EAAA,QAiFA,aAAA;EAAA,QAoCA,eAAA;EAAA,QAoCA,cAAA;EAAA,QAoCA,aAAA;EAAA,QAgHA,YAAA;EAAA,QA0CA,aAAA;EA4GN;;;;;;;;;;EAAA,UAxDE,eAAA,CACR,SAAA,UACA,IAAA,EAAM,cAAA,EACN,OAAA;IAAY,YAAA;EAAA,IACX,SAAA;EAAA,QAoDK,cAAA;EAAA,QAEA,UAAA;EAOF,QAAA,CAAA,GAAY,OAAA;EA6EF;;;;;;;;;;;;;;;;EA1ChB,OAAA,CAAA,GAAW,WAAA;EAkCX,YAAA,CAAA,GAAgB,MAAA;AAAA;;;;cAQL,KAAA,EAAK,QAAA,QAAA,WAAA,EAAA,YAAA;;gCAAA,UAAA"}
|
|
@@ -1,7 +1,7 @@
|
|
|
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 {
|
|
4
|
+
import { getCurrentUserId, getWorkspaceClient } from "../../context/execution-context.js";
|
|
5
5
|
import { init_context } from "../../context/index.js";
|
|
6
6
|
import { ResourceType } from "../../registry/types.generated.js";
|
|
7
7
|
import "../../registry/index.js";
|
|
@@ -14,6 +14,7 @@ import "../../connectors/files/index.js";
|
|
|
14
14
|
import { FILES_DOWNLOAD_DEFAULTS, FILES_MAX_UPLOAD_SIZE, FILES_READ_DEFAULTS, FILES_WRITE_DEFAULTS } from "./defaults.js";
|
|
15
15
|
import { parentDirectory, sanitizeFilename } from "./helpers.js";
|
|
16
16
|
import manifest_default from "./manifest.js";
|
|
17
|
+
import { PolicyDeniedError, policy } from "./policy.js";
|
|
17
18
|
import { ApiError } from "@databricks/sdk-experimental";
|
|
18
19
|
import { STATUS_CODES } from "node:http";
|
|
19
20
|
import { Readable } from "node:stream";
|
|
@@ -72,18 +73,72 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
72
73
|
}));
|
|
73
74
|
}
|
|
74
75
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
76
|
+
* Extract user identity from the request.
|
|
77
|
+
* Falls back to `getCurrentUserId()` in development mode.
|
|
77
78
|
*/
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
_extractUser(req) {
|
|
80
|
+
const userId = req.header("x-forwarded-user")?.trim();
|
|
81
|
+
if (userId) return { id: userId };
|
|
82
|
+
if (process.env.NODE_ENV === "development") {
|
|
83
|
+
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.");
|
|
84
|
+
return { id: getCurrentUserId() };
|
|
85
|
+
}
|
|
86
|
+
throw AuthenticationError.missingToken("Missing x-forwarded-user header. Cannot resolve user ID.");
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Check the policy for a volume. No-op if no policy is configured.
|
|
90
|
+
* Throws `PolicyDeniedError` if denied.
|
|
91
|
+
*/
|
|
92
|
+
async _checkPolicy(volumeKey, action, path, user, resourceOverrides) {
|
|
93
|
+
const policyFn = this.volumeConfigs[volumeKey]?.policy;
|
|
94
|
+
if (typeof policyFn !== "function") return;
|
|
95
|
+
if (!await policyFn(action, {
|
|
96
|
+
path,
|
|
97
|
+
volume: volumeKey,
|
|
98
|
+
...resourceOverrides
|
|
99
|
+
}, user)) {
|
|
100
|
+
const userId = user.isServicePrincipal ? "<service-principal>" : user.id;
|
|
101
|
+
logger.warn("Policy denied \"%s\" on volume \"%s\" for user \"%s\"", action, volumeKey, userId);
|
|
102
|
+
throw new PolicyDeniedError(action, volumeKey);
|
|
103
|
+
}
|
|
80
104
|
}
|
|
81
105
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
106
|
+
* HTTP-level wrapper around `_checkPolicy`.
|
|
107
|
+
* Extracts user (401 on failure), runs policy (403 on denial).
|
|
108
|
+
* Returns `true` if the request may proceed, `false` if a response was sent.
|
|
84
109
|
*/
|
|
85
|
-
|
|
86
|
-
|
|
110
|
+
async _enforcePolicy(req, res, volumeKey, action, path, resourceOverrides) {
|
|
111
|
+
let user;
|
|
112
|
+
try {
|
|
113
|
+
user = this._extractUser(req);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error instanceof AuthenticationError) {
|
|
116
|
+
res.status(401).json({
|
|
117
|
+
error: error.message,
|
|
118
|
+
plugin: this.name
|
|
119
|
+
});
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await this._checkPolicy(volumeKey, action, path, user, resourceOverrides);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error instanceof PolicyDeniedError) {
|
|
128
|
+
res.status(403).json({
|
|
129
|
+
error: error.message,
|
|
130
|
+
plugin: this.name
|
|
131
|
+
});
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
logger.error("Policy function threw on volume %s: %O", volumeKey, error);
|
|
135
|
+
res.status(500).json({
|
|
136
|
+
error: "Policy evaluation failed",
|
|
137
|
+
plugin: this.name
|
|
138
|
+
});
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
87
142
|
}
|
|
88
143
|
constructor(config) {
|
|
89
144
|
super(config);
|
|
@@ -97,7 +152,8 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
97
152
|
const volumePath = process.env[envVar];
|
|
98
153
|
const mergedConfig = {
|
|
99
154
|
maxUploadSize: volumeCfg.maxUploadSize ?? config.maxUploadSize,
|
|
100
|
-
customContentTypes: volumeCfg.customContentTypes ?? config.customContentTypes
|
|
155
|
+
customContentTypes: volumeCfg.customContentTypes ?? config.customContentTypes,
|
|
156
|
+
policy: volumeCfg.policy ?? policy.publicRead()
|
|
101
157
|
};
|
|
102
158
|
this.volumeConfigs[key] = mergedConfig;
|
|
103
159
|
this.volumeConnectors[key] = new FilesConnector({
|
|
@@ -107,51 +163,7 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
107
163
|
customContentTypes: mergedConfig.customContentTypes
|
|
108
164
|
});
|
|
109
165
|
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Creates a VolumeAPI for a specific volume key.
|
|
113
|
-
* Each method warns if called outside a user context (service principal).
|
|
114
|
-
*/
|
|
115
|
-
createVolumeAPI(volumeKey) {
|
|
116
|
-
const connector = this.volumeConnectors[volumeKey];
|
|
117
|
-
return {
|
|
118
|
-
list: (directoryPath) => {
|
|
119
|
-
this.throwIfNoUserContext(volumeKey, `list`);
|
|
120
|
-
return connector.list(getWorkspaceClient(), directoryPath);
|
|
121
|
-
},
|
|
122
|
-
read: (filePath, options) => {
|
|
123
|
-
this.throwIfNoUserContext(volumeKey, `read`);
|
|
124
|
-
return connector.read(getWorkspaceClient(), filePath, options);
|
|
125
|
-
},
|
|
126
|
-
download: (filePath) => {
|
|
127
|
-
this.throwIfNoUserContext(volumeKey, `download`);
|
|
128
|
-
return connector.download(getWorkspaceClient(), filePath);
|
|
129
|
-
},
|
|
130
|
-
exists: (filePath) => {
|
|
131
|
-
this.throwIfNoUserContext(volumeKey, `exists`);
|
|
132
|
-
return connector.exists(getWorkspaceClient(), filePath);
|
|
133
|
-
},
|
|
134
|
-
metadata: (filePath) => {
|
|
135
|
-
this.throwIfNoUserContext(volumeKey, `metadata`);
|
|
136
|
-
return connector.metadata(getWorkspaceClient(), filePath);
|
|
137
|
-
},
|
|
138
|
-
upload: (filePath, contents, options) => {
|
|
139
|
-
this.throwIfNoUserContext(volumeKey, `upload`);
|
|
140
|
-
return connector.upload(getWorkspaceClient(), filePath, contents, options);
|
|
141
|
-
},
|
|
142
|
-
createDirectory: (directoryPath) => {
|
|
143
|
-
this.throwIfNoUserContext(volumeKey, `createDirectory`);
|
|
144
|
-
return connector.createDirectory(getWorkspaceClient(), directoryPath);
|
|
145
|
-
},
|
|
146
|
-
delete: (filePath) => {
|
|
147
|
-
this.throwIfNoUserContext(volumeKey, `delete`);
|
|
148
|
-
return connector.delete(getWorkspaceClient(), filePath);
|
|
149
|
-
},
|
|
150
|
-
preview: (filePath) => {
|
|
151
|
-
this.throwIfNoUserContext(volumeKey, `preview`);
|
|
152
|
-
return connector.preview(getWorkspaceClient(), filePath);
|
|
153
|
-
}
|
|
154
|
-
};
|
|
166
|
+
for (const key of this.volumeKeys) if (!volumes[key].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);
|
|
155
167
|
}
|
|
156
168
|
injectRoutes(router) {
|
|
157
169
|
this.route(router, {
|
|
@@ -310,14 +322,25 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
310
322
|
* Invalidate cached list entries for a directory after a write operation.
|
|
311
323
|
* Uses the same cache-key format as `_handleList`: resolved path for
|
|
312
324
|
* subdirectories, `"__root__"` for the volume root.
|
|
325
|
+
*
|
|
326
|
+
* Cache keys include `getCurrentUserId()` — must match the identity used
|
|
327
|
+
* by `this.execute()` in `_handleList`. Both run in service-principal
|
|
328
|
+
* context; wrapping either in `runInUserContext` would break invalidation.
|
|
313
329
|
*/
|
|
314
|
-
_invalidateListCache(volumeKey, parentPath,
|
|
330
|
+
_invalidateListCache(volumeKey, parentPath, connector) {
|
|
315
331
|
const parent = parentDirectory(parentPath);
|
|
316
332
|
const cachePathSegment = parent ? connector.resolvePath(parent) : "__root__";
|
|
317
|
-
const listKey = this.cache.generateKey([`files:${volumeKey}:list`, cachePathSegment],
|
|
333
|
+
const listKey = this.cache.generateKey([`files:${volumeKey}:list`, cachePathSegment], getCurrentUserId());
|
|
318
334
|
this.cache.delete(listKey);
|
|
319
335
|
}
|
|
320
336
|
_handleApiError(res, error, fallbackMessage) {
|
|
337
|
+
if (error instanceof PolicyDeniedError) {
|
|
338
|
+
res.status(403).json({
|
|
339
|
+
error: error.message,
|
|
340
|
+
plugin: this.name
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
321
344
|
if (error instanceof AuthenticationError) {
|
|
322
345
|
res.status(401).json({
|
|
323
346
|
error: error.message,
|
|
@@ -356,11 +379,9 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
356
379
|
}
|
|
357
380
|
async _handleList(req, res, connector, volumeKey) {
|
|
358
381
|
const path = req.query.path;
|
|
382
|
+
if (!await this._enforcePolicy(req, res, volumeKey, "list", path ?? "/")) return;
|
|
359
383
|
try {
|
|
360
|
-
const result = await this.
|
|
361
|
-
this.warnIfNoUserContext(volumeKey, `list`);
|
|
362
|
-
return connector.list(getWorkspaceClient(), path);
|
|
363
|
-
}, this._readSettings([`files:${volumeKey}:list`, path ? connector.resolvePath(path) : "__root__"]));
|
|
384
|
+
const result = await this.execute(async () => connector.list(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:list`, path ? connector.resolvePath(path) : "__root__"]));
|
|
364
385
|
if (!result.ok) {
|
|
365
386
|
this._sendStatusError(res, result.status);
|
|
366
387
|
return;
|
|
@@ -380,11 +401,9 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
380
401
|
});
|
|
381
402
|
return;
|
|
382
403
|
}
|
|
404
|
+
if (!await this._enforcePolicy(req, res, volumeKey, "read", path)) return;
|
|
383
405
|
try {
|
|
384
|
-
const result = await this.
|
|
385
|
-
this.warnIfNoUserContext(volumeKey, `read`);
|
|
386
|
-
return connector.read(getWorkspaceClient(), path);
|
|
387
|
-
}, this._readSettings([`files:${volumeKey}:read`, connector.resolvePath(path)]));
|
|
406
|
+
const result = await this.execute(async () => connector.read(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:read`, connector.resolvePath(path)]));
|
|
388
407
|
if (!result.ok) {
|
|
389
408
|
this._sendStatusError(res, result.status);
|
|
390
409
|
return;
|
|
@@ -415,15 +434,12 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
415
434
|
});
|
|
416
435
|
return;
|
|
417
436
|
}
|
|
437
|
+
if (!await this._enforcePolicy(req, res, volumeKey, opts.mode, path)) return;
|
|
418
438
|
const label = opts.mode === "download" ? "Download" : "Raw fetch";
|
|
419
439
|
const volumeCfg = this.volumeConfigs[volumeKey];
|
|
420
440
|
try {
|
|
421
|
-
const userPlugin = this.asUser(req);
|
|
422
441
|
const settings = { default: FILES_DOWNLOAD_DEFAULTS };
|
|
423
|
-
const response = await
|
|
424
|
-
this.warnIfNoUserContext(volumeKey, `download`);
|
|
425
|
-
return connector.download(getWorkspaceClient(), path);
|
|
426
|
-
}, settings);
|
|
442
|
+
const response = await this.execute(async () => connector.download(getWorkspaceClient(), path), settings);
|
|
427
443
|
if (!response.ok) {
|
|
428
444
|
this._sendStatusError(res, response.status);
|
|
429
445
|
return;
|
|
@@ -459,11 +475,9 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
459
475
|
});
|
|
460
476
|
return;
|
|
461
477
|
}
|
|
478
|
+
if (!await this._enforcePolicy(req, res, volumeKey, "exists", path)) return;
|
|
462
479
|
try {
|
|
463
|
-
const result = await this.
|
|
464
|
-
this.warnIfNoUserContext(volumeKey, `exists`);
|
|
465
|
-
return connector.exists(getWorkspaceClient(), path);
|
|
466
|
-
}, this._readSettings([`files:${volumeKey}:exists`, connector.resolvePath(path)]));
|
|
480
|
+
const result = await this.execute(async () => connector.exists(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:exists`, connector.resolvePath(path)]));
|
|
467
481
|
if (!result.ok) {
|
|
468
482
|
this._sendStatusError(res, result.status);
|
|
469
483
|
return;
|
|
@@ -483,11 +497,9 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
483
497
|
});
|
|
484
498
|
return;
|
|
485
499
|
}
|
|
500
|
+
if (!await this._enforcePolicy(req, res, volumeKey, "metadata", path)) return;
|
|
486
501
|
try {
|
|
487
|
-
const result = await this.
|
|
488
|
-
this.warnIfNoUserContext(volumeKey, `metadata`);
|
|
489
|
-
return connector.metadata(getWorkspaceClient(), path);
|
|
490
|
-
}, this._readSettings([`files:${volumeKey}:metadata`, connector.resolvePath(path)]));
|
|
502
|
+
const result = await this.execute(async () => connector.metadata(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:metadata`, connector.resolvePath(path)]));
|
|
491
503
|
if (!result.ok) {
|
|
492
504
|
this._sendStatusError(res, result.status);
|
|
493
505
|
return;
|
|
@@ -507,11 +519,9 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
507
519
|
});
|
|
508
520
|
return;
|
|
509
521
|
}
|
|
522
|
+
if (!await this._enforcePolicy(req, res, volumeKey, "preview", path)) return;
|
|
510
523
|
try {
|
|
511
|
-
const result = await this.
|
|
512
|
-
this.warnIfNoUserContext(volumeKey, `preview`);
|
|
513
|
-
return connector.preview(getWorkspaceClient(), path);
|
|
514
|
-
}, this._readSettings([`files:${volumeKey}:preview`, connector.resolvePath(path)]));
|
|
524
|
+
const result = await this.execute(async () => connector.preview(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:preview`, connector.resolvePath(path)]));
|
|
515
525
|
if (!result.ok) {
|
|
516
526
|
this._sendStatusError(res, result.status);
|
|
517
527
|
return;
|
|
@@ -533,8 +543,19 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
533
543
|
}
|
|
534
544
|
const maxSize = this.volumeConfigs[volumeKey].maxUploadSize ?? FILES_MAX_UPLOAD_SIZE;
|
|
535
545
|
const rawContentLength = req.headers["content-length"];
|
|
536
|
-
|
|
537
|
-
if (
|
|
546
|
+
let contentLength;
|
|
547
|
+
if (typeof rawContentLength === "string" && rawContentLength.length > 0) {
|
|
548
|
+
if (!/^\d+$/.test(rawContentLength)) {
|
|
549
|
+
res.status(400).json({
|
|
550
|
+
error: "Invalid Content-Length header.",
|
|
551
|
+
plugin: this.name
|
|
552
|
+
});
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
contentLength = Number(rawContentLength);
|
|
556
|
+
}
|
|
557
|
+
if (!await this._enforcePolicy(req, res, volumeKey, "upload", path, { size: contentLength })) return;
|
|
558
|
+
if (contentLength !== void 0 && contentLength > maxSize) {
|
|
538
559
|
res.status(413).json({
|
|
539
560
|
error: `File size (${contentLength} bytes) exceeds maximum allowed size (${maxSize} bytes).`,
|
|
540
561
|
plugin: this.name
|
|
@@ -554,14 +575,12 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
554
575
|
controller.enqueue(chunk);
|
|
555
576
|
} }));
|
|
556
577
|
logger.debug(req, "Upload body received: volume=%s path=%s, size=%d bytes", volumeKey, path, contentLength ?? 0);
|
|
557
|
-
const userPlugin = this.asUser(req);
|
|
558
578
|
const settings = { default: FILES_WRITE_DEFAULTS };
|
|
559
|
-
const result = await this.trackWrite(() =>
|
|
560
|
-
this.warnIfNoUserContext(volumeKey, `upload`);
|
|
579
|
+
const result = await this.trackWrite(() => this.execute(async () => {
|
|
561
580
|
await connector.upload(getWorkspaceClient(), path, webStream);
|
|
562
581
|
return { success: true };
|
|
563
582
|
}, settings));
|
|
564
|
-
this._invalidateListCache(volumeKey, path,
|
|
583
|
+
this._invalidateListCache(volumeKey, path, connector);
|
|
565
584
|
if (!result.ok) {
|
|
566
585
|
logger.error(req, "Upload failed: volume=%s path=%s, size=%d bytes", volumeKey, path, contentLength ?? 0);
|
|
567
586
|
this._sendStatusError(res, result.status);
|
|
@@ -590,15 +609,14 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
590
609
|
});
|
|
591
610
|
return;
|
|
592
611
|
}
|
|
612
|
+
if (!await this._enforcePolicy(req, res, volumeKey, "mkdir", dirPath)) return;
|
|
593
613
|
try {
|
|
594
|
-
const userPlugin = this.asUser(req);
|
|
595
614
|
const settings = { default: FILES_WRITE_DEFAULTS };
|
|
596
|
-
const result = await this.trackWrite(() =>
|
|
597
|
-
this.warnIfNoUserContext(volumeKey, `createDirectory`);
|
|
615
|
+
const result = await this.trackWrite(() => this.execute(async () => {
|
|
598
616
|
await connector.createDirectory(getWorkspaceClient(), dirPath);
|
|
599
617
|
return { success: true };
|
|
600
618
|
}, settings));
|
|
601
|
-
this._invalidateListCache(volumeKey, dirPath,
|
|
619
|
+
this._invalidateListCache(volumeKey, dirPath, connector);
|
|
602
620
|
if (!result.ok) {
|
|
603
621
|
this._sendStatusError(res, result.status);
|
|
604
622
|
return;
|
|
@@ -619,15 +637,14 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
619
637
|
return;
|
|
620
638
|
}
|
|
621
639
|
const path = rawPath;
|
|
640
|
+
if (!await this._enforcePolicy(req, res, volumeKey, "delete", path)) return;
|
|
622
641
|
try {
|
|
623
|
-
const userPlugin = this.asUser(req);
|
|
624
642
|
const settings = { default: FILES_WRITE_DEFAULTS };
|
|
625
|
-
const result = await this.trackWrite(() =>
|
|
626
|
-
this.warnIfNoUserContext(volumeKey, `delete`);
|
|
643
|
+
const result = await this.trackWrite(() => this.execute(async () => {
|
|
627
644
|
await connector.delete(getWorkspaceClient(), path);
|
|
628
645
|
return { success: true };
|
|
629
646
|
}, settings));
|
|
630
|
-
this._invalidateListCache(volumeKey, path,
|
|
647
|
+
this._invalidateListCache(volumeKey, path, connector);
|
|
631
648
|
if (!result.ok) {
|
|
632
649
|
this._sendStatusError(res, result.status);
|
|
633
650
|
return;
|
|
@@ -637,6 +654,59 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
637
654
|
this._handleApiError(res, error, "Delete failed");
|
|
638
655
|
}
|
|
639
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* Creates a VolumeAPI for a specific volume key.
|
|
659
|
+
*
|
|
660
|
+
* By default, enforces the volume's policy before each operation.
|
|
661
|
+
* Pass `bypassPolicy: true` to skip policy checks — useful for
|
|
662
|
+
* background jobs or migrations that should bypass user-facing policies.
|
|
663
|
+
*
|
|
664
|
+
* @security When `bypassPolicy` is `true`, no policy enforcement runs.
|
|
665
|
+
* Do not expose bypassed APIs to HTTP routes or end-user code paths.
|
|
666
|
+
*/
|
|
667
|
+
createVolumeAPI(volumeKey, user, options) {
|
|
668
|
+
const connector = this.volumeConnectors[volumeKey];
|
|
669
|
+
const noop = () => Promise.resolve();
|
|
670
|
+
const check = options?.bypassPolicy ? noop : (action, path, overrides) => this._checkPolicy(volumeKey, action, path, user, overrides);
|
|
671
|
+
return {
|
|
672
|
+
list: async (directoryPath) => {
|
|
673
|
+
await check("list", directoryPath ?? "/");
|
|
674
|
+
return connector.list(getWorkspaceClient(), directoryPath);
|
|
675
|
+
},
|
|
676
|
+
read: async (filePath, opts) => {
|
|
677
|
+
await check("read", filePath);
|
|
678
|
+
return connector.read(getWorkspaceClient(), filePath, opts);
|
|
679
|
+
},
|
|
680
|
+
download: async (filePath) => {
|
|
681
|
+
await check("download", filePath);
|
|
682
|
+
return connector.download(getWorkspaceClient(), filePath);
|
|
683
|
+
},
|
|
684
|
+
exists: async (filePath) => {
|
|
685
|
+
await check("exists", filePath);
|
|
686
|
+
return connector.exists(getWorkspaceClient(), filePath);
|
|
687
|
+
},
|
|
688
|
+
metadata: async (filePath) => {
|
|
689
|
+
await check("metadata", filePath);
|
|
690
|
+
return connector.metadata(getWorkspaceClient(), filePath);
|
|
691
|
+
},
|
|
692
|
+
upload: async (filePath, contents, opts) => {
|
|
693
|
+
await check("upload", filePath);
|
|
694
|
+
return connector.upload(getWorkspaceClient(), filePath, contents, opts);
|
|
695
|
+
},
|
|
696
|
+
createDirectory: async (directoryPath) => {
|
|
697
|
+
await check("mkdir", directoryPath);
|
|
698
|
+
return connector.createDirectory(getWorkspaceClient(), directoryPath);
|
|
699
|
+
},
|
|
700
|
+
delete: async (filePath) => {
|
|
701
|
+
await check("delete", filePath);
|
|
702
|
+
return connector.delete(getWorkspaceClient(), filePath);
|
|
703
|
+
},
|
|
704
|
+
preview: async (filePath) => {
|
|
705
|
+
await check("preview", filePath);
|
|
706
|
+
return connector.preview(getWorkspaceClient(), filePath);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
}
|
|
640
710
|
inflightWrites = 0;
|
|
641
711
|
trackWrite(fn) {
|
|
642
712
|
this.inflightWrites++;
|
|
@@ -657,22 +727,31 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
657
727
|
* Returns the programmatic API for the Files plugin.
|
|
658
728
|
* Callable with a volume key to get a volume-scoped handle.
|
|
659
729
|
*
|
|
730
|
+
* All operations execute as the service principal.
|
|
731
|
+
* Use policies to control per-user access.
|
|
732
|
+
*
|
|
660
733
|
* @example
|
|
661
734
|
* ```ts
|
|
662
|
-
* //
|
|
663
|
-
* appKit.files("uploads").asUser(req).list()
|
|
664
|
-
*
|
|
665
|
-
* // Service principal access (logs a warning)
|
|
735
|
+
* // Service principal access
|
|
666
736
|
* appKit.files("uploads").list()
|
|
737
|
+
*
|
|
738
|
+
* // With policy: pass user identity for access control
|
|
739
|
+
* appKit.files("uploads").asUser(req).list()
|
|
667
740
|
* ```
|
|
668
741
|
*/
|
|
669
742
|
exports() {
|
|
670
743
|
const resolveVolume = (volumeKey) => {
|
|
671
744
|
if (!this.volumeKeys.includes(volumeKey)) throw new Error(`Unknown volume "${volumeKey}". Available volumes: ${this.volumeKeys.join(", ")}`);
|
|
672
745
|
return {
|
|
673
|
-
...this.createVolumeAPI(volumeKey
|
|
746
|
+
...this.createVolumeAPI(volumeKey, {
|
|
747
|
+
get id() {
|
|
748
|
+
return getCurrentUserId();
|
|
749
|
+
},
|
|
750
|
+
isServicePrincipal: true
|
|
751
|
+
}),
|
|
674
752
|
asUser: (req) => {
|
|
675
|
-
|
|
753
|
+
const user = this._extractUser(req);
|
|
754
|
+
return this.createVolumeAPI(volumeKey, user);
|
|
676
755
|
}
|
|
677
756
|
};
|
|
678
757
|
};
|
|
@@ -687,7 +766,7 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
687
766
|
/**
|
|
688
767
|
* @internal
|
|
689
768
|
*/
|
|
690
|
-
const files$1 = toPlugin(FilesPlugin);
|
|
769
|
+
const files$1 = Object.assign(toPlugin(FilesPlugin), { policy });
|
|
691
770
|
|
|
692
771
|
//#endregion
|
|
693
772
|
export { FilesPlugin, files$1 as files };
|