1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[PM-9613] port forwarders to integrations (#10075)

* introduced forwarder integrations
* simply contexts
* report error and message when both are present in an RPC response
This commit is contained in:
✨ Audrey ✨
2024-07-30 08:40:52 -04:00
committed by GitHub
parent 8f437dc773
commit 8c78959aaf
70 changed files with 2392 additions and 2415 deletions

View File

@@ -1,9 +1,12 @@
import { IntegrationContext } from "./integration-context";
import { IntegrationMetadata } from "./integration-metadata";
import { ApiSettings, TokenHeader } from "./rpc";
import { ApiSettings, IntegrationRequest, TokenHeader } from "./rpc";
/** Configures integration-wide settings */
export type IntegrationConfiguration = IntegrationMetadata & {
/** Creates the authentication header for all integration remote procedure calls */
authenticate: (settings: ApiSettings, context: IntegrationContext) => TokenHeader;
authenticate: (
request: IntegrationRequest,
context: IntegrationContext<ApiSettings>,
) => TokenHeader;
};

View File

@@ -25,7 +25,7 @@ describe("IntegrationContext", () => {
describe("baseUrl", () => {
it("outputs the base url from metadata", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n);
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
const result = context.baseUrl();
@@ -41,15 +41,15 @@ describe("IntegrationContext", () => {
};
i18n.t.mockReturnValue("error");
const context = new IntegrationContext(noBaseUrl, i18n);
const context = new IntegrationContext(noBaseUrl, null, i18n);
expect(() => context.baseUrl()).toThrow("error");
});
it("reads from the settings", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n);
const context = new IntegrationContext(EXAMPLE_META, { baseUrl: "httpbin.org" }, i18n);
const result = context.baseUrl({ baseUrl: "httpbin.org" });
const result = context.baseUrl();
expect(result).toBe("httpbin.org");
});
@@ -62,9 +62,9 @@ describe("IntegrationContext", () => {
baseUrl: "example.com",
selfHost: "never",
};
const context = new IntegrationContext(selfHostNever, i18n);
const context = new IntegrationContext(selfHostNever, { baseUrl: "httpbin.org" }, i18n);
const result = context.baseUrl({ baseUrl: "httpbin.org" });
const result = context.baseUrl();
expect(result).toBe("example.com");
});
@@ -77,11 +77,22 @@ describe("IntegrationContext", () => {
baseUrl: "example.com",
selfHost: "always",
};
const context = new IntegrationContext(selfHostAlways, i18n);
const context = new IntegrationContext(selfHostAlways, { baseUrl: "http.bin" }, i18n);
// expect success
const result = context.baseUrl({ baseUrl: "http.bin" });
const result = context.baseUrl();
expect(result).toBe("http.bin");
});
it("fails when the settings are empty and selfhost is 'always'", () => {
const selfHostAlways: IntegrationMetadata = {
id: "simplelogin" as IntegrationId, // arbitrary
name: "Example",
extends: ["forwarder"], // arbitrary
baseUrl: "example.com",
selfHost: "always",
};
const context = new IntegrationContext(selfHostAlways, {}, i18n);
// expect error
i18n.t.mockReturnValue("error");
@@ -97,7 +108,7 @@ describe("IntegrationContext", () => {
selfHost: "maybe",
};
const context = new IntegrationContext(selfHostMaybe, i18n);
const context = new IntegrationContext(selfHostMaybe, null, i18n);
const result = context.baseUrl();
@@ -113,9 +124,9 @@ describe("IntegrationContext", () => {
selfHost: "maybe",
};
const context = new IntegrationContext(selfHostMaybe, i18n);
const context = new IntegrationContext(selfHostMaybe, { baseUrl: "httpbin.org" }, i18n);
const result = context.baseUrl({ baseUrl: "httpbin.org" });
const result = context.baseUrl();
expect(result).toBe("httpbin.org");
});
@@ -123,39 +134,47 @@ describe("IntegrationContext", () => {
describe("authenticationToken", () => {
it("reads from the settings", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n);
const context = new IntegrationContext(EXAMPLE_META, { token: "example" }, i18n);
const result = context.authenticationToken({ token: "example" });
const result = context.authenticationToken();
expect(result).toBe("example");
});
it("base64 encodes the read value", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n);
it("suffix is appended to the token", () => {
const context = new IntegrationContext(EXAMPLE_META, { token: "example" }, i18n);
const result = context.authenticationToken({ token: "example" }, { base64: true });
const result = context.authenticationToken({ suffix: " with suffix" });
expect(result).toBe("example with suffix");
});
it("base64 encodes the read value", () => {
const context = new IntegrationContext(EXAMPLE_META, { token: "example" }, i18n);
const result = context.authenticationToken({ base64: true });
expect(result).toBe("ZXhhbXBsZQ==");
});
it("throws an error when the value is missing", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n);
const context = new IntegrationContext(EXAMPLE_META, {}, i18n);
i18n.t.mockReturnValue("error");
expect(() => context.authenticationToken({})).toThrow("error");
expect(() => context.authenticationToken()).toThrow("error");
});
it("throws an error when the value is empty", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n);
it.each([[undefined], [null], [""]])("throws an error when the value is %p", (token) => {
const context = new IntegrationContext(EXAMPLE_META, { token }, i18n);
i18n.t.mockReturnValue("error");
expect(() => context.authenticationToken({ token: "" })).toThrow("error");
expect(() => context.authenticationToken()).toThrow("error");
});
});
describe("website", () => {
it("returns the website", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n);
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
const result = context.website({ website: "www.example.com" });
@@ -163,7 +182,7 @@ describe("IntegrationContext", () => {
});
it("returns an empty string when the website is not specified", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n);
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
const result = context.website({ website: undefined });
@@ -173,7 +192,7 @@ describe("IntegrationContext", () => {
describe("generatedBy", () => {
it("creates generated by text", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n);
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
i18n.t.mockReturnValue("result");
const result = context.generatedBy({ website: null });
@@ -183,7 +202,7 @@ describe("IntegrationContext", () => {
});
it("creates generated by text including the website", () => {
const context = new IntegrationContext(EXAMPLE_META, i18n);
const context = new IntegrationContext(EXAMPLE_META, null, i18n);
i18n.t.mockReturnValue("result");
const result = context.generatedBy({ website: "www.example.com" });

View File

@@ -2,30 +2,33 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { IntegrationMetadata } from "./integration-metadata";
import { ApiSettings, SelfHostedApiSettings, IntegrationRequest } from "./rpc";
import { ApiSettings, IntegrationRequest } from "./rpc";
/** Utilities for processing integration settings */
export class IntegrationContext {
export class IntegrationContext<Settings extends object> {
/** Instantiates an integration context
* @param metadata - defines integration capabilities
* @param i18n - localizes error messages
*/
constructor(
readonly metadata: IntegrationMetadata,
protected settings: Settings,
protected i18n: I18nService,
) {}
/** Lookup the integration's baseUrl
* @param settings settings that override the baseUrl.
* @returns the baseUrl for the API's integration point.
* - By default this is defined by the metadata
* - When a service allows self-hosting, this can be supplied by `settings`.
* @throws a localized error message when a base URL is neither defined by the metadata or
* supplied by an argument.
*/
baseUrl(settings?: SelfHostedApiSettings) {
baseUrl(): string {
// normalize baseUrl
const setting = settings && "baseUrl" in settings ? settings.baseUrl : "";
const setting =
(this.settings && "baseUrl" in this.settings
? (this.settings.baseUrl as string)
: undefined) ?? "";
let result = "";
// look up definition
@@ -47,18 +50,24 @@ export class IntegrationContext {
}
/** look up a service API's authentication token
* @param settings store the API token
* @param options.base64 when `true`, base64 encodes the result. Defaults to `false`.
* @param options.suffix a string to append to the token. Defaults to empty.
* @returns the user's authentication token
* @throws a localized error message when the token is invalid.
* @remarks the string is thrown for backwards compatibility
*/
authenticationToken(settings: ApiSettings, options: { base64?: boolean } = null) {
if (!settings.token || settings.token === "") {
authenticationToken(
options: { base64?: boolean; suffix?: string } = null,
): Settings extends ApiSettings ? string : never {
// normalize `token` then assert it has a value
let token = "token" in this.settings ? ((this.settings.token as string) ?? "") : "";
if (token === "") {
const error = this.i18n.t("forwaderInvalidToken", this.metadata.name);
throw error;
}
let token = settings.token;
// if a suffix exists, it needs to be included before encoding
token += options?.suffix ?? "";
if (options?.base64) {
token = Utils.fromUtf8ToB64(token);
}

View File

@@ -51,17 +51,41 @@ describe("RestClient", () => {
expect(api.nativeFetch).toHaveBeenCalledWith(expectedRpc.fetchRequest);
});
it.each([[401], [403]])(
it.each([[401] /*,[403]*/])(
"throws an invalid token error when HTTP status is %i",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({ status });
const response = mock<Response>({ status, statusText: null });
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderInvalidToken");
await expect(result).rejects.toEqual("forwaderInvalidToken");
},
);
it.each([
[401, null, null],
[401, undefined, undefined],
[401, undefined, null],
[403, null, null],
[403, undefined, undefined],
[403, undefined, null],
])(
"throws an invalid token error when HTTP status is %i, message is %p, and error is %p",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({
status,
text: () => Promise.resolve(`{ "message": null, "error": null }`),
});
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwaderInvalidToken");
},
);
@@ -83,16 +107,73 @@ describe("RestClient", () => {
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderInvalidTokenWithMessage");
await expect(result).rejects.toEqual("forwaderInvalidTokenWithMessage");
expect(i18n.t).toHaveBeenCalledWith(
"forwarderInvalidTokenWithMessage",
"forwaderInvalidTokenWithMessage",
"mock",
"expected message",
);
},
);
it.each([[500], [501]])(
it.each([[401], [403]])(
"throws an invalid token detailed error when HTTP status is %i and the payload has a %s",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({
status,
text: () =>
Promise.resolve(`{ "error": "that happened", "message": "expected message" }`),
});
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwaderInvalidTokenWithMessage");
expect(i18n.t).toHaveBeenCalledWith(
"forwaderInvalidTokenWithMessage",
"mock",
"that happened: expected message",
);
},
);
it.each([[429], [500], [501]])(
"throws a forwarder error when HTTP status is %i",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({ status, statusText: null });
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderUnknownError");
expect(i18n.t).toHaveBeenCalledWith("forwarderUnknownError", "mock", undefined);
},
);
it.each([[429], [500], [501]])(
"throws a forwarder error when HTTP status is %i and the body is empty",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({
status,
statusText: null,
text: () => Promise.resolve(""),
});
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderUnknownError");
expect(i18n.t).toHaveBeenCalledWith("forwarderUnknownError", "mock", undefined);
},
);
it.each([[429], [500], [501]])(
"throws a forwarder error with the status text when HTTP status is %i",
async (status) => {
const client = new RestClient(api, i18n);
@@ -108,8 +189,10 @@ describe("RestClient", () => {
);
it.each([
[429, "message"],
[500, "message"],
[500, "message"],
[429, "error"],
[501, "error"],
[501, "error"],
])(
@@ -130,6 +213,61 @@ describe("RestClient", () => {
},
);
it.each([[429], [500], [500]])(
"throws a detailed forwarder error when HTTP status is %i and the payload is a string",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({
status,
text: () => Promise.resolve('"expected message"'),
});
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderError");
expect(i18n.t).toHaveBeenCalledWith("forwarderError", "mock", "expected message");
},
);
it.each([[429], [500], [500]])(
"throws an unknown forwarder error when HTTP status is %i and the payload could contain an html tag",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({
status,
statusText: null,
text: () => Promise.resolve("<head>"),
});
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderUnknownError");
expect(i18n.t).toHaveBeenCalledWith("forwarderUnknownError", "mock", undefined);
},
);
it.each([[429], [500], [500]])(
"throws a unknown forwarder error when HTTP status is %i and the payload is malformed",
async (status) => {
const client = new RestClient(api, i18n);
const request: IntegrationRequest = { website: null };
const response = mock<Response>({
status,
text: () => Promise.resolve(`{ foo: "not json" }`),
});
api.nativeFetch.mockResolvedValue(response);
const result = client.fetchJson(rpc, request);
await expect(result).rejects.toEqual("forwarderUnknownError");
expect(i18n.t).toHaveBeenCalledWith("forwarderUnknownError", "mock", undefined);
},
);
it("outputs an error if there's no json payload", async () => {
const client = new RestClient(api, i18n);
rpc.hasJsonPayload.mockReturnValue(false);

View File

@@ -10,59 +10,96 @@ export class RestClient {
private api: ApiService,
private i18n: I18nService,
) {}
/** uses the fetch API to request a JSON payload. */
async fetchJson<Parameters extends IntegrationRequest, Response>(
rpc: JsonRpc<Parameters, Response>,
// FIXME: once legacy password generator is removed, replace forwarder-specific error
// messages with RPC-generalized ones.
async fetchJson<Parameters extends IntegrationRequest, Result>(
rpc: JsonRpc<Parameters, Result>,
params: Parameters,
): Promise<Response> {
): Promise<Result> {
// run the request
const request = rpc.toRequest(params);
const response = await this.api.nativeFetch(request);
// FIXME: once legacy password generator is removed, replace forwarder-specific error
// messages with RPC-generalized ones.
let error: string = undefined;
let cause: string = undefined;
let result: Result = undefined;
let errorKey: string = undefined;
let errorMessage: string = undefined;
const commonError = await this.detectCommonErrors(response);
if (commonError) {
[errorKey, errorMessage] = commonError;
} else if (rpc.hasJsonPayload(response)) {
[result, errorMessage] = rpc.processJson(await response.json());
}
if (result) {
return result;
}
// handle failures
errorKey ??= errorMessage ? "forwarderError" : "forwarderUnknownError";
const error = this.i18n.t(errorKey, rpc.requestor.name, errorMessage);
throw error;
}
private async detectCommonErrors(response: Response): Promise<[string, string] | undefined> {
if (response.status === 401 || response.status === 403) {
cause = await this.tryGetErrorMessage(response);
error = cause ? "forwarderInvalidTokenWithMessage" : "forwarderInvalidToken";
} else if (response.status >= 500) {
cause = await this.tryGetErrorMessage(response);
cause = cause ?? response.statusText;
error = "forwarderError";
const message = await this.tryGetErrorMessage(response);
const key = message ? "forwaderInvalidTokenWithMessage" : "forwaderInvalidToken";
return [key, message];
} else if (response.status === 429 || response.status >= 500) {
const message = await this.tryGetErrorMessage(response);
const key = message ? "forwarderError" : "forwarderUnknownError";
return [key, message];
}
let ok: Response = undefined;
if (!error && rpc.hasJsonPayload(response)) {
[ok, cause] = rpc.processJson(await response.json());
}
// success
if (ok) {
return ok;
}
// failure
if (!error) {
error = cause ? "forwarderError" : "forwarderUnknownError";
}
throw this.i18n.t(error, rpc.requestor.name, cause);
}
private async tryGetErrorMessage(response: Response) {
const body = (await response.text()) ?? "";
if (!body.startsWith("{")) {
// nullish continues processing; false returns undefined
const error =
this.tryFindErrorAsJson(body) ?? this.tryFindErrorAsText(body) ?? response.statusText;
return error || undefined;
}
private tryFindErrorAsJson(body: string) {
// tryParse JSON object or string
const parsable = body.startsWith("{") || body.startsWith(`'`) || body.startsWith(`"`);
if (!parsable) {
// fail-and-continue because it's not JSON
return undefined;
}
let parsed = undefined;
try {
parsed = JSON.parse(body);
} catch {
// fail-and-exit in case `body` is malformed JSON
return false;
}
// could be a string
if (parsed && typeof parsed === "string") {
return parsed;
}
// could be { error?: T, message?: U }
const error = parsed.error?.toString() ?? null;
const message = parsed.message?.toString() ?? null;
// `false` signals no message found
const result = error && message ? `${error}: ${message}` : (error ?? message ?? false);
return result;
}
private tryFindErrorAsText(body: string) {
if (!body.length || body.includes("<")) {
return undefined;
}
const json = JSON.parse(body);
if ("error" in json) {
return json.error;
} else if ("message" in json) {
return json.message;
}
return undefined;
return body;
}
}

View File

@@ -0,0 +1,37 @@
import { Jsonify } from "type-fest";
/** Classifies an object's JSON-serializable data by property into
* 3 categories:
* * Disclosed data MAY be stored in plaintext.
* * Excluded data MUST NOT be saved.
* * The remaining data is secret and MUST be stored using encryption.
*
* This type should not be used to classify functions.
* Data that cannot be serialized by JSON.stringify() should
* be excluded.
*/
export interface Classifier<Plaintext, Disclosed, Secret> {
/** Partitions `secret` into its disclosed properties and secret properties.
* @param value The object to partition
* @returns an object that classifies secrets.
* The `disclosed` member is new and contains disclosed properties.
* The `secret` member is a copy of the secret parameter, including its
* prototype, with all disclosed and excluded properties deleted.
*/
classify(value: Plaintext): { disclosed: Jsonify<Disclosed>; secret: Jsonify<Secret> };
/** Merges the properties of `secret` and `disclosed`. When `secret` and
* `disclosed` contain the same property, the `secret` property overrides
* the `disclosed` property.
* @param disclosed an object whose disclosed properties are merged into
* the output. Unknown properties are ignored.
* @param secret an objects whose properties are merged into the output.
* Excluded properties are ignored. Unknown properties are retained.
* @returns a new object containing the merged data.
*
* @remarks Declassified data is always jsonified--the purpose of classifying it is
* to Jsonify it,
* which causes type conversions.
*/
declassify(disclosed: Jsonify<Disclosed>, secret: Jsonify<Secret>): Jsonify<Plaintext>;
}

View File

@@ -1,5 +1,7 @@
import { Jsonify } from "type-fest";
import { Classifier } from "./classifier";
/** Classifies an object's JSON-serializable data by property into
* 3 categories:
* * Disclosed data MAY be stored in plaintext.
@@ -10,7 +12,9 @@ import { Jsonify } from "type-fest";
* Data that cannot be serialized by JSON.stringify() should
* be excluded.
*/
export class SecretClassifier<Plaintext extends object, Disclosed, Secret> {
export class SecretClassifier<Plaintext extends object, Disclosed, Secret>
implements Classifier<Plaintext, Disclosed, Secret>
{
private constructor(
disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[],
excluded: readonly (keyof Plaintext)[],

View File

@@ -1,15 +1,19 @@
import { mock } from "jest-mock-extended";
import { Jsonify } from "type-fest";
import { GENERATOR_DISK, UserKeyDefinitionOptions } from "../../platform/state";
import { SecretClassifier } from "./secret-classifier";
import { Classifier } from "./classifier";
import { SecretKeyDefinition } from "./secret-key-definition";
describe("SecretKeyDefinition", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean }>();
type TestData = { foo: boolean };
const classifier = mock<Classifier<any, Record<string, never>, TestData>>();
const options: UserKeyDefinitionOptions<any> = { deserializer: (v: any) => v, clearOn: [] };
it("toEncryptedStateKey returns a key", () => {
const expectedOptions: UserKeyDefinitionOptions<any> = {
deserializer: (v: any) => v,
const expectedOptions: UserKeyDefinitionOptions<TestData> = {
deserializer: (v: Jsonify<TestData>) => v,
cleanupDelayMs: 100,
clearOn: ["logout", "lock"],
};

View File

@@ -2,7 +2,7 @@ import { UserKeyDefinitionOptions, UserKeyDefinition } from "../../platform/stat
// eslint-disable-next-line -- `StateDefinition` used as an argument
import { StateDefinition } from "../../platform/state/state-definition";
import { ClassifiedFormat } from "./classified-format";
import { SecretClassifier } from "./secret-classifier";
import { Classifier } from "./classifier";
/** Encryption and storage settings for data stored by a `SecretState`.
*/
@@ -10,7 +10,7 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec
private constructor(
readonly stateDefinition: StateDefinition,
readonly key: string,
readonly classifier: SecretClassifier<Inner, Disclosed, Secret>,
readonly classifier: Classifier<Inner, Disclosed, Secret>,
readonly options: UserKeyDefinitionOptions<Inner>,
// type erasure is necessary here because typescript doesn't support
// higher kinded types that generalize over collections. The invariants
@@ -46,7 +46,7 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec
static value<Value extends object, Disclosed, Secret>(
stateDefinition: StateDefinition,
key: string,
classifier: SecretClassifier<Value, Disclosed, Secret>,
classifier: Classifier<Value, Disclosed, Secret>,
options: UserKeyDefinitionOptions<Value>,
) {
return new SecretKeyDefinition<Value, void, Value, Disclosed, Secret>(
@@ -70,7 +70,7 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec
static array<Item extends object, Disclosed, Secret>(
stateDefinition: StateDefinition,
key: string,
classifier: SecretClassifier<Item, Disclosed, Secret>,
classifier: Classifier<Item, Disclosed, Secret>,
options: UserKeyDefinitionOptions<Item>,
) {
return new SecretKeyDefinition<Item[], number, Item, Disclosed, Secret>(
@@ -94,7 +94,7 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec
static record<Item extends object, Disclosed, Secret, Id extends string | number>(
stateDefinition: StateDefinition,
key: string,
classifier: SecretClassifier<Item, Disclosed, Secret>,
classifier: Classifier<Item, Disclosed, Secret>,
options: UserKeyDefinitionOptions<Item>,
) {
return new SecretKeyDefinition<Record<Id, Item>, Id, Item, Disclosed, Secret>(