authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 09:36:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 09:36:20-07:00
log50647de0143e68fde051eb1a0ccf4fe63f9947b2
treed8fe144cbca7298f194d7fd3ba7dbaa478df16d7
parentcfbd1457ad093b1cd7f6c53851009a62b676b203
signature Signed by PGP key B5690EEEBB952194

fix(server): check the specific scope for scripts, preview input, and full-access MCP grants (#17772)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

10 files changed, 140 insertions(+), 18 deletions(-)

apps/server/src/auth/EnvironmentAuth.ts+14-2
...@@ -4,6 +4,8 @@ import {...@@ -4,6 +4,8 @@ import {
4 AuthAdministrativeScopes,4 AuthAdministrativeScopes,
5 AuthOrchestrationOperateScope,5 AuthOrchestrationOperateScope,
6 AuthOrchestrationReadScope,6 AuthOrchestrationReadScope,
7 AuthSettingsWriteScope,
8 AuthSourceControlWriteScope,
7 AuthStandardClientScopes,9 AuthStandardClientScopes,
8 type AuthAccessTokenResult,10 type AuthAccessTokenResult,
9 type AuthBrowserSessionResult,11 type AuthBrowserSessionResult,
...@@ -74,7 +76,10 @@ export interface IssuedBearerSession {...@@ -74,7 +76,10 @@ export interface IssuedBearerSession {
74 * token cannot reach the full RPC surface around that cap.76 * token cannot reach the full RPC surface around that cap.
75 *77 *
76 * A read-only grant holds `orchestration:read` alone. Any other grant also78 * A read-only grant holds `orchestration:read` alone. Any other grant also
77 * holds `orchestration:operate` and carries its runtime-mode ceiling.79 * holds `orchestration:operate` and carries its runtime-mode ceiling. A
80 * full-access client may also change environment preferences and clone
81 * repositories, so it holds the scopes those need over RPC. Approving a grant
82 * requires every scope it holds.
78 */83 */
79const MCP_CLIENT_SUBJECT = "mcp-client";84const MCP_CLIENT_SUBJECT = "mcp-client";
80const MCP_CLIENT_SESSION_TTL = Duration.days(30);85const MCP_CLIENT_SESSION_TTL = Duration.days(30);
...@@ -84,7 +89,14 @@ export const mcpClientScopes = (...@@ -84,7 +89,14 @@ export const mcpClientScopes = (
84): ReadonlyArray<AuthEnvironmentScope> =>89): ReadonlyArray<AuthEnvironmentScope> =>
85 access === "read-only"90 access === "read-only"
86 ? [AuthOrchestrationReadScope]91 ? [AuthOrchestrationReadScope]
87 : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope];92 : access === "full-access"
93 ? [
94 AuthOrchestrationReadScope,
95 AuthOrchestrationOperateScope,
96 AuthSettingsWriteScope,
97 AuthSourceControlWriteScope,
98 ]
99 : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope];
88100
89export interface McpClientSession {101export interface McpClientSession {
90 readonly sessionId: AuthSessionId;102 readonly sessionId: AuthSessionId;
apps/server/src/auth/McpOAuth.test.ts+9
...@@ -451,6 +451,15 @@ it.live(...@@ -451,6 +451,15 @@ it.live(
451 const readOnlyResponse = yield* approveWith(readOnly.credential);451 const readOnlyResponse = yield* approveWith(readOnly.credential);
452 expect(readOnlyResponse.status).toBe(400);452 expect(readOnlyResponse.status).toBe(400);
453 expect(readOnlyResponse.message).toContain("cannot grant this access");453 expect(readOnlyResponse.message).toContain("cannot grant this access");
454 const operator = yield* auth.issuePairingCredential({
455 scopes: ["orchestration:read", "orchestration:operate"],
456 });
457 const operatorFull = yield* decide(handler, params, {
458 _tag: "pairing-code",
459 access: "full-access",
460 code: operator.credential,
461 });
462 expect(operatorFull.status).toBe(400);
454463
455 // A read-only code can approve read-only access.464 // A read-only code can approve read-only access.
456 const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] });465 const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] });
apps/server/src/auth/RpcAuthorization.ts+7
...@@ -8,6 +8,8 @@ import {...@@ -8,6 +8,8 @@ import {
8 ServerSettingsPatch,8 ServerSettingsPatch,
9 ProviderInstanceMutation,9 ProviderInstanceMutation,
10 requiredScopesForServerSettingsPatch,10 requiredScopesForServerSettingsPatch,
11 requiredScopesForProjectMutation,
12 ProjectMutation,
11 AuthSettingsWriteScope,13 AuthSettingsWriteScope,
12 AuthProvidersManageScope,14 AuthProvidersManageScope,
13 AuthEnvironmentMaintainScope,15 AuthEnvironmentMaintainScope,
...@@ -242,6 +244,8 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => {...@@ -242,6 +244,8 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => {
242 : [...new Set([...scopes, AuthProvidersManageScope])];244 : [...new Set([...scopes, AuthProvidersManageScope])];
243};245};
244246
247const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation);
248
245const requiredScopesForRpcCall = (249const requiredScopesForRpcCall = (
246 method: string,250 method: string,
247 payload: unknown,251 payload: unknown,
...@@ -260,6 +264,9 @@ const requiredScopesForRpcCall = (...@@ -260,6 +264,9 @@ const requiredScopesForRpcCall = (
260 ];264 ];
261 }265 }
262 if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload);266 if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload);
267 if (method === WS_METHODS.projectsMutate) {
268 return requiredScopesForProjectMutation(decodeProjectMutation(payload));
269 }
263 const guarded = clientRpcRequiredScopes(method, payload);270 const guarded = clientRpcRequiredScopes(method, payload);
264 if (guarded.length > 0) return guarded;271 if (guarded.length > 0) return guarded;
265 return [requiredScopeForRpcMethod(method)];272 return [requiredScopeForRpcMethod(method)];
apps/server/src/preview/ServerBrowser.test.ts+29
...@@ -1126,6 +1126,35 @@ it.live("a page's file picker goes to the controller and takes its uploaded file...@@ -1126,6 +1126,35 @@ it.live("a page's file picker goes to the controller and takes its uploaded file
1126 ).pipe(Effect.provide(layer)),1126 ).pipe(Effect.provide(layer)),
1127);1127);
11281128
1129it.live("only the current controller can answer a file picker", () =>
1130 Effect.scoped(
1131 Effect.gen(function* () {
1132 const { browser, tabId } = yield* ready;
1133 const first = yield* browser.attachViewer(viewerInput(tabId, true));
1134 yield* first.input({ type: "takeControl" });
1135 const setFiles = vi.fn(async (_files: unknown) => {});
1136 contexts[0]!.page.emit("filechooser", {
1137 isMultiple: () => false,
1138 element: () => ({ getAttribute: async () => "" }),
1139 setFiles,
1140 });
1141 let offered = yield* Queue.take(first.output);
1142 while (offered._tag !== "fileChooser") offered = yield* Queue.take(first.output);
1143 const second = yield* browser.attachViewer(viewerInput(tabId, true));
1144 yield* first.input({ type: "releaseControl" });
1145 yield* second.input({ type: "takeControl" });
1146 const answered = yield* browser.answerFileChooser({
1147 threadId: scope.thread.threadId,
1148 tabId,
1149 chooserId: offered.id,
1150 files: [{ name: "a.txt", mimeType: "text/plain", buffer: Buffer.from("a") }],
1151 });
1152 expect(answered).toBe(false);
1153 expect(setFiles).not.toHaveBeenCalled();
1154 }),
1155 ).pipe(Effect.provide(layer)),
1156);
1157
1129it.live("a file picker replaces a stalled viewer backlog instead of being dropped", () =>1158it.live("a file picker replaces a stalled viewer backlog instead of being dropped", () =>
1130 Effect.scoped(1159 Effect.scoped(
1131 Effect.gen(function* () {1160 Effect.gen(function* () {
apps/server/src/preview/ServerBrowser.ts+29-5
...@@ -310,11 +310,15 @@ interface ServerTab {...@@ -310,11 +310,15 @@ interface ServerTab {
310 readonly openerTabId: string | undefined;310 readonly openerTabId: string | undefined;
311 /** Finished downloads, newest last; files live until the tab closes. */311 /** Finished downloads, newest last; files live until the tab closes. */
312 readonly downloads: Array<ServerDownload>;312 readonly downloads: Array<ServerDownload>;
313 /** A page's open file picker, waiting for the controlling viewer's files. */313 /**
314 * A page's open file picker, waiting for the controlling viewer's files. Its
315 * id is reissued for each viewer it is offered to, so only that viewer can answer.
316 */
314 fileChooser: {317 fileChooser: {
315 readonly id: string;318 readonly id: string;
316 readonly chooser: FileChooser;319 readonly chooser: FileChooser;
317 readonly accept: string;320 readonly accept: string;
321 readonly offeredTo: string | null;
318 } | null;322 } | null;
319 dialog: Dialog | null;323 dialog: Dialog | null;
320 setting: PreviewViewportSetting;324 setting: PreviewViewportSetting;
...@@ -1027,14 +1031,27 @@ const make = Effect.gen(function* () {...@@ -1027,14 +1031,27 @@ const make = Effect.gen(function* () {
1027 .catch(() => null)) ?? "";1031 .catch(() => null)) ?? "";
1028 // A newer picker replaces an unanswered one, as a real browser allows only one.1032 // A newer picker replaces an unanswered one, as a real browser allows only one.
1029 if (previous) closeFileChooser(tab);1033 if (previous) closeFileChooser(tab);
1030 tab.fileChooser = { id: NodeCrypto.randomUUID(), chooser, accept: accept.slice(0, 1024) };1034 tab.fileChooser = {
1035 id: NodeCrypto.randomUUID(),
1036 chooser,
1037 accept: accept.slice(0, 1024),
1038 offeredTo: null,
1039 };
1031 pushFileChooser(tab);1040 pushFileChooser(tab);
1032 };1041 };
10331042
1034 const pushFileChooser = (tab: ServerTab) => {1043 const pushFileChooser = (tab: ServerTab) => {
1035 const message = fileChooserMessage(tab);1044 const open = tab.fileChooser;
1036 const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller);1045 const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller);
1037 if (message) controller?.push(message);1046 if (!open || !controller) return;
1047 if (open.offeredTo !== controller.id) {
1048 // A new controller gets a fresh id; the previous one can no longer answer.
1049 tab.fileChooser = { ...open, id: NodeCrypto.randomUUID(), offeredTo: controller.id };
1050 if (open.offeredTo !== null)
1051 for (const viewer of tab.viewers) viewer.push({ _tag: "fileChooserClosed", id: open.id });
1052 }
1053 const message = fileChooserMessage(tab);
1054 if (message) controller.push(message);
1038 };1055 };
10391056
1040 const closeFileChooser = (tab: ServerTab) => {1057 const closeFileChooser = (tab: ServerTab) => {
...@@ -1050,7 +1067,14 @@ const make = Effect.gen(function* () {...@@ -1050,7 +1067,14 @@ const make = Effect.gen(function* () {
1050 ) => {1067 ) => {
1051 const tab = tabs.get(tabKey(input.threadId, input.tabId));1068 const tab = tabs.get(tabKey(input.threadId, input.tabId));
1052 const open = tab?.fileChooser;1069 const open = tab?.fileChooser;
1053 if (!tab || !open || open.id !== input.chooserId) return false;1070 if (
1071 !tab ||
1072 !open ||
1073 open.id !== input.chooserId ||
1074 open.offeredTo === null ||
1075 open.offeredTo !== tab.control.controller
1076 )
1077 return false;
1054 if (input.files.length > 0) {1078 if (input.files.length > 0) {
1055 await open.chooser.setFiles(1079 await open.chooser.setFiles(
1056 open.chooser.isMultiple() ? [...input.files] : input.files.slice(0, 1),1080 open.chooser.isMultiple() ? [...input.files] : input.files.slice(0, 1),
apps/server/src/preview/ServerBrowserStream.test.ts+5-3
...@@ -5,6 +5,7 @@ import { expect, it } from "@effect/vitest";...@@ -5,6 +5,7 @@ import { expect, it } from "@effect/vitest";
5import {5import {
6 AuthOrchestrationOperateScope,6 AuthOrchestrationOperateScope,
7 AuthOrchestrationReadScope,7 AuthOrchestrationReadScope,
8 AuthPreviewOperateScope,
8 AuthSessionId,9 AuthSessionId,
9 PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE,10 PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE,
10 PreviewStreamHostSetup,11 PreviewStreamHostSetup,
...@@ -71,7 +72,7 @@ it.effect.each([...@@ -71,7 +72,7 @@ it.effect.each([
71 Effect.gen(function* () {72 Effect.gen(function* () {
72 const canOperate = hasOperateScope && interactive;73 const canOperate = hasOperateScope && interactive;
73 const scopes = hasOperateScope74 const scopes = hasOperateScope
74 ? [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]75 ? [AuthOrchestrationReadScope, AuthPreviewOperateScope]
75 : [AuthOrchestrationReadScope];76 : [AuthOrchestrationReadScope];
76 const auth = makeAuth(scopes);77 const auth = makeAuth(scopes);
77 const inputs: unknown[] = [];78 const inputs: unknown[] = [];
...@@ -293,11 +294,12 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco...@@ -293,11 +294,12 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco
293 });294 });
294 expect((yield* upload([AuthOrchestrationReadScope], "chooser-1")).status).toBe(403);295 expect((yield* upload([AuthOrchestrationReadScope], "chooser-1")).status).toBe(403);
295 expect(answers).toEqual([]);296 expect(answers).toEqual([]);
296 expect((yield* upload([AuthOrchestrationOperateScope], "chooser-1")).status).toBe(204);297 expect((yield* upload([AuthOrchestrationOperateScope], "chooser-1")).status).toBe(403);
298 expect((yield* upload([AuthPreviewOperateScope], "chooser-1")).status).toBe(204);
297 expect(answers).toEqual([299 expect(answers).toEqual([
298 { chooserId: "chooser-1", files: [{ name: "notes.txt", text: "hello" }] },300 { chooserId: "chooser-1", files: [{ name: "notes.txt", text: "hello" }] },
299 ]);301 ]);
300 expect((yield* upload([AuthOrchestrationOperateScope], "stale")).status).toBe(409);302 expect((yield* upload([AuthPreviewOperateScope], "stale")).status).toBe(409);
301 }).pipe(Effect.scoped),303 }).pipe(Effect.scoped),
302);304);
303305
apps/server/src/preview/ServerBrowserStream.ts+4-4
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest";1import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest";
2import {2import {
3 AuthOrchestrationOperateScope,
4 AuthOrchestrationReadScope,3 AuthOrchestrationReadScope,
4 AuthPreviewOperateScope,
5 PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE,5 PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE,
6 PreviewStreamHostSetup,6 PreviewStreamHostSetup,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
...@@ -71,9 +71,9 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) =>...@@ -71,9 +71,9 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) =>
71 }71 }
72 const session = yield* authenticateMediaRequest(AuthOrchestrationReadScope);72 const session = yield* authenticateMediaRequest(AuthOrchestrationReadScope);
73 const params = url.value.searchParams;73 const params = url.value.searchParams;
74 // Watching needs read access; driving the page is a preview mutation.
74 const canOperate =75 const canOperate =
75 session.scopes.includes(AuthOrchestrationOperateScope) &&76 session.scopes.includes(AuthPreviewOperateScope) && params.get("interactive") !== "false";
76 params.get("interactive") !== "false";
77 const threadId = params.get("threadId") ?? "";77 const threadId = params.get("threadId") ?? "";
78 const tabId = params.get("tabId") ?? "";78 const tabId = params.get("tabId") ?? "";
79 if (threadId.length === 0 || tabId.length === 0) {79 if (threadId.length === 0 || tabId.length === 0) {
...@@ -238,7 +238,7 @@ const UPLOAD_MAX_FILES = 20;...@@ -238,7 +238,7 @@ const UPLOAD_MAX_FILES = 20;
238 */238 */
239const receiveUpload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) =>239const receiveUpload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) =>
240 Effect.gen(function* () {240 Effect.gen(function* () {
241 yield* authenticateMediaRequest(AuthOrchestrationOperateScope);241 yield* authenticateMediaRequest(AuthPreviewOperateScope);
242 const request = yield* HttpServerRequest.HttpServerRequest;242 const request = yield* HttpServerRequest.HttpServerRequest;
243 const fs = yield* FileSystem.FileSystem;243 const fs = yield* FileSystem.FileSystem;
244 const parts = yield* request.multipart.pipe(244 const parts = yield* request.multipart.pipe(
apps/server/src/project/http.ts+4-2
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import {1import {
2 AuthOrchestrationOperateScope,
3 AuthOrchestrationReadScope,2 AuthOrchestrationReadScope,
4 EnvironmentHttpApi,3 EnvironmentHttpApi,
4 requiredScopesForProjectMutation,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";7import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
...@@ -53,7 +53,9 @@ export const layer = HttpApiBuilder.group(...@@ -53,7 +53,9 @@ export const layer = HttpApiBuilder.group(
53 "mutate",53 "mutate",
54 Effect.fn("environment.projects.mutate")(function* (args) {54 Effect.fn("environment.projects.mutate")(function* (args) {
55 yield* annotateEnvironmentRequest(args.endpoint.name);55 yield* annotateEnvironmentRequest(args.endpoint.name);
56 yield* requireEnvironmentScope(AuthOrchestrationOperateScope);56 for (const scope of requiredScopesForProjectMutation(args.payload)) {
57 yield* requireEnvironmentScope(scope);
58 }
57 const operation = projectMutationOperation(projects, args.payload);59 const operation = projectMutationOperation(projects, args.payload);
58 return yield* startup60 return yield* startup
59 .enqueueCommand(operation)61 .enqueueCommand(operation)
packages/contracts/src/settings.test.ts+20-1
...@@ -1,13 +1,14 @@...@@ -1,13 +1,14 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
33
4import type { ProjectId } from "./baseSchemas.ts";4import type { CommandId, ProjectId } from "./baseSchemas.ts";
5import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";5import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
6import {6import {
7 ClientSettingsSchema,7 ClientSettingsSchema,
8 ClientSettingsPatch,8 ClientSettingsPatch,
9 ClaudeSettings,9 ClaudeSettings,
10 DEFAULT_SERVER_SETTINGS,10 DEFAULT_SERVER_SETTINGS,
11 requiredScopesForProjectMutation,
11 resolveProviderInstanceEnabled,12 resolveProviderInstanceEnabled,
12 ServerSettings,13 ServerSettings,
13 ServerSettingsPatch,14 ServerSettingsPatch,
...@@ -1068,3 +1069,21 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => {...@@ -1068,3 +1069,21 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => {
1068 ).toBe(true);1069 ).toBe(true);
1069 });1070 });
1070});1071});
1072
1073describe("requiredScopesForProjectMutation", () => {
1074 it("adds the settings scope only when a mutation carries scripts", () => {
1075 const projectId = "project-1" as ProjectId;
1076 const commandId = "command-1" as CommandId;
1077 expect(
1078 requiredScopesForProjectMutation({ type: "project.update", commandId, projectId }),
1079 ).toEqual(["orchestration:operate"]);
1080 expect(
1081 requiredScopesForProjectMutation({
1082 type: "project.update",
1083 commandId,
1084 projectId,
1085 scripts: [],
1086 }),
1087 ).toEqual(["orchestration:operate", "settings:write"]);
1088 });
1089});
packages/contracts/src/settings.ts+19-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { SshDeviceHostConfigs } from "./device.ts";1import { SshDeviceHostConfigs } from "./device.ts";
2import {2import {
3 AuthOrchestrationOperateScope,
3 AuthSettingsWriteScope,4 AuthSettingsWriteScope,
4 AuthProvidersManageScope,5 AuthProvidersManageScope,
5 type AuthEnvironmentScope,6 type AuthEnvironmentScope,
...@@ -27,7 +28,7 @@ import {...@@ -27,7 +28,7 @@ import {
27 ProviderOptionSelections,28 ProviderOptionSelections,
28} from "./model.ts";29} from "./model.ts";
29import { ModelSelection } from "./modelSelection.ts";30import { ModelSelection } from "./modelSelection.ts";
30import { ProjectScript } from "./project.ts";31import { ProjectScript, type ProjectMutation } from "./project.ts";
31import { DEFAULT_RUNTIME_MODE, RuntimeMode } from "./providerPolicy.ts";32import { DEFAULT_RUNTIME_MODE, RuntimeMode } from "./providerPolicy.ts";
32import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";33import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";
33import {34import {
...@@ -1583,6 +1584,23 @@ export function requiredScopesForServerSettingsPatch(...@@ -1583,6 +1584,23 @@ export function requiredScopesForServerSettingsPatch(
1583 ];1584 ];
1584}1585}
15851586
1587/**
1588 * Project scripts run on the host when a worktree is created or a thread
1589 * settles, so a project mutation that carries them needs the same grant as
1590 * saving project scripts through settings, on top of `orchestration:operate`.
1591 */
1592export function requiredScopesForProjectMutation(
1593 mutation: ProjectMutation,
1594): ReadonlyArray<AuthEnvironmentScope> {
1595 if (mutation.type === "project.delete" || mutation.scripts === undefined) {
1596 return [AuthOrchestrationOperateScope];
1597 }
1598 return [
1599 AuthOrchestrationOperateScope,
1600 ...requiredScopesForServerSettingsPatch({ defaultProjectScripts: mutation.scripts }),
1601 ];
1602}
1603
1586export const ClientSettingsPatch = Schema.Struct({1604export const ClientSettingsPatch = Schema.Struct({
1587 notificationMode: Schema.optionalKey(NotificationMode),1605 notificationMode: Schema.optionalKey(NotificationMode),
1588 inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),1606 inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),