mirror of
https://github.com/bitwarden/browser
synced 2026-02-11 22:13:32 +00:00
introduce integration service
This commit is contained in:
@@ -133,6 +133,7 @@ export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", {
|
||||
|
||||
// Tools
|
||||
|
||||
export const INTEGRATION_DISK = new StateDefinition("integration", "disk");
|
||||
export const GENERATOR_DISK = new StateDefinition("generator", "disk");
|
||||
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");
|
||||
export const BROWSER_SEND_MEMORY = new StateDefinition("sendBrowser", "memory");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { OrganizationEncryptor } from "./cryptography/organization-encryptor.abstraction";
|
||||
import { UserEncryptor } from "./cryptography/user-encryptor.abstraction";
|
||||
import { VendorId } from "./integration/metadata/type";
|
||||
|
||||
/** error emitted when the `SingleUserDependency` changes Ids */
|
||||
export type UserChangedError = {
|
||||
@@ -151,6 +152,10 @@ export type SingleUserDependency = {
|
||||
singleUserId$: Observable<UserId>;
|
||||
};
|
||||
|
||||
export type SingleVendorDependency = {
|
||||
singleVendorId$: Observable<VendorId>;
|
||||
};
|
||||
|
||||
/** A pattern for types that emit values exclusively when the dependency
|
||||
* emits a message.
|
||||
*
|
||||
|
||||
48
libs/common/src/tools/integration/integration-key.ts
Normal file
48
libs/common/src/tools/integration/integration-key.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { INTEGRATION_DISK } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { PrivateClassifier } from "../private-classifier";
|
||||
import { ObjectKey } from "../state/object-key";
|
||||
|
||||
import { IntegrationRegistry } from "./metadata/registry";
|
||||
import { ExtensionSite, VendorId } from "./metadata/type";
|
||||
|
||||
export class IntegrationKey<T> {
|
||||
constructor(
|
||||
private site: ExtensionSite,
|
||||
private deserialize: (json: Jsonify<T>) => T,
|
||||
private initial?: T,
|
||||
) {}
|
||||
|
||||
/** Constructs an object key
|
||||
* @remarks this method should only be called by the integration service.
|
||||
* It is not provided for general use.
|
||||
*/
|
||||
toObjectKey(vendor: VendorId, registry: IntegrationRegistry): ObjectKey<T> {
|
||||
const integration = registry.getIntegration(this.site, vendor);
|
||||
|
||||
// an integrator can only store fields that are exported from the
|
||||
// extension point *and* that were requested by the extension. All
|
||||
// of the data stored by the extension is, for the moment, private.
|
||||
const fields: any[] = integration.extension.availableFields.filter((available) =>
|
||||
integration.requestedFields.includes(available),
|
||||
);
|
||||
const classifier = new PrivateClassifier(fields);
|
||||
|
||||
const objectKey: ObjectKey<T> = {
|
||||
target: "object",
|
||||
key: `${integration.extension.id}.${integration.product.vendor.id}`,
|
||||
state: INTEGRATION_DISK,
|
||||
classifier,
|
||||
format: "classified",
|
||||
options: {
|
||||
deserializer: this.deserialize,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
initial: this.initial,
|
||||
};
|
||||
|
||||
return objectKey;
|
||||
}
|
||||
}
|
||||
58
libs/common/src/tools/integration/integration.service.ts
Normal file
58
libs/common/src/tools/integration/integration.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { LegacyEncryptorProvider } from "../cryptography/legacy-encryptor-provider";
|
||||
import { SingleUserDependency, SingleVendorDependency } from "../dependencies";
|
||||
import { UserStateSubject } from "../state/user-state-subject";
|
||||
|
||||
import { IntegrationKey } from "./integration-key";
|
||||
import { IntegrationRegistry } from "./metadata/registry";
|
||||
import { ExtensionSite, IntegrationMetadata } from "./metadata/type";
|
||||
|
||||
const DEFAULT_INTEGRATION_STORAGE_FRAME = 512;
|
||||
|
||||
export class IntegrationService {
|
||||
constructor(
|
||||
private readonly registry: IntegrationRegistry,
|
||||
private readonly stateProvider: StateProvider,
|
||||
private readonly encryptorProvider: LegacyEncryptorProvider,
|
||||
) {}
|
||||
|
||||
/** Observe the integrations available at an extension site */
|
||||
integrations$(site: ExtensionSite): Observable<IntegrationMetadata[]> {
|
||||
const integrations = this.registry.getIntegrations(site);
|
||||
|
||||
// slot into a behavior subject so that the integration data is always available
|
||||
// downstream; once dynamic registrations are supported this could be extended
|
||||
// to publish updated lists without changing the signature
|
||||
const integrations$ = new BehaviorSubject(integrations);
|
||||
|
||||
return integrations$.asObservable();
|
||||
}
|
||||
|
||||
/** Create a subject monitoring the integration's private data store */
|
||||
state$<T extends object>(
|
||||
key: IntegrationKey<T>,
|
||||
dependencies: SingleUserDependency & SingleVendorDependency,
|
||||
): Promise<UserStateSubject<T>> {
|
||||
const singleUserEncryptor$ = this.encryptorProvider.userEncryptor$(
|
||||
DEFAULT_INTEGRATION_STORAGE_FRAME,
|
||||
dependencies,
|
||||
);
|
||||
|
||||
const result$ = combineLatest([singleUserEncryptor$, dependencies.singleVendorId$]).pipe(
|
||||
map(([encryptor, vendor]) => {
|
||||
const subject = new UserStateSubject<T, T, Record<string, never>>(
|
||||
key.toObjectKey(vendor, this.registry),
|
||||
(key) => this.stateProvider.getUser(encryptor.userId, key),
|
||||
{ singleUserEncryptor$ },
|
||||
);
|
||||
|
||||
return subject;
|
||||
}),
|
||||
);
|
||||
|
||||
return firstValueFrom(result$);
|
||||
}
|
||||
}
|
||||
34
libs/common/src/tools/integration/metadata/data.ts
Normal file
34
libs/common/src/tools/integration/metadata/data.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const Site = Object.freeze({
|
||||
forwarder: "forwarder",
|
||||
} as const);
|
||||
|
||||
export const Field = Object.freeze({
|
||||
token: "token",
|
||||
baseUrl: "baseUrl",
|
||||
domain: "domain",
|
||||
prefix: "prefix",
|
||||
} as const);
|
||||
|
||||
export const FieldsBySite = {
|
||||
[Site.forwarder]: [Field.token, Field.baseUrl, Field.domain, Field.prefix],
|
||||
} as const;
|
||||
|
||||
export const Vendor = Object.freeze({
|
||||
anonaddy: "anonaddy",
|
||||
duckduckgo: "duckduckgo",
|
||||
fastmail: "fastmail",
|
||||
mozilla: "mozilla",
|
||||
forwardemail: "forwardemail",
|
||||
simplelogin: "simplelogin",
|
||||
} as const);
|
||||
|
||||
export const VendorsByExtension = {
|
||||
[Site.forwarder]: [
|
||||
Vendor.anonaddy,
|
||||
Vendor.duckduckgo,
|
||||
Vendor.fastmail,
|
||||
Vendor.mozilla,
|
||||
Vendor.forwardemail,
|
||||
Vendor.simplelogin,
|
||||
] as const,
|
||||
} as const;
|
||||
9
libs/common/src/tools/integration/metadata/extension.ts
Normal file
9
libs/common/src/tools/integration/metadata/extension.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Field, Site } from "./data";
|
||||
import { ExtensionMetadata } from "./type";
|
||||
|
||||
export const Extension: Record<string, ExtensionMetadata> = {
|
||||
[Site.forwarder]: {
|
||||
id: Site.forwarder,
|
||||
availableFields: [Field.baseUrl, Field.domain, Field.prefix, Field.token],
|
||||
},
|
||||
};
|
||||
35
libs/common/src/tools/integration/metadata/factory.ts
Normal file
35
libs/common/src/tools/integration/metadata/factory.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Extension } from "./extension";
|
||||
import { IntegrationRegistry } from "./registry";
|
||||
import { IntegrationMetadata, VendorMetadata } from "./type";
|
||||
import { AddyIo, AddyIoIntegrations } from "./vendor/addy-io";
|
||||
import { DuckDuckGo, DuckDuckGoIntegrations } from "./vendor/duck-duck-go";
|
||||
import { Fastmail, FastmailIntegrations } from "./vendor/fastmail";
|
||||
import { ForwardEmail, ForwardEmailIntegrations } from "./vendor/forward-email";
|
||||
import { Mozilla, MozillaIntegrations } from "./vendor/mozilla";
|
||||
import { SimpleLogin, SimpleLoginIntegrations } from "./vendor/simple-login";
|
||||
|
||||
/** Constructs the integration registry */
|
||||
export function buildRegistry() {
|
||||
// FIXME: find a better way to build the registry than a hard-coded factory function
|
||||
function registerAll(vendor: VendorMetadata, integrations: IntegrationMetadata[]) {
|
||||
registry.registerVendor(vendor);
|
||||
for (const integration of integrations) {
|
||||
registry.registerIntegration(integration);
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new IntegrationRegistry();
|
||||
|
||||
for (const site of Reflect.ownKeys(Extension) as string[]) {
|
||||
registry.registerExtension(Extension[site]);
|
||||
}
|
||||
|
||||
registerAll(AddyIo, AddyIoIntegrations);
|
||||
registerAll(DuckDuckGo, DuckDuckGoIntegrations);
|
||||
registerAll(Fastmail, FastmailIntegrations);
|
||||
registerAll(ForwardEmail, ForwardEmailIntegrations);
|
||||
registerAll(Mozilla, MozillaIntegrations);
|
||||
registerAll(SimpleLogin, SimpleLoginIntegrations);
|
||||
|
||||
return registry;
|
||||
}
|
||||
87
libs/common/src/tools/integration/metadata/registry.ts
Normal file
87
libs/common/src/tools/integration/metadata/registry.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ExtensionPointId } from "../extension-point-id";
|
||||
|
||||
import {
|
||||
ExtensionMetadata,
|
||||
ExtensionSite,
|
||||
IntegrationMetadata,
|
||||
VendorId,
|
||||
VendorMetadata,
|
||||
} from "./type";
|
||||
|
||||
/** Tracks extension points and the integrations that extend them. */
|
||||
export class IntegrationRegistry {
|
||||
private extensions = new Map<ExtensionSite, ExtensionMetadata>();
|
||||
private vendors = new Map<VendorId, VendorMetadata>();
|
||||
private integrations = new Map<ExtensionSite, Map<VendorId, IntegrationMetadata>>();
|
||||
|
||||
/** Registers a site supporting extensibility.
|
||||
* @param site - identifies the site being extended
|
||||
* @param meta - configures the extension site
|
||||
* @return self for method chaining.
|
||||
*/
|
||||
registerExtension(meta: ExtensionMetadata): IntegrationRegistry {
|
||||
if (!this.extensions.has(meta.id)) {
|
||||
this.extensions.set(meta.id, meta);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getExtension(site: ExtensionPointId): ExtensionMetadata | undefined {
|
||||
const extension = this.extensions.get(site);
|
||||
return extension ? extension : undefined;
|
||||
}
|
||||
|
||||
/** Registers a site supporting extensibility.
|
||||
* @param site - identifies the site being extended
|
||||
* @param meta - configures the extension site
|
||||
* @return self for method chaining.
|
||||
*/
|
||||
registerVendor(meta: VendorMetadata): IntegrationRegistry {
|
||||
if (!this.vendors.has(meta.id)) {
|
||||
this.vendors.set(meta.id, meta);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getVendor(vendorId: VendorId): VendorMetadata | undefined {
|
||||
const vendor = this.vendors.get(vendorId);
|
||||
return vendor ? vendor : undefined;
|
||||
}
|
||||
|
||||
/** Registers an integration provided by a vendor to an extension site.
|
||||
* The vendor and site MUST be registered before the integration.
|
||||
* @param site - identifies the site being extended
|
||||
* @param meta - configures the extension site
|
||||
* @return self for method chaining.
|
||||
*/
|
||||
registerIntegration(meta: IntegrationMetadata): IntegrationRegistry {
|
||||
if (!this.extensions.has(meta.extension.id)) {
|
||||
throw new Error(`Unrecognized site: ${meta.extension.id}`);
|
||||
} else if (!this.vendors.has(meta.product.vendor.id)) {
|
||||
throw new Error(`Unrecognized vendor: ${meta.product.vendor.id}`);
|
||||
}
|
||||
|
||||
let integrations = this.integrations.get(meta.extension.id);
|
||||
if (!integrations) {
|
||||
integrations = new Map();
|
||||
this.integrations.set(meta.extension.id, integrations);
|
||||
}
|
||||
|
||||
if (!integrations.has(meta.product.vendor.id)) {
|
||||
integrations.set(meta.product.vendor.id, meta);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getIntegration(site: ExtensionPointId, vendor: VendorId): IntegrationMetadata | undefined {
|
||||
return this.integrations.get(site)?.get(vendor) ?? undefined;
|
||||
}
|
||||
|
||||
getIntegrations(site: ExtensionPointId): IntegrationMetadata[] {
|
||||
const integrations = Array.from(this.integrations.get(site)?.values() ?? []);
|
||||
return integrations;
|
||||
}
|
||||
}
|
||||
63
libs/common/src/tools/integration/metadata/type.ts
Normal file
63
libs/common/src/tools/integration/metadata/type.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { FieldsBySite, Site, VendorsByExtension } from "./data";
|
||||
|
||||
/** well-known name for a feature extensible through an integration. */
|
||||
export type ExtensionSite = keyof typeof Site;
|
||||
|
||||
/** well-known name for a field surfaced from an extension site to a vendor. */
|
||||
export type DisclosedField = (typeof FieldsBySite)[ExtensionSite][number];
|
||||
|
||||
/** Identifies a vendor integrated into bitwarden */
|
||||
export type VendorId = (typeof VendorsByExtension)[ExtensionSite][number];
|
||||
|
||||
/** The capabilities and descriptive content for an integration */
|
||||
export type ExtensionMetadata = {
|
||||
/** Uniquely identifies the integrator. */
|
||||
id: ExtensionSite;
|
||||
|
||||
/** Lists the fields disclosed by the extension to the vendor */
|
||||
availableFields: DisclosedField[];
|
||||
};
|
||||
|
||||
/** Catalogues an integration's hosting status.
|
||||
* selfHost: "never" always uses the service's base URL
|
||||
* selfHost: "maybe" allows the user to override the service's
|
||||
* base URL with their own.
|
||||
* selfHost: "always" requires a base URL.
|
||||
*/
|
||||
export type ApiHost =
|
||||
| { selfHost: "never"; baseUrl: string }
|
||||
| { selfHost: "maybe"; baseUrl: string }
|
||||
| { selfHost: "always" };
|
||||
|
||||
/** The capabilities and descriptive content for an integration */
|
||||
export type VendorMetadata = {
|
||||
/** Uniquely identifies the integrator. */
|
||||
id: VendorId;
|
||||
|
||||
/** Brand name of the integrator. */
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** Describes an integration provided by a vendor */
|
||||
export type IntegrationMetadata = {
|
||||
/** The part of Bitwarden extended by the vendor's services */
|
||||
extension: ExtensionMetadata;
|
||||
|
||||
/** Product description */
|
||||
product: {
|
||||
/** The vendor providing the extension */
|
||||
vendor: VendorMetadata;
|
||||
|
||||
/** The branded name of the product, if it varies from the Vendor name */
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/** Hosting provider capabilities required by the integration */
|
||||
host: ApiHost;
|
||||
|
||||
/** Lists the fields disclosed by the extension to the vendor.
|
||||
* This should be a subset of the `availableFields` listed in
|
||||
* the extension.
|
||||
*/
|
||||
requestedFields: DisclosedField[];
|
||||
};
|
||||
22
libs/common/src/tools/integration/metadata/vendor/addy-io.ts
vendored
Normal file
22
libs/common/src/tools/integration/metadata/vendor/addy-io.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Field, Vendor } from "../data";
|
||||
import { Extension } from "../extension";
|
||||
import { IntegrationMetadata } from "../type";
|
||||
|
||||
export const AddyIo = {
|
||||
id: Vendor.anonaddy,
|
||||
name: "Addy.io",
|
||||
};
|
||||
|
||||
export const AddyIoIntegrations: IntegrationMetadata[] = [
|
||||
{
|
||||
extension: Extension.forwarder,
|
||||
product: {
|
||||
vendor: AddyIo,
|
||||
},
|
||||
host: {
|
||||
selfHost: "maybe",
|
||||
baseUrl: "https://app.addy.io",
|
||||
},
|
||||
requestedFields: [Field.token, Field.baseUrl, Field.domain],
|
||||
},
|
||||
];
|
||||
22
libs/common/src/tools/integration/metadata/vendor/duck-duck-go.ts
vendored
Normal file
22
libs/common/src/tools/integration/metadata/vendor/duck-duck-go.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Field, Vendor } from "../data";
|
||||
import { Extension } from "../extension";
|
||||
import { IntegrationMetadata } from "../type";
|
||||
|
||||
export const DuckDuckGo = {
|
||||
id: Vendor.duckduckgo,
|
||||
name: "DuckDuckGo",
|
||||
};
|
||||
|
||||
export const DuckDuckGoIntegrations: IntegrationMetadata[] = [
|
||||
{
|
||||
extension: Extension.forwarder,
|
||||
product: {
|
||||
vendor: DuckDuckGo,
|
||||
},
|
||||
host: {
|
||||
selfHost: "never",
|
||||
baseUrl: "https://quack.duckduckgo.com/api",
|
||||
},
|
||||
requestedFields: [Field.token],
|
||||
},
|
||||
];
|
||||
23
libs/common/src/tools/integration/metadata/vendor/fastmail.ts
vendored
Normal file
23
libs/common/src/tools/integration/metadata/vendor/fastmail.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Field, Vendor } from "../data";
|
||||
import { Extension } from "../extension";
|
||||
import { IntegrationMetadata } from "../type";
|
||||
|
||||
export const Fastmail = {
|
||||
id: Vendor.fastmail,
|
||||
name: "Fastmail",
|
||||
};
|
||||
|
||||
// integration-wide configuration
|
||||
export const FastmailIntegrations: IntegrationMetadata[] = [
|
||||
{
|
||||
extension: Extension.forwarder,
|
||||
product: {
|
||||
vendor: Fastmail,
|
||||
},
|
||||
host: {
|
||||
selfHost: "maybe",
|
||||
baseUrl: "https://api.fastmail.com",
|
||||
},
|
||||
requestedFields: [Field.token],
|
||||
},
|
||||
];
|
||||
22
libs/common/src/tools/integration/metadata/vendor/forward-email.ts
vendored
Normal file
22
libs/common/src/tools/integration/metadata/vendor/forward-email.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Field, Vendor } from "../data";
|
||||
import { Extension } from "../extension";
|
||||
import { IntegrationMetadata } from "../type";
|
||||
|
||||
export const ForwardEmail = {
|
||||
id: Vendor.forwardemail,
|
||||
name: "Forward Email",
|
||||
};
|
||||
|
||||
export const ForwardEmailIntegrations: IntegrationMetadata[] = [
|
||||
{
|
||||
extension: Extension.forwarder,
|
||||
product: {
|
||||
vendor: ForwardEmail,
|
||||
},
|
||||
host: {
|
||||
selfHost: "never",
|
||||
baseUrl: "https://api.forwardemail.net",
|
||||
},
|
||||
requestedFields: [Field.domain, Field.token],
|
||||
},
|
||||
];
|
||||
23
libs/common/src/tools/integration/metadata/vendor/mozilla.ts
vendored
Normal file
23
libs/common/src/tools/integration/metadata/vendor/mozilla.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Field, Vendor } from "../data";
|
||||
import { Extension } from "../extension";
|
||||
import { IntegrationMetadata } from "../type";
|
||||
|
||||
export const Mozilla = {
|
||||
id: Vendor.mozilla,
|
||||
name: "Mozilla",
|
||||
};
|
||||
|
||||
export const MozillaIntegrations: IntegrationMetadata[] = [
|
||||
{
|
||||
extension: Extension.forwarder,
|
||||
product: {
|
||||
vendor: Mozilla,
|
||||
name: "Firefox Relay",
|
||||
},
|
||||
host: {
|
||||
selfHost: "never",
|
||||
baseUrl: "https://relay.firefox.com/api",
|
||||
},
|
||||
requestedFields: [Field.token],
|
||||
},
|
||||
];
|
||||
22
libs/common/src/tools/integration/metadata/vendor/simple-login.ts
vendored
Normal file
22
libs/common/src/tools/integration/metadata/vendor/simple-login.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Field, Vendor } from "../data";
|
||||
import { Extension } from "../extension";
|
||||
import { IntegrationMetadata, VendorMetadata } from "../type";
|
||||
|
||||
export const SimpleLogin: VendorMetadata = {
|
||||
id: Vendor.simplelogin,
|
||||
name: "SimpleLogin",
|
||||
};
|
||||
|
||||
export const SimpleLoginIntegrations: IntegrationMetadata[] = [
|
||||
{
|
||||
extension: Extension.forwarder,
|
||||
product: {
|
||||
vendor: SimpleLogin,
|
||||
},
|
||||
host: {
|
||||
selfHost: "maybe",
|
||||
baseUrl: "https://app.simplelogin.io",
|
||||
},
|
||||
requestedFields: [Field.baseUrl, Field.token, Field.domain],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user