diff --git a/design/images/permission.dialog.html b/design/images/permission.dialog.html new file mode 100644 index 000000000..ad04e28c0 --- /dev/null +++ b/design/images/permission.dialog.html @@ -0,0 +1,248 @@ + + + + + + + + +
+ +
+
+
+

"Wear A Hat" (🔓 mres.altvr.com) requests the following permissions:

+
    +
  • +
  • +
  • +
+
+ +
+ +
+ + + +
+
+ +
+
<
+ • • • +
>
+
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + +
+ 🔓 mres.altvr.com
+ Wear A Hat, Functional Tests +
Execution, User Interaction, User Tracking +
🖉
+
🗙
+
+ 🔓 secure-mre.azurewebsites.net
+ Where In The World +
Execution, User Interaction +
🖉
+
🗙
+
+ 🔓 helmets-mre.azurewebsites.net
+ Helmets +
Execution, User Interaction +
🖉
+
🗙
+
+
+
+ + + \ No newline at end of file diff --git a/design/images/permission.overview.png b/design/images/permission.overview.png new file mode 100644 index 000000000..872a185eb Binary files /dev/null and b/design/images/permission.overview.png differ diff --git a/design/images/permission.prompt.png b/design/images/permission.prompt.png new file mode 100644 index 000000000..bc4e07684 Binary files /dev/null and b/design/images/permission.prompt.png differ diff --git a/design/images/permission.prompt.xcf b/design/images/permission.prompt.xcf new file mode 100644 index 000000000..565c6d020 Binary files /dev/null and b/design/images/permission.prompt.xcf differ diff --git a/design/images/permission.requests.png b/design/images/permission.requests.png new file mode 100644 index 000000000..58d001632 Binary files /dev/null and b/design/images/permission.requests.png differ diff --git a/design/images/permission.settings.png b/design/images/permission.settings.png new file mode 100644 index 000000000..bbd3b4656 Binary files /dev/null and b/design/images/permission.settings.png differ diff --git a/design/permissions.md b/design/permissions.md new file mode 100644 index 000000000..27e52bd96 --- /dev/null +++ b/design/permissions.md @@ -0,0 +1,195 @@ +Permissions API +================= + +To be a responsible member of the Social MR ecosystem, the MRE SDK has to prioritize the safety of its residents over +the power of this SDK. As such, I propose the addition of a permissions API, so MREs can have more "invasive" features +without compromising user safety and agency. + + +Permission scope +------------------ + +When a user approves or denies permission to an MRE, the scope of that decision is determined by the host app, +and is based on the MRE URL. To mimic how Chromium manages permissions, hosts could associate decisions with +unique combinations of URL scheme, host, and port, known collectively as the app "origin". For a more narrow +permissioning system, the URL path could also be included, but it is not recommended that the other parts of a URL +be considered: username, password, anchor, or query arguments. + + +Features requiring user permission +------------------------------------ + +If a permission is not requested, it is considered denied. + +* `execution` (client only) - Required to connect to an MRE server. Typically granted by default, but can be revoked. + * Allow: MRE connection can be established + * Deny: MRE connection will not be established +* `user-tracking` - Grants access to a persistent user identity across sessions. Needed for things like high scores lists. + * Allow: This user will be uniquely identified to this MRE origin across sessions and instances. + * Deny: This user will be assigned a new ID every time they connect to MREs from this origin. If the `user-interaction` + permission is also denied, this client will not join a user to the session at all. +* `user-interaction` - Behaviors, exclusive actors, attachments, and dialogs. + * Allow: This user can interact with behaviors, exclusively own actors, be a target for attached actors, and can be + sent dialogs. + * Deny: Interactions with behaviors will not be sent back to the app server. Attempts to create exclusive actors + for this user will fail. Actors attached to this user will be considered unattached. Calls to `user.prompt` + will be rejected. If the `user-tracking` permission is also denied, this client will not join a user to the + session at all. +* Play sounds and video +* Large assets (hypothetical) - If an MRE wants to load more than some large amount of assets into memory + (30MB worth of memory? TBD), the client must first approve. This permission might automatically be approved/denied + by clients on behalf of users based on device capabilities. +* Movement (hypothetical) - The ability to forcibly move a user's avatar and point of view, either smoothly + or teleported. +* Microphone input (hypothetical) - The ability for users to stream their microphone input into an MRE, for voice + commands, synthesizers, chat bots, or anything else. + + +Permission declaration +------------------------ + +Apps must declare which APIs they will use in advance of any users connecting. This is done via a JSON-formatted +manifest loaded from the app's HTTP root. If the MRE is served over a secure websocket (`wss://`), the manifest should +be loaded using HTTPS instead. For example, the manifest for the URL `ws://mres.altvr.com/tests/red` should +be found at `http://mres.altvr.com/tests/red/manifest.json`. Note that this file can be served from the filesystem or +constructed on request. + +The manifest can contain lots of different fields in addition to permissions, but must conform to the included +[JSON schema](../packages/sdk/src/util/manifestSchema.json). + + +Permissioning flow +-------------------- + +Legend: + +* [App] Step executed by MRE developer code +* [SDK] Step executed by MRE SDK +* [Runtime] Step executed in client-side MRE runtime +* [Host] Step executed by host application + +Startup: + +1. [App] During development, the app developer authors a static manifest file, or uses the WebHost API to generate one. +2. [Runtime|Host] Initializes the MRE API with a permissions manager instance that will receive new permission requests. + If permission decisions are persistent, load old decisions into memory now. +3. [Host] The host wishes to run an MRE, so creates an IMixedRealityExtensionApp instance and calls Startup(). +4. [Runtime] Downloads the manifest from the provided MRE server. If missing, assumes no permissions but `execution` + are required. +5. [Runtime] Calls into the permission manager requesting the manifest-listed permissions. +6. [Host] Permission manager evaluates the required and optional MRE permissions against the current set of decisions + for the MRE's URL, and determines if any new permission decisions need to be made by the user. If so: + 1. [Host] Present the choices to the user by whatever means the host sees fit, persist the decision if desired, + and report the result to the Runtime. Optional permissions should be presented in such a way as they can be + granted or denied individually, but required permissions should be decided as a group. +7. [Runtime] Determine if the MRE has sufficient permission to run, i.e. `execution` and all the required permissions + are granted. If not, abort connecting to the MRE. +8. [Runtime] Startup proceeds like normal, but if a `user-joined` message is sent, the user payload must include + the IDs of any permissions that were granted to this MRE by this user (other than `execution`, which is implied). + + +Permissions Manager +--------------------- + +Host apps must provide hooks for the MRE subsystem to obtain permission from users. This is provided to +`MREAPI.InitializeAPI` as an implementation of `IPermissionManager`, which has the following methods: + +* `Task PromptForPermission(...)` - Request permissions from the user, and return a Task that + resolves with those permissions the user has granted. Takes the following arguments: + * `Uri appLocation` - The URI of the MRE requesting permission, as described above. + * `IEnumerable permissionsNeeded` - A list of the permissions required to run the app. + * `IEnumerable permissionsWanted` - A list of permissions that the app can use, but are not + needed to run. + * `Permissions permissionFlagsNeeded` - Same as `permissionsNeeded`, but in a bitfield. + * `Permissions permissionFlagsWanted` - Same as `permissionsWanted`, but in a bitfield. +* `Permissions CurrentPermissions(...)` - Returns a bitfield of the currently granted permissions. Takes + the following arguments: + * `Uri appLocation` - The URI of the MRE for which we want the permission set. +* `event Action OnPermissionDecisionsChanged` + +The `Permissions` enum has the `Flags` attribute and is on powers of 2 in case testing combinations is desired. + +```cs +[Flags] +enum Permissions { + None = 0, + Execution = 1, + UserTracking = 2, + UserInteraction = 4, + ... +} +``` + + +UI Mockups +============ + +This is what an MRE permissions management interface could look like. When an MRE is available to run in the current +host app context, a button to bring up the management UI should be readily available (within two clicks of steady-state +usage). Attention should be drawn to this button when a new MRE permission request comes in. + +Clicking the prompt or the button brings up your MRE permission requests: + +![Requests](images/permission.requests.png) + +This window is paginated, one page per MRE that requires permissions. The name of the app requesting permission, the +server name it's hosted on, and a secure protocol indicator are all featured prominently. Each permission has a +checkbox next to it: required permissions are checked and disabled, and optional permissions are checked or not based +on app origin decision history and settings. + +A global MRE settings menu could also be available: + +![Settings](images/permission.settings.png) + +From this UI, the user could choose to grant a certain permission to all MREs globally. + +There could also be UI to retroactively change your permission decisions: + +![Overview](images/permission.overview.png) + +This panel displays an overview of each MRE available in the current host app context, what permissions the user has +granted, and some controls. One button lets the user modify their permission decisions by re-opening the Requests view +for that app, and if that app is currently running, reconnect. The other button deletes the saved decisions for that +app origin. + +The "Here" and "All" tabs in this mockup are laid out the same, except "Here" is only the MREs in the current context, +whereas "All" is the list of all MRE origins for which there are stored decisions. + + +Old stuff +=========== + + +Methods for acquiring permission +---------------------------------- + +1. **Request permission for a feature the first time it is used** + 1. The application code makes an async call to an API that requires user permission. + 2. The SDK detects that this is the first time that API is used for this user, so it saves the API call request + internally and sends a permission request to the user. + 3. The user's client will reply with an approved or denied message. If approved, the original API call is executed. + If not, handle the rejection. +2. **Request permission for a set of features explicitly** + 1. The application code uses a permissions API to set the permission requirements for the app. + 2. All current and late-joining users are sent a message asking for any permissions not already granted or denied. + 3. Each user will reply with an approved or denied message. If approved, the approval is saved, and all future API + requests that require that permission will be processed for this user. If denied, handle the rejection. +3. **Establish permissions during initialization** + 1. Provide a list of permissions required by an app during app setup. + 2. During connection handshake, the list of required permissions will be sent to the client. + 3. If the client approves, initialization proceeds like normal. If not, handle the rejection. + + +Methods for handling permission rejection +------------------------------------------- + +1. Revoke access to the denied APIs for the set of denying users. +2. All users must approve of all permissions; users that do not approve immediately leave the MRE. +3. All clients must approve of all permissions; clients that do not approve immediately disconnect from the MRE server. + + +Methods for presenting permission requests to users +----------------------------------------------------- + +1. Dialogs +2. Settings menu diff --git a/packages/functional-tests/public/manifest.json b/packages/functional-tests/public/manifest.json new file mode 100644 index 000000000..99003ad72 --- /dev/null +++ b/packages/functional-tests/public/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "MRE Functional Tests", + "description": "A set of samples that exercise various features of the Mixed Reality Extensions SDK", + "author": "Microsoft", + "license": "MIT", + "repositoryUrl": "https://github.com/microsoft/mixed-reality-extension-sdk", + "optionalPermissions": ["user-interaction"] +} diff --git a/packages/functional-tests/src/server.ts b/packages/functional-tests/src/server.ts index 6efa0857f..92b451f0e 100644 --- a/packages/functional-tests/src/server.ts +++ b/packages/functional-tests/src/server.ts @@ -20,7 +20,7 @@ process.on('unhandledRejection', (reason) => console.log('unhandledRejection', r // Start listening for connections, and serve static files const server = new MRE.WebHost({ // baseUrl: 'http://.ngrok.io', - baseDir: resolvePath(__dirname, '../public'), + baseDir: resolvePath(__dirname, '../public') }); // Handle new application sessions diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index 0dc16f521..a1d71fabf 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -1164,6 +1164,11 @@ "graceful-fs": "^4.1.6" } }, + "jsonschema": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.2.6.tgz", + "integrity": "sha512-SqhURKZG07JyKKeo/ir24QnS4/BV7a6gQy93bUSe4lUdNp0QNpIz2c9elWJQ9dpc5cQYY6cvCzgRwy0MQCLyqA==" + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b37ba8e99..edc94f76e 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -54,6 +54,7 @@ "deepmerge": "^2.1.1", "etag": "^1.8.1", "forwarded-for": "^1.1.0", + "jsonschema": "^1.2.6", "query-string": "^6.2.0", "restify": "^8.5.1", "restify-errors": "^8.0.2", diff --git a/packages/sdk/src/core/contextInternal.ts b/packages/sdk/src/core/contextInternal.ts index 525b15dcf..c1b2bfe0e 100644 --- a/packages/sdk/src/core/contextInternal.ts +++ b/packages/sdk/src/core/contextInternal.ts @@ -22,6 +22,7 @@ import { MediaInstance, newGuid, PerformanceStats, + Permissions, SetMediaStateOptions, TriggerEvent, User, @@ -111,6 +112,27 @@ export class ContextInternal { // Get a reference to the new actor. const actor = this.context.actor(payload.actor.id); + // check permission for exclusive actors + let user: User; + if (actor.exclusiveToUser && + (user = this.userSet.get(actor.exclusiveToUser)) && + !(user.grantedPermissions.includes(Permissions.UserInteraction))) { + actor.internal.notifyCreated(false, + `Permission denied on user ${user.id} (${user.name}). Either this MRE did not ` + + "request the UserInteraction permission, or it was denied by the user." + ); + } + + // check permission for attachments + if (actor.attachment?.userId && + (user = this.userSet.get(actor.attachment?.userId)) && + !(user.grantedPermissions.includes(Permissions.UserInteraction))) { + actor.internal.notifyCreated(false, + `Permission denied on user ${user.id} (${user.name}). Either this MRE did not ` + + "request the UserInteraction permission, or it was denied by the user." + ); + } + this.protocol.sendPayload( payload, { resolve: (replyPayload: Payloads.ObjectSpawned | Payloads.OperationResult) => { this.protocol.recvPayload(replyPayload); diff --git a/packages/sdk/src/user/index.ts b/packages/sdk/src/user/index.ts index 84e1e5c1a..a28159db2 100644 --- a/packages/sdk/src/user/index.ts +++ b/packages/sdk/src/user/index.ts @@ -6,3 +6,4 @@ export * from './groupMask'; export * from './invertedGroupMask'; export * from './user'; +export * from './permissions'; diff --git a/packages/sdk/src/user/permissions.ts b/packages/sdk/src/user/permissions.ts new file mode 100644 index 000000000..c265429ef --- /dev/null +++ b/packages/sdk/src/user/permissions.ts @@ -0,0 +1,25 @@ +/*! + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * The set of permissions MREs can request from users, and users can grant. + */ +export enum Permissions { + /** + * Grants access to a persistent user identity across sessions. Needed for things like high scores lists or + * persistent settings. Allow: This user will be uniquely identified to this MRE origin across sessions and + * instances. Deny: This user will be assigned a new ID every time they connect to MREs from this origin. + * If the `user-interaction` permission is also denied, this client will not join a user to the session at all. + */ + UserTracking = 'user-tracking', + /** + * Allow: This user can interact with behaviors, exclusively own actors, be a target for attached actors, and can be + * sent dialogs. Deny: Interactions with behaviors will not be sent back to the app server. Attempts to create + * exclusive actors for this user will fail. Actors attached to this user will be considered unattached. Calls + * to `user.prompt` will be rejected. If the `user-tracking` permission is also denied, this client will not join + * a user to the session at all. + */ + UserInteraction = 'user-interaction', +} diff --git a/packages/sdk/src/user/user.ts b/packages/sdk/src/user/user.ts index 1293eae85..0f15cb83f 100644 --- a/packages/sdk/src/user/user.ts +++ b/packages/sdk/src/user/user.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { Context, GroupMask, Guid } from '..'; +import { Context, GroupMask, Guid, Permissions } from '..'; import { Patchable, readPath, } from '../internal'; import { UserInternal } from './userInternal'; @@ -11,6 +11,11 @@ export interface UserLike { id: Guid; name: string; groups: number | GroupMask; + /** + * An array of values from the [[Permissions]] enum. These indicate permissions that this user has granted, + * either implicitly or explicitly. + */ + grantedPermissions: Permissions[]; properties: { [name: string]: string }; } @@ -32,6 +37,7 @@ export class User implements UserLike, Patchable { private _name: string; private _properties: { [name: string]: string }; private _groups: GroupMask; + private _grantedPermissions: Permissions[]; public get context() { return this._context; } public get id() { return this._id; } @@ -68,6 +74,9 @@ export class User implements UserLike, Patchable { */ public get properties() { return Object.freeze({ ...this._properties }); } + /** @inheritdoc */ + public get grantedPermissions() { return [...this._grantedPermissions]; } + /** * PUBLIC METHODS */ @@ -85,6 +94,7 @@ export class User implements UserLike, Patchable { return this.internal.prompt(text, acceptInput); } + /** @hidden */ public copy(from: Partial): this { // Pause change detection while we copy the values into the actor. const wasObserving = this.internal.observing; @@ -101,17 +111,20 @@ export class User implements UserLike, Patchable { this.groups = from.groups; } } + if (from.grantedPermissions !== undefined) { this._grantedPermissions = from.grantedPermissions; } this.internal.observing = wasObserving; return this; } + /** @hidden */ public toJSON() { return { id: this.id, name: this.name, groups: this.groups.packed(), properties: this.properties, + grantedPermissions: this.grantedPermissions } as UserLike; } diff --git a/packages/sdk/src/user/userInternal.ts b/packages/sdk/src/user/userInternal.ts index 31ce8a4dc..7d5a628de 100644 --- a/packages/sdk/src/user/userInternal.ts +++ b/packages/sdk/src/user/userInternal.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { DialogResponse, User, UserLike } from '..'; +import { DialogResponse, Permissions, User, UserLike } from '..'; import { InternalPatchable, Payloads } from '../internal'; import { ContextInternal } from '../core/contextInternal'; @@ -36,7 +36,12 @@ export class UserInternal implements InternalPatchable { } as Payloads.ShowDialog; return new Promise((resolve, reject) => { - this.context.sendPayload(payload, { resolve, reject }); + if (this.user.grantedPermissions.includes(Permissions.UserInteraction)) { + this.context.sendPayload(payload, { resolve, reject }); + } else { + reject(`Permission denied on user ${this.user.id} (${this.user.name}). Either this MRE did not ` + + "request the UserInteraction permission, or it was denied by the user."); + } }) .then(response => { if (response.failureMessage) { diff --git a/packages/sdk/src/util/manifestSchema.json b/packages/sdk/src/util/manifestSchema.json new file mode 100644 index 000000000..273e1c085 --- /dev/null +++ b/packages/sdk/src/util/manifestSchema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "name": { "type": "string" }, + "description": { "type": "string" }, + "author": { "type": "string" }, + "license": { + "description": "An SPDX short license ID", + "type": "string" + }, + "repositoryUrl": { + "description": "The public repository for this MRE's source code", + "type": "string" + }, + "permissions": { + "description": "A list of permissions required for this MRE to run", + "type": "array", + "items": { + "type": "string", + "enum": ["user-tracking", "user-interaction"] + } + }, + "optionalPermissions": { + "description": "A list of permissions that this MRE can use, but are not required", + "type": "array", + "items": { + "type": "string", + "enum": ["user-tracking", "user-interaction"] + } + } + } +} diff --git a/packages/sdk/src/util/webHost.ts b/packages/sdk/src/util/webHost.ts index 5e8333e44..951542ef1 100644 --- a/packages/sdk/src/util/webHost.ts +++ b/packages/sdk/src/util/webHost.ts @@ -8,7 +8,14 @@ import * as Restify from 'restify'; import { NotFoundError } from 'restify-errors'; import etag from 'etag'; -import { log, MultipeerAdapter } from '..'; +import { validate } from 'jsonschema'; +import manifestSchema from './manifestSchema.json'; +import { resolve } from 'path'; +import { readFile as _readFile } from 'fs'; +import { promisify } from 'util'; +const readFile = promisify(_readFile); + +import { log, MultipeerAdapter, Permissions } from '..'; import { Adapter } from '../internal'; type StaticBufferInfo = { @@ -24,6 +31,7 @@ export class WebHost { private _adapter: Adapter; private _baseDir: string; private _baseUrl: string; + private manifest: Buffer = null; public get adapter() { return this._adapter; } public get baseDir() { return this._baseDir; } @@ -35,6 +43,8 @@ export class WebHost { baseDir?: string; baseUrl?: string; port?: string | number; + permissions?: Permissions[]; + optionalPermissions?: Permissions[]; } = {}) { const pjson = require('../../package.json'); /* eslint-disable-line @typescript-eslint/no-var-requires */ log.info('app', `Node: ${process.version}`); @@ -55,25 +65,29 @@ export class WebHost { this._adapter = new MultipeerAdapter({ port }); // Start listening for new app connections from a multi-peer client. - this._adapter.listen() - .then(server => { - this._baseUrl = this._baseUrl || server.url.replace(/\[::\]/u, '127.0.0.1'); - log.info('app', `${server.name} listening on ${JSON.stringify(server.address())}`); - log.info('app', `baseUrl: ${this.baseUrl}`); - log.info('app', `baseDir: ${this.baseDir}`); - if (this.baseDir) { - this.serveStaticFiles(server); - } - }) - .catch(reason => log.error('app', `Failed to start HTTP server: ${reason}`)); - } - - private serveStaticFiles(server: Restify.Server) { - server.get(`/buffers/:name`, - this.checkStaticBuffers, - Restify.plugins.conditionalRequest(), - this.serveStaticBuffers); - server.get('/*', Restify.plugins.serveStaticFiles(this._baseDir)); + this.validateManifest() + .then(() => this._adapter.listen()) + .then(server => { + this._baseUrl = this._baseUrl || server.url.replace(/\[::\]/u, '127.0.0.1'); + log.info('app', `${server.name} listening on ${JSON.stringify(server.address())}`); + log.info('app', `baseUrl: ${this.baseUrl}`); + log.info('app', `baseDir: ${this.baseDir}`); + + // check if a procedural manifest is needed, and serve if so + this.serveManifestIfNeeded(server, options.permissions, options.optionalPermissions); + + // serve static buffers + server.get(`/buffers/:name`, + this.checkStaticBuffers, + Restify.plugins.conditionalRequest(), + this.serveStaticBuffers); + + // serve static files + if (this.baseDir) { + server.get('/*', Restify.plugins.serveStaticFiles(this._baseDir)); + } + }) + .catch(reason => log.error('app', `Failed to start HTTP server: ${reason}`)); } private checkStaticBuffers = (req: Restify.Request, res: Restify.Response, next: Restify.Next) => { @@ -97,6 +111,55 @@ export class WebHost { } }; + private async validateManifest() { + const manifestPath = resolve(this.baseDir, './manifest.json'); + + try { + this.manifest = await readFile(manifestPath); + } catch { + return; + } + + let manifestJson: any; + try { + manifestJson = JSON.parse(this.manifest.toString('utf8')); + } catch { + log.error('app', `App manifest "${manifestPath}" is not JSON`); + this.manifest = null; + return; + } + + const result = validate(manifestJson, manifestSchema); + if (!result.valid) { + log.error('app', `App manifest "${manifestPath}" is not valid:\n${result.errors.join('\n')}`); + this.manifest = null; + return; + } + } + + private serveManifestIfNeeded( + server: Restify.Server, permissions?: Permissions[], optionalPermissions?: Permissions[] + ) { + // print warning if no manifest supplied + if (!this.manifest && !permissions && !optionalPermissions) { + log.warning('app', + "No MRE manifest provided, and no permissions requested! For this MRE to use protected features, " + + `provide an MRE manifest at "${resolve(this.baseDir, './manifest.json')}", or pass a permissions ` + + "list into the WebHost constructor."); + } + + server.get('/manifest.json', (_, res, next) => { + if (this.manifest) { + res.send(200, this.manifest, { "Content-Type": "application/json" }); + } else if (permissions || optionalPermissions) { + res.json(200, { permissions, optionalPermissions }); + } else { + res.send(404); + } + next(); + }); + } + /** * Serve arbitrary binary blobs from a URL * @param filename A unique string ID for the blob diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index d1254ee34..07523a96f 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -1,12 +1,13 @@ { "extends": "../tsconfig.packages.json", "include": [ - "./src/**/*.ts", "./types/**/*.d.ts" + "./src/**/*.ts", "./src/**/*.json", "./types/**/*.d.ts" ], "compilerOptions": { "outDir": "./built", /* Redirect output structure to the directory. */ "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ - "typeRoots": ["./types"] + "typeRoots": ["./types"], + "resolveJsonModule": true }, "references": [ {