Merge: Client class (OO API)

Client.login() performs full SRP + key unwrap and returns a ready
object. toJSON/fromJSON for consumer-managed persistence.
listCollections, listFiles (with pagination), downloadFile,
downloadThumbnail, whoami, logout. 8 new tests in a literate
tutorial-as-test format. 86 total tests, all green.
This commit is contained in:
2026-05-13 18:01:21 -07:00
4 changed files with 851 additions and 5 deletions

View File

@@ -649,12 +649,17 @@ Phase 6: download
- [x] Live integration test: logs in, decrypts collections and files, downloads - [x] Live integration test: logs in, decrypts collections and files, downloads
a real JPEG from the dev account and verifies it on disk a real JPEG from the dev account and verifies it on disk
Phase 7: session persistence Phase 7: Client class
- [ ] `SessionStore` writing an encrypted session blob with a key from the OS - [x] `Client.login({ email, password, totp?, emailOTP? })` performs the full
keychain (`keytar`) or a `0600` keyfile fallback SRP handshake, key unwrap, returns a ready Client
- [ ] `Client.fromSavedSession()` and `Client.saveSession()` - [x] `Client.fromJSON(snapshot)` restores from a serialized snapshot
- [ ] `quack logout` deletes the session and the keychain entry - [x] `client.toJSON()` produces a `ClientSnapshot` the consumer can persist
- [x] `client.whoami()`, `client.logout()`
- [x] `client.listCollections()` with decryption
- [x] `client.listFiles(collectionID, collectionKey)` with pagination
- [x] `client.downloadFile(file, outPath?)` and `client.downloadThumbnail()`
- [x] Literate test/client/usage.test.ts tutorial covering the entire API
Phase 8: CLI Phase 8: CLI

199
src/client.ts Normal file
View File

