Add new SendApiServer that uses the SDK (#20170)

* Add new SendApiServer that uses the SDK
This commit is contained in:
adudek-bw 2026-06-02 14:19:45 -04:00 committed by GitHub
parent f0bc8d0c46
commit f9c569379c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 918 additions and 118 deletions

View File

@ -215,8 +215,10 @@ import {
PasswordStrengthServiceAbstraction,
} from "@bitwarden/common/tools/password-strength";
import { createSystemServiceProvider } from "@bitwarden/common/tools/providers";
import { SendApiServiceSelector } from "@bitwarden/common/tools/send/services/send-api-service.selector";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service";
import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendSdkApiService } from "@bitwarden/common/tools/send/services/send-sdk-api.service";
import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider";
import { SendService } from "@bitwarden/common/tools/send/services/send.service";
import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction";
@ -1150,11 +1152,22 @@ export default class MainBackground {
this.encryptService,
this.configService,
);
this.sendApiService = new SendApiService(
const legacySendApiService = new SendApiService(
this.apiService,
this.fileUploadService,
this.sendService,
);
this.sendApiService = new SendApiServiceSelector(
this.configService,
legacySendApiService,
new SendSdkApiService(
this.sdkService,
legacySendApiService,
this.sendService,
this.accountService,
this.logService,
),
);
this.avatarService = new AvatarService(this.apiService, this.stateProvider);

View File

@ -156,7 +156,9 @@ import {
PasswordStrengthServiceAbstraction,
} from "@bitwarden/common/tools/password-strength";
import { createSystemServiceProvider } from "@bitwarden/common/tools/providers";
import { SendApiServiceSelector } from "@bitwarden/common/tools/send/services/send-api-service.selector";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service";
import { SendSdkApiService } from "@bitwarden/common/tools/send/services/send-sdk-api.service";
import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider";
import { SendService } from "@bitwarden/common/tools/send/services/send.service";
import { UserId } from "@bitwarden/common/types/guid";
@ -319,7 +321,7 @@ export class ServiceContainer {
folderApiService: FolderApiService;
userVerificationApiService: UserVerificationApiService;
organizationApiService: OrganizationApiServiceAbstraction;
sendApiService: SendApiService;
sendApiService: SendApiServiceSelector;
sendTokenService: SendTokenService;
sendPasswordService: SendPasswordService;
devicesApiService: DevicesApiServiceAbstraction;
@ -652,11 +654,22 @@ export class ServiceContainer {
this.configService,
);
this.sendApiService = this.sendApiService = new SendApiService(
const legacySendApiService = new SendApiService(
this.apiService,
this.fileUploadService,
this.sendService,
);
this.sendApiService = new SendApiServiceSelector(
this.configService,
legacySendApiService,
new SendSdkApiService(
this.sdkService,
legacySendApiService,
this.sendService,
this.accountService,
this.logService,
),
);
this.sendPasswordService = new DefaultSendPasswordService(this.cryptoFunctionService);

View File

@ -310,8 +310,10 @@ import {
PasswordStrengthService,
PasswordStrengthServiceAbstraction,
} from "@bitwarden/common/tools/password-strength";
import { SendApiServiceSelector } from "@bitwarden/common/tools/send/services/send-api-service.selector";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service";
import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendSdkApiService } from "@bitwarden/common/tools/send/services/send-sdk-api.service";
import { SendStateProvider as SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider";
import { SendStateProvider as SendStateProviderAbstraction } from "@bitwarden/common/tools/send/services/send-state.provider.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service";
@ -936,10 +938,20 @@ const safeProviders: SafeProvider[] = [
deps: [StateProvider],
}),
safeProvider({
provide: SendApiServiceAbstraction,
provide: SendApiService,
useClass: SendApiService,
deps: [ApiServiceAbstraction, FileUploadServiceAbstraction, InternalSendService],
}),
safeProvider({
provide: SendSdkApiService,
useClass: SendSdkApiService,
deps: [SdkService, SendApiService, InternalSendService, AccountServiceAbstraction, LogService],
}),
safeProvider({
provide: SendApiServiceAbstraction,
useClass: SendApiServiceSelector,
deps: [ConfigService, SendApiService, SendSdkApiService],
}),
safeProvider({
provide: KeyApiService,
useClass: DefaultKeyApiService,

View File

@ -62,6 +62,7 @@ export enum FeatureFlag {
/* Tools */
SendControls = "pm-31885-send-controls",
Pm30110SdkSendsApi = "pm-30110-sdk-sends-api",
SendEventLogging = "pm-36560-send-event-logging",
/* DIRT */
@ -142,6 +143,7 @@ export const DefaultFeatureFlagValue = {
/* Tools */
[FeatureFlag.SendControls]: FALSE,
[FeatureFlag.Pm30110SdkSendsApi]: FALSE,
[FeatureFlag.SendEventLogging]: FALSE,
/* DIRT */

View File

@ -0,0 +1,318 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { SendAccessToken } from "../../../auth/send-access";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { Send } from "../models/domain/send";
import { SendAccessRequest } from "../models/request/send-access.request";
import { SendAccessView } from "../models/view/send-access.view";
import { AuthType } from "../types/auth-type";
import { SendType } from "../types/send-type";
import { SendApiServiceSelector } from "./send-api-service.selector";
import { SendApiService } from "./send-api.service";
import { SendSdkApiService } from "./send-sdk-api.service";
describe("SendApiServiceSelector", () => {
let configService: MockProxy<ConfigService>;
let legacy: MockProxy<SendApiService>;
let sdk: MockProxy<SendSdkApiService>;
let flag$: BehaviorSubject<boolean>;
function buildSelector(initialFlag: boolean): SendApiServiceSelector {
flag$ = new BehaviorSubject<boolean>(initialFlag);
configService.getFeatureFlag$.mockImplementation((key) =>
key === FeatureFlag.Pm30110SdkSendsApi ? flag$.asObservable() : (undefined as any),
);
return new SendApiServiceSelector(configService, legacy, sdk);
}
beforeEach(() => {
configService = mock<ConfigService>();
legacy = mock<SendApiService>();
sdk = mock<SendSdkApiService>();
});
describe("save", () => {
it("routes to legacy when creating a file send, even with the flag on", async () => {
const selector = buildSelector(true);
const send = new Send();
send.id = null;
send.type = SendType.File;
const buffer = mock<EncArrayBuffer>();
await selector.save([send, buffer]);
expect(legacy.save).toHaveBeenCalledWith([send, buffer]);
expect(sdk.save).not.toHaveBeenCalled();
});
it("routes to SDK for text creates when the flag is on", async () => {
const selector = buildSelector(true);
const send = new Send();
send.id = null;
send.type = SendType.Text;
send.authType = AuthType.None;
const buffer = mock<EncArrayBuffer>();
await selector.save([send, buffer]);
expect(sdk.save).toHaveBeenCalledWith([send, buffer]);
expect(legacy.save).not.toHaveBeenCalled();
});
it("routes to SDK for file edits when the flag is on", async () => {
const selector = buildSelector(true);
const send = new Send();
send.id = "existing-id";
send.type = SendType.File;
send.authType = AuthType.None;
const buffer = mock<EncArrayBuffer>();
await selector.save([send, buffer]);
expect(sdk.save).toHaveBeenCalledWith([send, buffer]);
expect(legacy.save).not.toHaveBeenCalled();
});
it("routes to legacy for password-protected creates, even with the flag on", async () => {
const selector = buildSelector(true);
const send = new Send();
send.id = null;
send.type = SendType.Text;
send.authType = AuthType.Password;
const buffer = mock<EncArrayBuffer>();
await selector.save([send, buffer]);
expect(legacy.save).toHaveBeenCalledWith([send, buffer]);
expect(sdk.save).not.toHaveBeenCalled();
});
it("routes to legacy for password-protected edits, even with the flag on", async () => {
const selector = buildSelector(true);
const send = new Send();
send.id = "existing-id";
send.type = SendType.Text;
send.authType = AuthType.Password;
const buffer = mock<EncArrayBuffer>();
await selector.save([send, buffer]);
expect(legacy.save).toHaveBeenCalledWith([send, buffer]);
expect(sdk.save).not.toHaveBeenCalled();
});
it("routes to legacy when the flag is off", async () => {
const selector = buildSelector(false);
const send = new Send();
send.id = "existing-id";
send.type = SendType.Text;
const buffer = mock<EncArrayBuffer>();
await selector.save([send, buffer]);
expect(legacy.save).toHaveBeenCalledWith([send, buffer]);
expect(sdk.save).not.toHaveBeenCalled();
});
});
describe.each([
["delete", (s: SendApiServiceSelector) => s.delete("id")],
["removePassword", (s: SendApiServiceSelector) => s.removePassword("id")],
["deleteSend", (s: SendApiServiceSelector) => s.deleteSend("id")],
])("%s — flag-controlled, no overrides", (methodName, invoke) => {
it("routes to SDK when the flag is on", async () => {
const selector = buildSelector(true);
await invoke(selector);
expect((sdk as any)[methodName]).toHaveBeenCalledWith("id");
expect((legacy as any)[methodName]).not.toHaveBeenCalled();
});
it("routes to legacy when the flag is off", async () => {
const selector = buildSelector(false);
await invoke(selector);
expect((legacy as any)[methodName]).toHaveBeenCalledWith("id");
expect((sdk as any)[methodName]).not.toHaveBeenCalled();
});
});
describe.each([
["getSend", (s: SendApiServiceSelector) => s.getSend("id"), ["id"]],
["getSends", (s: SendApiServiceSelector) => s.getSends(), []],
["putSendRemovePassword", (s: SendApiServiceSelector) => s.putSendRemovePassword("id"), ["id"]],
])("%s — always legacy", (methodName, invoke, expectedArgs) => {
it.each([true, false])("routes to legacy regardless of flag (flag=%s)", async (flagOn) => {
const selector = buildSelector(flagOn);
await invoke(selector);
expect((legacy as any)[methodName]).toHaveBeenCalledWith(...expectedArgs);
expect((sdk as any)[methodName]).not.toHaveBeenCalled();
});
});
describe("postSendAccess", () => {
const request = new SendAccessRequest();
it("routes to legacy when apiUrl is supplied, even with the flag on", async () => {
const selector = buildSelector(true);
await selector.postSendAccess("id", request, "https://other.example");
expect(legacy.postSendAccess).toHaveBeenCalledWith("id", request, "https://other.example");
expect(sdk.postSendAccess).not.toHaveBeenCalled();
});
it("routes to SDK without apiUrl when the flag is on", async () => {
const selector = buildSelector(true);
await selector.postSendAccess("id", request);
expect(sdk.postSendAccess).toHaveBeenCalledWith("id", request);
expect(legacy.postSendAccess).not.toHaveBeenCalled();
});
it("routes to legacy without apiUrl when the flag is off", async () => {
const selector = buildSelector(false);
await selector.postSendAccess("id", request);
expect(legacy.postSendAccess).toHaveBeenCalledWith("id", request);
expect(sdk.postSendAccess).not.toHaveBeenCalled();
});
});
describe("postSendAccessV2", () => {
const accessToken: SendAccessToken = { token: "tok" } as SendAccessToken;
it("routes to legacy when apiUrl is supplied, even with the flag on", async () => {
const selector = buildSelector(true);
await selector.postSendAccessV2(accessToken, "https://other.example");
expect(legacy.postSendAccessV2).toHaveBeenCalledWith(accessToken, "https://other.example");
expect(sdk.postSendAccessV2).not.toHaveBeenCalled();
});
it("routes to SDK without apiUrl when the flag is on", async () => {
const selector = buildSelector(true);
await selector.postSendAccessV2(accessToken);
expect(sdk.postSendAccessV2).toHaveBeenCalledWith(accessToken);
expect(legacy.postSendAccessV2).not.toHaveBeenCalled();
});
it("routes to legacy without apiUrl when the flag is off", async () => {
const selector = buildSelector(false);
await selector.postSendAccessV2(accessToken);
expect(legacy.postSendAccessV2).toHaveBeenCalledWith(accessToken);
expect(sdk.postSendAccessV2).not.toHaveBeenCalled();
});
});
describe("getSendFileDownloadData", () => {
const accessView = mock<SendAccessView>();
const request = new SendAccessRequest();
it("routes to legacy when apiUrl is supplied, even with the flag on", async () => {
const selector = buildSelector(true);
await selector.getSendFileDownloadData(accessView, request, "https://other.example");
expect(legacy.getSendFileDownloadData).toHaveBeenCalledWith(
accessView,
request,
"https://other.example",
);
expect(sdk.getSendFileDownloadData).not.toHaveBeenCalled();
});
it("routes to SDK without apiUrl when the flag is on", async () => {
const selector = buildSelector(true);
await selector.getSendFileDownloadData(accessView, request);
expect(sdk.getSendFileDownloadData).toHaveBeenCalledWith(accessView, request);
expect(legacy.getSendFileDownloadData).not.toHaveBeenCalled();
});
it("routes to legacy without apiUrl when the flag is off", async () => {
const selector = buildSelector(false);
await selector.getSendFileDownloadData(accessView, request);
expect(legacy.getSendFileDownloadData).toHaveBeenCalledWith(accessView, request);
expect(sdk.getSendFileDownloadData).not.toHaveBeenCalled();
});
});
describe("getSendFileDownloadDataV2", () => {
const accessView = mock<SendAccessView>();
const accessToken: SendAccessToken = { token: "tok" } as SendAccessToken;
it("routes to legacy when apiUrl is supplied, even with the flag on", async () => {
const selector = buildSelector(true);
await selector.getSendFileDownloadDataV2(accessView, accessToken, "https://other.example");
expect(legacy.getSendFileDownloadDataV2).toHaveBeenCalledWith(
accessView,
accessToken,
"https://other.example",
);
expect(sdk.getSendFileDownloadDataV2).not.toHaveBeenCalled();
});
it("routes to SDK without apiUrl when the flag is on", async () => {
const selector = buildSelector(true);
await selector.getSendFileDownloadDataV2(accessView, accessToken);
expect(sdk.getSendFileDownloadDataV2).toHaveBeenCalledWith(accessView, accessToken);
expect(legacy.getSendFileDownloadDataV2).not.toHaveBeenCalled();
});
it("routes to legacy without apiUrl when the flag is off", async () => {
const selector = buildSelector(false);
await selector.getSendFileDownloadDataV2(accessView, accessToken);
expect(legacy.getSendFileDownloadDataV2).toHaveBeenCalledWith(accessView, accessToken);
expect(sdk.getSendFileDownloadDataV2).not.toHaveBeenCalled();
});
});
describe("feature flag caching", () => {
it("subscribes to the feature flag once across many calls", async () => {
const selector = buildSelector(true);
await selector.delete("a");
await selector.delete("b");
await selector.deleteSend("c");
await selector.removePassword("d");
expect(configService.getFeatureFlag$).toHaveBeenCalledTimes(1);
});
it("picks up flag changes for subsequent calls", async () => {
const selector = buildSelector(true);
await selector.delete("first");
flag$.next(false);
await selector.delete("second");
expect(sdk.delete).toHaveBeenCalledWith("first");
expect(legacy.delete).toHaveBeenCalledWith("second");
});
});
});

View File

@ -0,0 +1,168 @@
import { Observable, firstValueFrom, map, shareReplay } from "rxjs";
import { SendAccessToken } from "../../../auth/send-access";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ListResponse } from "../../../models/response/list.response";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { Send } from "../models/domain/send";
import { SendAccessRequest } from "../models/request/send-access.request";
import { SendAccessResponse } from "../models/response/send-access.response";
import { SendFileDownloadDataResponse } from "../models/response/send-file-download-data.response";
import { SendResponse } from "../models/response/send.response";
import { SendAccessView } from "../models/view/send-access.view";
import { AuthType } from "../types/auth-type";
import { SendType } from "../types/send-type";
import { SendApiService } from "./send-api.service";
import { SendApiService as SendApiServiceAbstraction } from "./send-api.service.abstraction";
import { SendSdkApiService } from "./send-sdk-api.service";
/**
* Selects between {@link SendApiService} and {@link SendSdkApiService} based on the
* `pm-30110-sdk-sends-api` feature flag.
*
* Methods whose return type is a wire-encrypted shape the SDK cannot produce (`getSend`,
* `getSends`, `putSendRemovePassword`) always route to legacy. Mutations and access-side
* methods are flag-controlled; the SDK service refetches the encrypted form via legacy
* after mutations to keep `InternalSendService` coherent.
*
* A "cross-instance Send" is a Send hosted on a different Bitwarden server than the
* client is signed in to typically the CLI receiving a self-hosted or EU-cloud Send
* link. Callers signal this by passing `apiUrl`; the selector routes those calls to
* legacy because the SDK client targets only its configured environment.
*/
export class SendApiServiceSelector implements SendApiServiceAbstraction {
private readonly service$: Observable<SendApiServiceAbstraction>;
constructor(
configService: ConfigService,
private sendApiService: SendApiService,
private sendSdkApiService: SendSdkApiService,
) {
this.service$ = configService.getFeatureFlag$(FeatureFlag.Pm30110SdkSendsApi).pipe(
map((useSdk) => (useSdk ? this.sendSdkApiService : this.sendApiService)),
shareReplay({ bufferSize: 1, refCount: false }),
);
}
private getService(): Promise<SendApiServiceAbstraction> {
return firstValueFrom(this.service$);
}
/**
* Routes saves to SDK when the flag is on, except for two cases that fall back to
* legacy regardless: new file sends (the SDK generates its own send key, which
* wouldn't match the caller's pre-encrypted file buffer) and password-protected
* sends (the SDK re-applies PBKDF2 to `auth.password`, double-hashing the keyB64
* the client already derived).
*/
async save(sendData: [Send, EncArrayBuffer]): Promise<Send> {
const [send] = sendData;
if (send.id == null && send.type === SendType.File) {
return this.sendApiService.save(sendData);
}
if (send.authType === AuthType.Password) {
return this.sendApiService.save(sendData);
}
return (await this.getService()).save(sendData);
}
async delete(id: string): Promise<any> {
return (await this.getService()).delete(id);
}
/**
* Removes the auth from a send and updates local state.
*
* Under the SDK flag this calls the V2 endpoint, which removes **all auth** on the
* send (password and any other auth type), not just the password. The legacy path
* uses V1, which removes only the password. Callers that need the V1 semantics
* specifically should keep the flag off until V2 ships everywhere.
*/
async removePassword(id: string): Promise<any> {
return (await this.getService()).removePassword(id);
}
/**
* Always routed to legacy. Returns a wire-encrypted `SendResponse`, which the SDK
* cannot produce (the SDK only exposes plaintext views).
*/
async getSend(id: string): Promise<SendResponse> {
return this.sendApiService.getSend(id);
}
/**
* Accesses a send. Routes to legacy whenever `apiUrl` is supplied (cross-instance
* receive, e.g. the CLI opening a self-hosted Send link while signed in to a
* different server) because the SDK client targets only its configured environment.
*/
async postSendAccess(
id: string,
request: SendAccessRequest,
apiUrl?: string,
): Promise<SendAccessResponse> {
if (apiUrl != null) {
return this.sendApiService.postSendAccess(id, request, apiUrl);
}
return (await this.getService()).postSendAccess(id, request);
}
async postSendAccessV2(
accessToken: SendAccessToken,
apiUrl?: string,
): Promise<SendAccessResponse> {
if (apiUrl != null) {
return this.sendApiService.postSendAccessV2(accessToken, apiUrl);
}
return (await this.getService()).postSendAccessV2(accessToken);
}
/**
* Always routed to legacy. Returns a wire-encrypted list of `SendResponse`, which the
* SDK cannot produce; see {@link getSend}.
*/
async getSends(): Promise<ListResponse<SendResponse>> {
return this.sendApiService.getSends();
}
/**
* Always routed to legacy. The selector's `removePassword` is the higher-level flow that
* also refreshes local state; this lower-level method returns a wire-encrypted
* `SendResponse` the SDK cannot produce.
*
* Note that the legacy `PUT /sends/{id}/remove-password` endpoint is V1 it removes
* only the password, not all auth types. See {@link removePassword} for the V2
* (all-auth) behavior under the SDK flag.
*/
async putSendRemovePassword(id: string): Promise<SendResponse> {
return this.sendApiService.putSendRemovePassword(id);
}
async deleteSend(id: string): Promise<any> {
return (await this.getService()).deleteSend(id);
}
/** See {@link postSendAccess} — cross-instance callers (those passing `apiUrl`) route to legacy. */
async getSendFileDownloadData(
send: SendAccessView,
request: SendAccessRequest,
apiUrl?: string,
): Promise<SendFileDownloadDataResponse> {
if (apiUrl != null) {
return this.sendApiService.getSendFileDownloadData(send, request, apiUrl);
}
return (await this.getService()).getSendFileDownloadData(send, request);
}
async getSendFileDownloadDataV2(
send: SendAccessView,
accessToken: SendAccessToken,
apiUrl?: string,
): Promise<SendFileDownloadDataResponse> {
if (apiUrl != null) {
return this.sendApiService.getSendFileDownloadDataV2(send, accessToken, apiUrl);
}
return (await this.getService()).getSendFileDownloadDataV2(send, accessToken);
}
}

View File

@ -4,10 +4,8 @@ import { ListResponse } from "../../../models/response/list.response";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { Send } from "../models/domain/send";
import { SendAccessRequest } from "../models/request/send-access.request";
import { SendRequest } from "../models/request/send.request";
import { SendAccessResponse } from "../models/response/send-access.response";
import { SendFileDownloadDataResponse } from "../models/response/send-file-download-data.response";
import { SendFileUploadDataResponse } from "../models/response/send-file-upload-data.response";
import { SendResponse } from "../models/response/send.response";
import { SendAccessView } from "../models/view/send-access.view";
@ -23,10 +21,6 @@ export abstract class SendApiService {
apiUrl?: string,
): Promise<SendAccessResponse>;
abstract getSends(): Promise<ListResponse<SendResponse>>;
abstract postSend(request: SendRequest): Promise<SendResponse>;
abstract postFileTypeSend(request: SendRequest): Promise<SendFileUploadDataResponse>;
abstract postSendFile(sendId: string, fileId: string, data: FormData): Promise<any>;
abstract putSend(id: string, request: SendRequest): Promise<SendResponse>;
abstract putSendRemovePassword(id: string): Promise<SendResponse>;
abstract deleteSend(id: string): Promise<any>;
abstract getSendFileDownloadData(
@ -39,10 +33,6 @@ export abstract class SendApiService {
accessToken: SendAccessToken,
apiUrl?: string,
): Promise<SendFileDownloadDataResponse>;
abstract renewSendFileUploadUrl(
sendId: string,
fileId: string,
): Promise<SendFileUploadDataResponse>;
abstract removePassword(id: string): Promise<any>;
abstract delete(id: string): Promise<any>;
abstract save(sendData: [Send, EncArrayBuffer]): Promise<Send>;

View File

@ -118,39 +118,6 @@ export class SendApiService implements SendApiServiceAbstraction {
return new ListResponse(r, SendResponse);
}
async postSend(request: SendRequest): Promise<SendResponse> {
const r = await this.apiService.send("POST", "/sends", request, true, true);
return new SendResponse(r);
}
async postFileTypeSend(request: SendRequest): Promise<SendFileUploadDataResponse> {
const r = await this.apiService.send("POST", "/sends/file/v2", request, true, true);
return new SendFileUploadDataResponse(r);
}
async renewSendFileUploadUrl(
sendId: string,
fileId: string,
): Promise<SendFileUploadDataResponse> {
const r = await this.apiService.send(
"GET",
"/sends/" + sendId + "/file/" + fileId,
null,
true,
true,
);
return new SendFileUploadDataResponse(r);
}
postSendFile(sendId: string, fileId: string, data: FormData): Promise<any> {
return this.apiService.send("POST", "/sends/" + sendId + "/file/" + fileId, data, true, false);
}
async putSend(id: string, request: SendRequest): Promise<SendResponse> {
const r = await this.apiService.send("PUT", "/sends/" + id, request, true, true);
return new SendResponse(r);
}
async putSendRemovePassword(id: string): Promise<SendResponse> {
const r = await this.apiService.send(
"PUT",
@ -187,6 +154,39 @@ export class SendApiService implements SendApiServiceAbstraction {
// Send File Upload methods
private async postSend(request: SendRequest): Promise<SendResponse> {
const r = await this.apiService.send("POST", "/sends", request, true, true);
return new SendResponse(r);
}
private async postFileTypeSend(request: SendRequest): Promise<SendFileUploadDataResponse> {
const r = await this.apiService.send("POST", "/sends/file/v2", request, true, true);
return new SendFileUploadDataResponse(r);
}
private async renewSendFileUploadUrl(
sendId: string,
fileId: string,
): Promise<SendFileUploadDataResponse> {
const r = await this.apiService.send(
"GET",
"/sends/" + sendId + "/file/" + fileId,
null,
true,
true,
);
return new SendFileUploadDataResponse(r);
}
private postSendFile(sendId: string, fileId: string, data: FormData): Promise<any> {
return this.apiService.send("POST", "/sends/" + sendId + "/file/" + fileId, data, true, false);
}
private async putSend(id: string, request: SendRequest): Promise<SendResponse> {
const r = await this.apiService.send("PUT", "/sends/" + id, request, true, true);
return new SendResponse(r);
}
private async upload(sendData: [Send, EncArrayBuffer]): Promise<SendResponse> {
const request = new SendRequest(sendData[0], sendData[1]?.buffer.byteLength);

View File

@ -0,0 +1,344 @@
import { catchError, firstValueFrom, switchMap } from "rxjs";
import {
PasswordManagerClient,
SendAddRequest,
SendAuthType,
SendEditRequest,
SendId as SdkSendId,
SendView as SdkSendView,
SendViewType,
} from "@bitwarden/sdk-internal";
import { AccountService } from "../../../auth/abstractions/account.service";
import { SendAccessToken } from "../../../auth/send-access";
import { getUserId } from "../../../auth/services/account.service";
import { ListResponse } from "../../../models/response/list.response";
import { LogService } from "../../../platform/abstractions/log.service";
import { SdkService, asUuid } from "../../../platform/abstractions/sdk/sdk.service";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { UserId } from "../../../types/guid";
import { SendData } from "../models/data/send.data";
import { Send } from "../models/domain/send";
import { SendAccessRequest } from "../models/request/send-access.request";
import { SendAccessResponse } from "../models/response/send-access.response";
import { SendFileDownloadDataResponse } from "../models/response/send-file-download-data.response";
import { SendResponse } from "../models/response/send.response";
import { SendAccessView } from "../models/view/send-access.view";
import { SendView } from "../models/view/send.view";
import { AuthType } from "../types/auth-type";
import { SendType } from "../types/send-type";
import { SendApiService } from "./send-api.service";
import { SendApiService as SendApiServiceAbstraction } from "./send-api.service.abstraction";
import { InternalSendService } from "./send.service.abstraction";
/**
* SDK-backed implementation of `SendApiService`. Save/removePassword mutate via the SDK
* then refetch via legacy to keep `InternalSendService` populated with `EncString`-shaped
* data. Methods returning wire-encrypted shapes have no SDK equivalent and are routed to
* legacy by `SendApiServiceSelector`; the throw-stubs here guard direct callers.
*/
export class SendSdkApiService implements SendApiServiceAbstraction {
constructor(
private sdkService: SdkService,
private legacySendApiService: SendApiService,
private sendService: InternalSendService,
private accountService: AccountService,
private logService: LogService,
) {}
/**
* Saves a send via the SDK. After the mutation, refetches the wire-encrypted form via
* the legacy service to keep `InternalSendService` populated with `EncString`-shaped
* data. Patches the input with server-assigned id/accessId on create so callers reading
* those after `save()` continue to work.
*
* Two cases the SDK cannot currently handle are routed to legacy by
* `SendApiServiceSelector` and rejected here as a guard for direct callers:
*
* - **New file sends.** `SendService.encrypt` produces a pre-encrypted buffer under a
* client-derived key; the SDK's `create_file_send` generates its own key.
* - **Password-protected sends.** `Send.password` is already the PBKDF2-derived
* `keyB64`, but the SDK's `SendAuthType::auth_data`
* (`bitwarden-send/src/send.rs:160-163`) re-applies PBKDF2 to whatever string sits in
* `auth.password`, double-hashing the value. No skip-hash variant exists on the
* public request types.
*/
async save(sendData: [Send, EncArrayBuffer]): Promise<Send> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const [send] = sendData;
if (send.id == null && send.type === SendType.File) {
throw new Error("SendSdkApiService.save: file send creation requires SendApiService.");
}
if (send.authType === AuthType.Password) {
throw new Error("SendSdkApiService.save: password-protected sends require SendApiService.");
}
const sendView = await send.decrypt(userId);
const sdkView = await this.mutateSend(sendView, userId);
// Patch server-assigned identifiers onto the input for callers that read them after
// save (matches the legacy SendApiService contract). The server always returns id
// and accessId on a successful create.
const sendId = sdkView.id as unknown as string;
if (send.id == null) {
send.id = sendId;
send.accessId = sdkView.accessId as unknown as string;
}
try {
return await this.refreshSendFromServer(sendId);
} catch (error) {
// The SDK mutation already landed on the server; only the encrypted-form refetch
// failed. Surfacing this as a save error would prompt the user to retry, which on
// a new send would duplicate it server-side. Log and return the caller's input
// Send — its EncString fields are still valid (the caller encrypted them moments
// ago), so decryption in post-save paths works. InternalSendService reconciles on
// the next sync.
this.logService.error(`Send refresh failed after successful mutation: ${error}`);
return send;
}
}
async delete(id: string): Promise<any> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
return await ref.value.sends().delete(asUuid<SdkSendId>(id));
}),
catchError((error: unknown) => {
this.logService.error(`Failed to delete send: ${error}`);
throw error;
}),
),
);
await this.sendService.delete(id);
}
// Note: the SDK calls the V2 endpoint which removes all auth (password and any other
// auth type), not just the password.
async removePassword(id: string): Promise<any> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
return await ref.value.sends().remove_password(asUuid<SdkSendId>(id));
}),
catchError((error: unknown) => {
this.logService.error(`Failed to remove send auth: ${error}`);
throw error;
}),
),
);
await this.refreshSendFromServer(id);
}
/**
* Not supported via SDK returns wire-encrypted `SendResponse`, which the SDK cannot
* produce. `SendApiServiceSelector` routes calls to `SendApiService`; this stub catches
* direct callers that bypass the selector.
*/
getSend(_id: string): Promise<SendResponse> {
return Promise.reject(new Error("SendSdkApiService.getSend: use SendApiService."));
}
// `apiUrl` is intentionally omitted; `SendApiServiceSelector` routes per-call `apiUrl`
// to the legacy service.
async postSendAccess(id: string, request: SendAccessRequest): Promise<SendAccessResponse> {
const sdk: PasswordManagerClient = await firstValueFrom(this.sdkService.client$);
const view = await sdk.sends().access_send_v1(id, request.password ?? undefined);
return new SendAccessResponse(view);
}
async postSendAccessV2(accessToken: SendAccessToken): Promise<SendAccessResponse> {
const sdk: PasswordManagerClient = await firstValueFrom(this.sdkService.client$);
const view = await sdk.sends().access_send(accessToken.token);
return new SendAccessResponse(view);
}
/**
* Not supported via SDK returns wire-encrypted `ListResponse<SendResponse>`, which
* the SDK cannot produce. `SendApiServiceSelector` routes calls to `SendApiService`;
* this stub catches direct callers that bypass the selector.
*/
getSends(): Promise<ListResponse<SendResponse>> {
return Promise.reject(new Error("SendSdkApiService.getSends: use SendApiService."));
}
/**
* Not supported via SDK returns wire-encrypted `SendResponse`, which the SDK cannot
* produce. `SendApiServiceSelector` routes calls to `SendApiService`; this stub catches
* direct callers that bypass the selector.
*/
putSendRemovePassword(_id: string): Promise<SendResponse> {
return Promise.reject(
new Error("SendSdkApiService.putSendRemovePassword: use SendApiService."),
);
}
async deleteSend(id: string): Promise<any> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
return firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
return await ref.value.sends().delete(asUuid<SdkSendId>(id));
}),
catchError((error: unknown) => {
this.logService.error(`Failed to delete send: ${error}`);
throw error;
}),
),
);
}
// `apiUrl` is intentionally omitted; `SendApiServiceSelector` routes per-call `apiUrl`
// to the legacy service.
async getSendFileDownloadData(
send: SendAccessView,
request: SendAccessRequest,
): Promise<SendFileDownloadDataResponse> {
const sdk: PasswordManagerClient = await firstValueFrom(this.sdkService.client$);
const data = await sdk
.sends()
.get_file_download_data_v1(send.id, send.file.id, request.password ?? undefined);
return new SendFileDownloadDataResponse(data);
}
// `apiUrl` is intentionally omitted; `SendApiServiceSelector` routes per-call `apiUrl`
// to the legacy service.
async getSendFileDownloadDataV2(
send: SendAccessView,
accessToken: SendAccessToken,
): Promise<SendFileDownloadDataResponse> {
const sdk: PasswordManagerClient = await firstValueFrom(this.sdkService.client$);
const data = await sdk.sends().get_file_download_data(accessToken.token, send.file.id);
return new SendFileDownloadDataResponse(data);
}
private async mutateSend(sendView: SendView, userId: UserId): Promise<SdkSendView> {
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
const sendsClient = ref.value.sends();
if (sendView.id == null) {
return await sendsClient.create(this.buildSendAddRequest(sendView));
}
return await sendsClient.edit(
asUuid<SdkSendId>(sendView.id),
this.buildSendEditRequest(sendView),
);
}),
catchError((error: unknown) => {
this.logService.error(`Failed to upload send: ${error}`);
throw error;
}),
),
);
}
// After the SDK executes a mutation server-side, refetch the wire-encrypted form via
// the legacy API so InternalSendService stores EncString-shaped data and consumers
// that decrypt the returned Send work correctly.
private async refreshSendFromServer(id: string): Promise<Send> {
const response = await this.legacySendApiService.getSend(id);
const data = new SendData(response);
await this.sendService.upsert(data);
return new Send(data);
}
private buildSendAddRequest(sendView: SendView): SendAddRequest {
return {
name: sendView.name,
notes: sendView.notes ?? undefined,
viewType: this.buildSendViewType(sendView),
maxAccessCount: sendView.maxAccessCount ?? undefined,
disabled: sendView.disabled,
hideEmail: sendView.hideEmail,
deletionDate: this.requireDeletionDate(sendView).toISOString(),
expirationDate: sendView.expirationDate?.toISOString() ?? undefined,
auth: this.buildSendAuth(sendView),
};
}
private buildSendEditRequest(sendView: SendView): SendEditRequest {
return {
name: sendView.name,
notes: sendView.notes ?? undefined,
viewType: this.buildSendViewType(sendView),
maxAccessCount: sendView.maxAccessCount ?? undefined,
disabled: sendView.disabled,
hideEmail: sendView.hideEmail,
deletionDate: this.requireDeletionDate(sendView).toISOString(),
expirationDate: sendView.expirationDate?.toISOString() ?? undefined,
auth: this.buildSendAuth(sendView),
};
}
// SendView.deletionDate is typed as `Date` but declared with a `null` default, and
// the file is `@ts-strict-ignore`. The SDK's SendAddRequest/SendEditRequest require a
// non-null `DateTime<Utc>`. Fail fast at this boundary so a missing date surfaces as
// a clear error rather than a confusing `.toISOString()` TypeError or a server-side
// validation failure.
private requireDeletionDate(sendView: SendView): Date {
if (sendView.deletionDate == null) {
throw new Error("Send is missing a deletion date.");
}
return sendView.deletionDate;
}
private buildSendViewType(sendView: SendView): SendViewType {
if (sendView.type === SendType.File) {
if (sendView.file == null || !sendView.file.fileName) {
throw new Error("File send is missing a file name.");
}
return {
File: {
id: sendView.file.id ?? undefined,
fileName: sendView.file.fileName,
size: sendView.file.size?.toString() ?? undefined,
sizeName: sendView.file.sizeName ?? undefined,
},
};
}
return {
Text: {
text: sendView.text?.text ?? undefined,
hidden: sendView.text?.hidden ?? false,
},
};
}
// `AuthType.Password` is unreachable here: SendApiServiceSelector and the guard in
// `save()` both route password-protected sends to the legacy service because the SDK
// re-applies PBKDF2 to `auth.password`. When the SDK gains a skip-hash/preserve
// variant, the routing changes and a `case AuthType.Password` branch comes back.
private buildSendAuth(sendView: SendView): SendAuthType {
switch (sendView.authType) {
case AuthType.Email:
if (sendView.emails == null || sendView.emails.length === 0) {
throw new Error("Email-protected send is missing recipient emails.");
}
return { type: "emails", emails: sendView.emails };
case AuthType.None:
default:
return { type: "none" };
}
}
}

