1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 22:13:32 +00:00

introduce integration service

This commit is contained in:
✨ Audrey ✨
2024-12-18 12:39:23 -05:00
parent 6b99b191a4
commit 54b2be3707
15 changed files with 474 additions and 0 deletions

View File

@@ -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");

View File

@@ -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.
*

View 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;
}
}

View 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$);
}
}

View 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;

View 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],
},
};

View 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;
}

View 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;
}
}

View 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[];
};

View 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],
},
];

View 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],
},
];

View 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],
},
];

View 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],
},
];

View 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],
},
];

View 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],
},
];