@@ -0,0 +1,199 @@
import { ApiClient, type ApiClientOptions } from "./api/client.js";
import {
beginLogin,
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";
export interface LoginOptions {
email: string;
password: string;
totp?: () => Promise<string>;
emailOTP?: () => Promise<string>;
apiOptions?: ApiClientOptions;
}
export interface ClientSnapshot {
email: string;
userID: number;
token: string;
masterKey: string;
secretKey: string;
publicKey: string;
}
export class Client {
private readonly api: ApiClient;
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(
snapshot: ClientSnapshot,
apiOptions?: ApiClientOptions,
): Client {
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 } {
this.assertLoggedIn();
return { email: this.email, userID: this.userID };
}
toJSON(): ClientSnapshot {
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 {
this.loggedOut = true;
this.api.clearAuthToken();
}
async listCollections(): Promise<Collection[]> {
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(
collectionID: number,
collectionKey: Uint8Array,
): Promise<EnteFile[]> {
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(
file: EnteFile,
outPath?: string,
): Promise<DownloadResult> {
this.assertLoggedIn();
return dlFile(this.api, file, outPath);
}
async downloadThumbnail(
file: EnteFile,
outPath?: string,
): Promise<DownloadResult> {
this.assertLoggedIn();
return dlThumb(this.api, file, outPath);
}
}

View File

@@ -1,9 +1,31 @@
export const VERSION = "0.0.0"; export const VERSION = "0.0.0";
export { Client, type LoginOptions, type ClientSnapshot } from "./client.js";
export { ApiClient, ApiError, type ApiClientOptions } from "./api/client.js";
export { unwrapAuth, type UnwrapResult } from "./auth/unwrap.js"; export { unwrapAuth, type UnwrapResult } from "./auth/unwrap.js";
export {
beginLogin,
submitTOTP,
requestEmailOTP,
submitEmailOTP,
} from "./auth/login.js";
export { decryptCollection, decryptFile } from "./model/index.js";
export { downloadFile, downloadThumbnail } from "./download/index.js";
export type { export type {
AuthorizationResponse, AuthorizationResponse,
KeyAttributes, KeyAttributes,
LoginChallenge, LoginChallenge,
SRPAttributes, SRPAttributes,
} from "./auth/types.js"; } from "./auth/types.js";
export type {
Collection,
CollectionType,
EnteFile,
FileBlob,
FileMetadata,
FileType,
Microseconds,
RawCollection,
RawEnteFile,
} from "./model/types.js";
export type { DownloadResult } from "./download/index.js";

620
test/client/usage.test.ts Normal file
View File

@@ -0,0 +1,620 @@
/**
* # Using the quack Client
*
* This test file is a tutorial. It walks through every operation the
* library supports, in the order you would use them in a real program.
* Each `it()` block is a self-contained example with commentary
* explaining what is happening and why. If you are reading the quack
* source for the first time, start here.
*
* The tests run against a mock Ente server built from the same SRP and
* crypto primitives the real server uses, so the data flowing through
* the Client is structurally identical to production data. Nothing hits
* the network.
*
*
* ## Table of contents
*
* 1. Logging in
* 2. Inspecting account info
* 3. Listing collections (albums)
* 4. Listing files in a collection
* 5. Downloading a file
* 6. Downloading a thumbnail
* 7. Serializing and restoring a session
* 8. Logging out
*
*
* ## Prerequisites
*
* All you need is the `Client` class:
*
* ```ts
* import { Client } from "quack";
* ```
*
* The Client wraps every lower-level module (crypto, auth, api, model,
* download) into a single object. You create one by logging in, and
* then call methods on it for the lifetime of your program. The session
* (auth token, master key, secret key) lives in memory on the Client
* instance. If you want to persist it across process restarts, call
* `client.toJSON()` to get a serializable snapshot, store it however
* you like, and later restore it with `Client.fromJSON(snapshot)`.
*/
import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo";
import { SRP, SrpServer } from "fast-srp-hap";
import { beforeAll, afterAll, describe, expect, it } from "vitest";
import {
init,
toBase64,
deriveKEK,
deriveLoginSubkey,
} from "../../src/crypto/index.js";
import { Client } from "../../src/client.js";
import type { KeyAttributes } from "../../src/auth/types.js";
// ---------------------------------------------------------------------------
// Mock server
// ---------------------------------------------------------------------------
//
// Everything below this line builds a fake Ente backend that the Client
// talks to via an injected `fetch`. You would not write any of this in a
// real program; it exists only so the tests are self-contained.
const TEST_EMAIL = "user@example.com";
const TEST_PASSWORD = "hunter2";
const TEST_OPS = 2;
const TEST_MEM = 64 * 1024 * 1024;
interface ServerState {
srpAttributes: {
srpUserID: string;
srpSalt: string;
memLimit: number;
opsLimit: number;
kekSalt: string;
isEmailMFAEnabled: boolean;
};
verifier: Buffer;
keyAttributes: KeyAttributes;
encryptedToken: string;
masterKey: Uint8Array;
photoPlaintext: Uint8Array;
photoKey: Uint8Array;
photoHeader: Uint8Array;
photoCiphertext: Uint8Array;
thumbPlaintext: Uint8Array;
thumbHeader: Uint8Array;
thumbCiphertext: Uint8Array;
collectionKey: Uint8Array;
}
let server: ServerState;
let testDir: string;
const buildServer = async (): Promise<ServerState> => {
const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM);
const loginSubKeyBytes = deriveLoginSubkey(kek);
const srpUserID = "mock-srp-user";
const srpSalt = sodium.randombytes_buf(16);
const verifier = SRP.computeVerifier(
SRP.params["4096"],
Buffer.from(srpSalt),
Buffer.from(srpUserID),
Buffer.from(loginSubKeyBytes),
);
// Master key, keypair, token
const masterKey = sodium.randombytes_buf(32);
const keyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encryptedKey = sodium.crypto_secretbox_easy(masterKey, keyNonce, kek);
const kp = sodium.crypto_box_keypair();
const skNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encryptedSecretKey = sodium.crypto_secretbox_easy(
kp.privateKey,
skNonce,
masterKey,
);
const tokenBytes = sodium.randombytes_buf(32);
const encryptedToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey);
const keyAttributes: KeyAttributes = {
kekSalt: toBase64(kekSalt),
encryptedKey: toBase64(encryptedKey),
keyDecryptionNonce: toBase64(keyNonce),
publicKey: toBase64(kp.publicKey),
encryptedSecretKey: toBase64(encryptedSecretKey),
secretKeyDecryptionNonce: toBase64(skNonce),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
};
// A collection with one photo
const collectionKey = sodium.crypto_secretbox_keygen();
const ckNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encCollKey = sodium.crypto_secretbox_easy(
collectionKey,
ckNonce,
masterKey,
);
const collName = new TextEncoder().encode("Vacation");
const cnNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encCollName = sodium.crypto_secretbox_easy(
collName,
cnNonce,
collectionKey,
);
// A file inside that collection
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const fkNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encFileKey = sodium.crypto_secretbox_easy(
photoKey,
fkNonce,
collectionKey,
);
const metadata = JSON.stringify({
title: "beach.jpg",
fileType: 0,
creationTime: 1700000000000000,
modificationTime: 1700000000000000,
latitude: 35.6762,
longitude: 139.6503,
});
const metaPush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(photoKey);
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
metaPush.state,
new TextEncoder().encode(metadata),
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
// Encrypted file body (a small fake JPEG)
const photoPlaintext = sodium.randombytes_buf(5000);
const filePush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(photoKey);
const photoCiphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
filePush.state,
photoPlaintext,
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
// Encrypted thumbnail
const thumbPlaintext = sodium.randombytes_buf(800);
const thumbPush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(photoKey);
const thumbCiphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
thumbPush.state,
thumbPlaintext,
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
// Wire up the mock fetch responses
const rawCollection = {
id: 1,
owner: { id: 42 },
encryptedKey: toBase64(encCollKey),
keyDecryptionNonce: toBase64(ckNonce),
encryptedName: toBase64(encCollName),
nameDecryptionNonce: toBase64(cnNonce),
type: "album",
updationTime: 1700000000000000,
};
const rawFile = {
id: 100,
collectionID: 1,
ownerID: 42,
encryptedKey: toBase64(encFileKey),
keyDecryptionNonce: toBase64(fkNonce),
metadata: {
encryptedData: toBase64(encMeta),
decryptionHeader: toBase64(metaPush.header),
},
file: { decryptionHeader: toBase64(filePush.header) },
thumbnail: { decryptionHeader: toBase64(thumbPush.header) },
updationTime: 1700000000000000,
};
// Store the raw JSON responses on the server state so the mock fetch
// can return them. This is ugly plumbing; in a real program you
// never see any of it.
(globalThis as Record<string, unknown>).__mockRawCollection = rawCollection;
(globalThis as Record<string, unknown>).__mockRawFile = rawFile;
return {
srpAttributes: {
srpUserID,
srpSalt: toBase64(srpSalt),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
kekSalt: toBase64(kekSalt),
isEmailMFAEnabled: false,
},
verifier,
keyAttributes,
encryptedToken: toBase64(encryptedToken),
masterKey,
photoPlaintext,
photoKey,
photoHeader: filePush.header,
photoCiphertext,
thumbPlaintext,
thumbHeader: thumbPush.header,
thumbCiphertext,
collectionKey,
};
};
const buildMockFetch = (s: ServerState) => {
let srpServer: SrpServer;
return (async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const path = new URL(url).pathname;
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
if (path === "/users/srp/attributes") {
return json({ attributes: s.srpAttributes });
}
if (path === "/users/srp/create-session") {
const body = JSON.parse(init?.body as string);
const serverKey = await SRP.genKey();
srpServer = new SrpServer(
SRP.params["4096"],
s.verifier,
serverKey,
);
const B = srpServer.computeB();
srpServer.setA(Buffer.from(body.srpA, "base64"));
return json({ sessionID: "s1", srpB: B.toString("base64") });
}
if (path === "/users/srp/verify-session") {
const body = JSON.parse(init?.body as string);
srpServer.checkM1(Buffer.from(body.srpM1, "base64"));
const M2 = srpServer.computeM2();
return json({
srpM2: M2.toString("base64"),
id: 42,
keyAttributes: s.keyAttributes,
encryptedToken: s.encryptedToken,
});
}
if (path === "/collections/v2") {
const raw = (globalThis as Record<string, unknown>)
.__mockRawCollection;
return json({ collections: [raw] });
}
if (path === "/collections/v2/diff") {
const raw = (globalThis as Record<string, unknown>).__mockRawFile;
return json({ diff: [raw], hasMore: false });
}
if (url.includes("files.ente.io") || path === "/files/download/100") {
return new Response(s.photoCiphertext, { status: 200 });
}
if (
url.includes("thumbnails.ente.io") ||
path === "/files/preview/100"
) {
return new Response(s.thumbCiphertext, { status: 200 });
}
return new Response("not found", { status: 404 });
}) as typeof globalThis.fetch;
};
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeAll(async () => {
await init();
await sodium.ready;
server = await buildServer();
testDir = mkdtempSync(join(tmpdir(), "quack-usage-test-"));
});
afterAll(() => {
if (testDir && existsSync(testDir))
rmSync(testDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// The tutorial
// ---------------------------------------------------------------------------
describe("quack Client usage guide", () => {
/**
* ## 1. Logging in
*
* Create a Client by calling `Client.login()`. This performs the
* entire authentication handshake (SRP key exchange, key derivation,
* key unwrapping) and returns a ready-to-use Client. The password
* never leaves the process.
*
* If the account requires a second factor, `login()` accepts
* optional callbacks for TOTP and email OTP. If neither is provided
* and the server demands 2FA, `login()` throws.
*
* ```ts
* const client = await Client.login({
* email: "you@example.com",
* password: "your-password",
* // Optional: supply these if the account has 2FA enabled.
* // totp: async () => prompt("Enter TOTP code: "),
* // emailOTP: async () => prompt("Enter email code: "),
* });
* ```
*/
it("1. log in with email and password", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
// The client is now authenticated. All subsequent calls use the
// auth token internally.
expect(client).toBeInstanceOf(Client);
});
/**
* ## 2. Inspecting account info
*
* After login, the client knows the user's email and numeric ID.
* The master key and secret key are held in memory but not directly
* exposed; they are used internally by collection/file decryption.
*
* ```ts
* const { email, userID } = client.whoami();
* console.log(`Logged in as ${email} (id ${userID})`);
* ```
*/
it("2. inspect account info with whoami()", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
const info = client.whoami();
expect(info.email).toBe(TEST_EMAIL);
expect(info.userID).toBe(42);
});
/**
* ## 3. Listing collections
*
* Collections are Ente's name for albums. Each collection has a
* name, a type (album, folder, favorites, uncategorized), and an
* encryption key. `listCollections()` fetches them from the server,
* decrypts the keys and names, and returns typed objects.
*
* ```ts
* const collections = await client.listCollections();
* for (const c of collections) {
* console.log(`${c.name} [${c.type}] — ${c.id}`);
* }
* ```
*/
it("3. list and decrypt collections", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
const collections = await client.listCollections();
expect(collections.length).toBe(1);
expect(collections[0]!.name).toBe("Vacation");
expect(collections[0]!.type).toBe("album");
expect(collections[0]!.id).toBe(1);
// The decrypted collection key is available for advanced use.
expect(collections[0]!.key.length).toBe(32);
});
/**
* ## 4. Listing files in a collection
*
* Each collection contains files (photos, videos, live photos).
* `listFiles()` fetches the file list, decrypts each file's key
* and metadata (title, type, creation time, GPS coordinates), and
* returns typed objects.
*
* ```ts
* const files = await client.listFiles(collection.id, collection.key);
* for (const f of files) {
* console.log(`${f.metadata.title} [${f.metadata.fileType}]`);
* if (f.metadata.latitude) {
* console.log(` at ${f.metadata.latitude}, ${f.metadata.longitude}`);
* }
* }
* ```
*/
it("4. list and decrypt files", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
const collections = await client.listCollections();
const col = collections[0]!;
const files = await client.listFiles(col.id, col.key);
expect(files.length).toBe(1);
expect(files[0]!.metadata.title).toBe("beach.jpg");
expect(files[0]!.metadata.fileType).toBe("image");
expect(files[0]!.metadata.latitude).toBeCloseTo(35.6762);
expect(files[0]!.metadata.longitude).toBeCloseTo(139.6503);
});
/**
* ## 5. Downloading a file
*
* `downloadFile()` fetches the encrypted file body from the CDN,
* decrypts it chunk by chunk using the file's secretstream key and
* header, and writes the plaintext to disk.
*
* If you omit `outPath`, the file is written to the current
* directory using the title from the decrypted metadata.
*
* ```ts
* const result = await client.downloadFile(file, "/tmp/beach.jpg");
* console.log(`Wrote ${result.bytesWritten} bytes to ${result.path}`);
* ```
*/
it("5. download and decrypt a file to disk", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
const collections = await client.listCollections();
const files = await client.listFiles(
collections[0]!.id,
collections[0]!.key,
);
const file = files[0]!;
const outPath = join(testDir, "beach.jpg");
const result = await client.downloadFile(file, outPath);
expect(result.path).toBe(outPath);
expect(result.bytesWritten).toBe(server.photoPlaintext.length);
expect(readFileSync(outPath)).toEqual(
Buffer.from(server.photoPlaintext),
);
});
/**
* ## 6. Downloading a thumbnail
*
* Thumbnails are encrypted the same way as full files, just served
* from a different CDN endpoint. The API is identical.
*
* ```ts
* const result = await client.downloadThumbnail(file, "/tmp/thumb.jpg");
* ```
*/
it("6. download and decrypt a thumbnail", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
const collections = await client.listCollections();
const files = await client.listFiles(
collections[0]!.id,
collections[0]!.key,
);
const file = files[0]!;
const outPath = join(testDir, "thumb.jpg");
const result = await client.downloadThumbnail(file, outPath);
expect(result.bytesWritten).toBe(server.thumbPlaintext.length);
expect(readFileSync(outPath)).toEqual(
Buffer.from(server.thumbPlaintext),
);
});
/**
* ## 7. Serializing and restoring a session
*
* The Client holds the auth token and key material in memory. If
* you need to persist a session across process restarts (for a CLI
* that doesn't re-prompt for a password every time, or a long-lived
* daemon), call `toJSON()` to get a plain object you can
* JSON.stringify and store however you like. Later, pass it to
* `Client.fromJSON()` to get back a working Client without logging
* in again.
*
* The serialized form contains the master key and secret key. Treat
* it as you would treat the password itself. Encrypt it at rest if
* you store it on disk.
*
* ```ts
* // Save
* const snapshot = client.toJSON();
* fs.writeFileSync("session.json", JSON.stringify(snapshot));
*
* // Restore (in a later process)
* const data = JSON.parse(fs.readFileSync("session.json", "utf-8"));
* const restored = Client.fromJSON(data);
* const collections = await restored.listCollections(); // works
* ```
*/
it("7. serialize with toJSON() and restore with fromJSON()", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
// Serialize to a plain object. This is what you would persist.
const snapshot = client.toJSON();
expect(snapshot.email).toBe(TEST_EMAIL);
expect(snapshot.userID).toBe(42);
expect(typeof snapshot.token).toBe("string");
expect(typeof snapshot.masterKey).toBe("string"); // base64
// Restore from the snapshot. The new client must be able to do
// everything the original could, without logging in again.
const restored = Client.fromJSON(snapshot, {
fetch: buildMockFetch(server),
});
expect(restored.whoami().email).toBe(TEST_EMAIL);
const collections = await restored.listCollections();
expect(collections[0]!.name).toBe("Vacation");
});
/**
* ## 8. Logging out
*
* `logout()` clears the in-memory session. After this call, every
* method that requires authentication will throw. The Client object
* is no longer usable.
*
* ```ts
* client.logout();
* // client.listCollections() would now throw
* ```
*/
it("8. logout clears the session", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
client.logout();
expect(() => client.whoami()).toThrow();
await expect(client.listCollections()).rejects.toThrow();
});
});