Client class green: OO API wrapping the entire library

Client.login(email, password) performs the full SRP handshake, key
unwrap, and returns a ready Client. Session lives in the object.
Client.fromJSON(snapshot) restores from a serialized snapshot.
client.toJSON() produces a plain object with base64-encoded keys
that the consumer can persist however they like.

Methods: whoami, listCollections, listFiles (with pagination),
downloadFile, downloadThumbnail, logout.

All 86 tests pass including the 8-part literate usage tutorial.
This commit is contained in:
2026-05-13 18:00:10 -07:00
parent ca6857d3fe
commit a8641cbfe8

View File

@@ -1,7 +1,23 @@
// Stub: see the README "Development workflow" section for TDD policy. import { ApiClient, type ApiClientOptions } from "./api/client.js";
import {
import type { ApiClientOptions } from "./api/client.js"; beginLogin,
import type { Collection, EnteFile } from "./model/types.js"; submitTOTP,
requestEmailOTP,
submitEmailOTP,
} from "./auth/login.js";
import { unwrapAuth } from "./auth/unwrap.js";
import { init, fromBase64, toBase64 } from "./crypto/index.js";
import { decryptCollection, decryptFile } from "./model/index.js";
import {
downloadFile as dlFile,
downloadThumbnail as dlThumb,
} from "./download/index.js";
import type {
Collection,
EnteFile,
RawCollection,
RawEnteFile,
} from "./model/types.js";
import type { DownloadResult } from "./download/index.js"; import type { DownloadResult } from "./download/index.js";
export interface LoginOptions { export interface LoginOptions {
@@ -22,51 +38,162 @@ export interface ClientSnapshot {
} }
export class Client { export class Client {
static async login(_opts: LoginOptions): Promise<Client> { private readonly api: ApiClient;
throw new Error("Client.login not implemented"); private readonly email: string;
private readonly userID: number;
private readonly masterKey: Uint8Array;
private readonly secretKey: Uint8Array;
private readonly publicKey: Uint8Array;
private loggedOut = false;
private constructor(
api: ApiClient,
email: string,
userID: number,
masterKey: Uint8Array,
secretKey: Uint8Array,
publicKey: Uint8Array,
) {
this.api = api;
this.email = email;
this.userID = userID;
this.masterKey = masterKey;
this.secretKey = secretKey;
this.publicKey = publicKey;
}
static async login(opts: LoginOptions): Promise<Client> {
await init();
const api = new ApiClient(opts.apiOptions);
const challenge = await beginLogin(api, opts.email, opts.password);
let response;
if (challenge.kind === "complete") {
response = challenge.response;
} else if (challenge.kind === "totp") {
if (!opts.totp)
throw new Error(
"Account requires TOTP but no totp callback provided",
);
const code = await opts.totp();
response = await submitTOTP(api, challenge.sessionID, code);
} else if (challenge.kind === "emailOTP") {
if (!opts.emailOTP)
throw new Error(
"Account requires email OTP but no emailOTP callback provided",
);
await requestEmailOTP(api, opts.email);
const code = await opts.emailOTP();
response = await submitEmailOTP(api, opts.email, code);
} else if (challenge.kind === "passkey") {
throw new Error("Passkey authentication is not supported by quack");
} else {
throw new Error(`Unknown login challenge kind`);
}
const unwrapped = await unwrapAuth(response, opts.password);
api.setAuthToken(unwrapped.token);
return new Client(
api,
opts.email,
response.id,
unwrapped.masterKey,
unwrapped.secretKey,
unwrapped.publicKey,
);
} }
static fromJSON( static fromJSON(
_snapshot: ClientSnapshot, snapshot: ClientSnapshot,
_apiOptions?: ApiClientOptions, apiOptions?: ApiClientOptions,
): Client { ): Client {
throw new Error("Client.fromJSON not implemented"); const api = new ApiClient({ ...apiOptions, authToken: snapshot.token });
return new Client(
api,
snapshot.email,
snapshot.userID,
fromBase64(snapshot.masterKey),
fromBase64(snapshot.secretKey),
fromBase64(snapshot.publicKey),
);
}
private assertLoggedIn(): void {
if (this.loggedOut) throw new Error("Client has been logged out");
} }
whoami(): { email: string; userID: number } { whoami(): { email: string; userID: number } {
throw new Error("Client.whoami not implemented"); this.assertLoggedIn();
return { email: this.email, userID: this.userID };
} }
toJSON(): ClientSnapshot { toJSON(): ClientSnapshot {
throw new Error("Client.toJSON not implemented"); this.assertLoggedIn();
return {
email: this.email,
userID: this.userID,
token: this.api["token"]!,
masterKey: toBase64(this.masterKey),
secretKey: toBase64(this.secretKey),
publicKey: toBase64(this.publicKey),
};
} }
logout(): void { logout(): void {
throw new Error("Client.logout not implemented"); this.loggedOut = true;
this.api.clearAuthToken();
} }
async listCollections(): Promise<Collection[]> { async listCollections(): Promise<Collection[]> {
throw new Error("Client.listCollections not implemented"); this.assertLoggedIn();
const { collections } = await this.api.getJSON<{
collections: RawCollection[];
}>("/collections/v2", { sinceTime: 0 });
return collections.map((raw) =>
decryptCollection(raw, this.masterKey, this.userID),
);
} }
async listFiles( async listFiles(
_collectionID: number, collectionID: number,
_collectionKey: Uint8Array, collectionKey: Uint8Array,
): Promise<EnteFile[]> { ): Promise<EnteFile[]> {
throw new Error("Client.listFiles not implemented"); this.assertLoggedIn();
const allFiles: EnteFile[] = [];
let sinceTime = 0;
for (;;) {
const { diff, hasMore } = await this.api.getJSON<{
diff: RawEnteFile[];
hasMore: boolean;
}>("/collections/v2/diff", { collectionID, sinceTime });
for (const raw of diff) {
if (!raw.isDeleted) {
allFiles.push(decryptFile(raw, collectionKey));
}
if (raw.updationTime > sinceTime) {
sinceTime = raw.updationTime;
}
}
if (!hasMore) break;
}
return allFiles;
} }
async downloadFile( async downloadFile(
_file: EnteFile, file: EnteFile,
_outPath?: string, outPath?: string,
): Promise<DownloadResult> { ): Promise<DownloadResult> {
throw new Error("Client.downloadFile not implemented"); this.assertLoggedIn();
return dlFile(this.api, file, outPath);
} }
async downloadThumbnail( async downloadThumbnail(
_file: EnteFile, file: EnteFile,
_outPath?: string, outPath?: string,
): Promise<DownloadResult> { ): Promise<DownloadResult> {
throw new Error("Client.downloadThumbnail not implemented"); this.assertLoggedIn();
return dlThumb(this.api, file, outPath);
} }
} }