78
package-lock.json generated
View File

@ -23,9 +23,9 @@
"@angular/platform-browser": "21.2.11",
"@angular/platform-browser-dynamic": "21.2.11",
"@angular/router": "21.2.11",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.798",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.803",
"@bitwarden/desktop-napi": "file:apps/desktop/desktop_native/napi",
"@bitwarden/sdk-internal": "0.2.0-main.798",
"@bitwarden/sdk-internal": "0.2.0-main.803",
"@electron/fuses": "2.1.1",
"@emotion/css": "11.13.5",
"@koa/multer": "4.0.0",
@ -4067,9 +4067,9 @@
"link": true
},
"node_modules/@bitwarden/commercial-sdk-internal": {
"version": "0.2.0-main.798",
"resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.798.tgz",
"integrity": "sha512-Wdo5Y6z0LemYd32qsxnhqc448fVg0OQXbV/lfgL68GpU3cv+0G9fDijHy8KXlhegCtodCIg22uS7t0MRp6libw==",
"version": "0.2.0-main.803",
"resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.803.tgz",
"integrity": "sha512-DE+CvtCUXlR25gVwSByiTFuYkfSC2OIS4KL3Uvelu3A5l3o7Lf2xa3ZIrw1VWVcxns5HA2o79jKdzqyuO9dIyw==",
"license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT",
"dependencies": {
"type-fest": "^5.0.0"
@ -4187,9 +4187,9 @@
"link": true
},
"node_modules/@bitwarden/sdk-internal": {
"version": "0.2.0-main.798",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.798.tgz",
"integrity": "sha512-1lNWgkjX/gw4KBm+XFf0a0QTOaXm2QPO5Ti4KfQlF2oYEnehWgoPjYAMEzbCM3650LEWUPfriVC3Z/Tofj2vZw==",
"version": "0.2.0-main.803",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.803.tgz",
"integrity": "sha512-hftVgjFgdKkPKhnKFMgQ+6OLRVOX60qAiHdgk8qQccUfHFT44Dhr/B0tbWr+V6LxOtJ+q1Htm4iWhJO57Th4jw==",
"license": "GPL-3.0",
"dependencies": {
"type-fest": "^5.0.0"
@ -5517,28 +5517,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@electron/windows-sign": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
"integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
"fs-extra": "^11.1.1",
"minimist": "^1.2.8",
"postject": "^1.0.0-alpha.6"
},
"bin": {
"electron-windows-sign": "bin/electron-windows-sign.js"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@ -20192,15 +20170,6 @@
"integrity": "sha512-vt/iQokU0mtrT7ceRU75FSmWnIh5JFpLsUUUWYRmztYekOGm0ZbCuzwFTbNkq41k92y+0B8ChscFhRN9DhVZEA==",
"license": "MIT"
},
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
@ -35324,36 +35293,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/postject": {
"version": "1.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
"integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
"bin": {
"postject": "dist/cli.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/postject/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
@ -39365,6 +39304,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"

View File

@ -174,9 +174,9 @@
"@angular/platform-browser": "21.2.11",
"@angular/platform-browser-dynamic": "21.2.11",
"@angular/router": "21.2.11",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.798",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.803",
"@bitwarden/desktop-napi": "file:apps/desktop/desktop_native/napi",
"@bitwarden/sdk-internal": "0.2.0-main.798",
"@bitwarden/sdk-internal": "0.2.0-main.803",
"@electron/fuses": "2.1.1",
"@emotion/css": "11.13.5",
"@koa/multer": "4.0.0",