quak logout ends the session on the server (closes #108)
check / check (push) Successful in 47s
check / check (push) Successful in 47s
logout now calls POST /users/logout with the saved token through the new Client.logoutOnServer(), then deletes session.json even when that call fails; in that case it says the server session could not be ended and exits 1. It prints the account's cache directory and says it still holds decrypted data. The default cache path moves into defaultCacheDirectory(), shared with Library.open, so both name the same directory. Model: opus-5-5
This commit is contained in:
@@ -318,6 +318,7 @@ Endpoints used:
|
|||||||
encrypted token plus key attributes.
|
encrypted token plus key attributes.
|
||||||
- `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path.
|
- `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path.
|
||||||
- `POST /users/two-factor/verify`: TOTP second factor.
|
- `POST /users/two-factor/verify`: TOTP second factor.
|
||||||
|
- `POST /users/logout`: end the calling token's session (`quak logout`).
|
||||||
- `GET /collections/v2?sinceTime=<usec>`: list collections changed since
|
- `GET /collections/v2?sinceTime=<usec>`: list collections changed since
|
||||||
microsecond timestamp; pass 0 for a full enumeration.
|
microsecond timestamp; pass 0 for a full enumeration.
|
||||||
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a
|
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a
|
||||||
@@ -432,7 +433,9 @@ whatever else fits their use case. `Client.fromJSON(snapshot)` restores a
|
|||||||
working client from that snapshot without re-authenticating; it checks every
|
working client from that snapshot without re-authenticating; it checks every
|
||||||
field and each key's length first, and throws an error naming the bad field.
|
field and each key's length first, and throws an error naming the bad field.
|
||||||
`client.logout()` clears the token and zeroes the key buffers in place; every
|
`client.logout()` clears the token and zeroes the key buffers in place; every
|
||||||
later call on that client throws.
|
later call on that client throws. It does not contact the server, so the token
|
||||||
|
stays valid there and in any saved snapshot; `await client.logoutOnServer()`
|
||||||
|
first ends the session on the server (`POST /users/logout`).
|
||||||
|
|
||||||
The CLI stores the snapshot at the platform-appropriate data directory via
|
The CLI stores the snapshot at the platform-appropriate data directory via
|
||||||
`env-paths`: `~/Library/Application Support/quak/session.json` on macOS,
|
`env-paths`: `~/Library/Application Support/quak/session.json` on macOS,
|
||||||
@@ -442,13 +445,21 @@ you would treat the password itself. A missing file is reported as "not logged
|
|||||||
in"; a file that exists but is corrupt is reported as such, naming the bad
|
in"; a file that exists but is corrupt is reported as such, naming the bad
|
||||||
field. Both exit with status 1.
|
field. Both exit with status 1.
|
||||||
|
|
||||||
|
`quak logout` ends the session on the server, so the token in `session.json`
|
||||||
|
stops working even in a copy of the file, and then deletes the file. If the
|
||||||
|
server call fails (or the file is corrupt), the file is still deleted, the
|
||||||
|
command says the server session could not be ended, and it exits with status 1.
|
||||||
|
It does not delete the cache: it prints the account's cache directory and says
|
||||||
|
it still holds decrypted data (file keys in `metadata.json`, cached originals
|
||||||
|
and thumbnails), for the user to delete if they want it gone.
|
||||||
|
|
||||||
### CLI surface
|
### CLI surface
|
||||||
|
|
||||||
```
|
```
|
||||||
quak [--cache-dir <path>] <command> global: local metadata/content cache location
|
quak [--cache-dir <path>] <command> global: local metadata/content cache location
|
||||||
quak login interactive or QUAK_EMAIL/QUAK_PASSWORD
|
quak login interactive or QUAK_EMAIL/QUAK_PASSWORD
|
||||||
quak whoami print logged-in account as JSON
|
quak whoami print logged-in account as JSON
|
||||||
quak logout delete saved session
|
quak logout end the session, delete it
|
||||||
quak collections [--json] list all collections
|
quak collections [--json] list all collections
|
||||||
quak files --collection <id> [--json] list files in a collection
|
quak files --collection <id> [--json] list files in a collection
|
||||||
quak get <fileID> [--out path] [--collection] download and decrypt a file
|
quak get <fileID> [--out path] [--collection] download and decrypt a file
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ Tag v1.0.0.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-23: `quak logout` ends the session on the server (issue 108). It calls
|
||||||
|
`POST /users/logout` through the new `Client.logoutOnServer()`, then deletes
|
||||||
|
`session.json` even when that call fails, says so and exits 1. It prints the
|
||||||
|
account's cache directory and says it still holds decrypted data. The default
|
||||||
|
cache path is now `defaultCacheDirectory()` in the library, shared with
|
||||||
|
`Library.open`.
|
||||||
|
|
||||||
- 2026-09-23: Kept one account's cache from mixing with another's (issue 104).
|
- 2026-09-23: Kept one account's cache from mixing with another's (issue 104).
|
||||||
When `metadata.json` in the cache directory was written for a different,
|
When `metadata.json` in the cache directory was written for a different,
|
||||||
non-zero user ID than the client's, `Library.open` deletes it and `mldata/`
|
non-zero user ID than the client's, `Library.open` deletes it and `mldata/`
|
||||||
and starts empty, so the first refresh enumerates from 0. This only happens
|
and starts empty, so the first refresh enumerates from 0. This only happens
|
||||||
with `--cache-dir` or an explicit `cacheDirectory`; the default path already
|
with `--cache-dir` or an explicit `cacheDirectory`; the default path already
|
||||||
includes the user ID. A test opens one account's cache as another account.
|
includes the user ID. A test opens one account's cache as another account.
|
||||||
|
|
||||||
- 2026-09-23: `backup-metadata` no longer stops on one failed ML data request
|
- 2026-09-23: `backup-metadata` no longer stops on one failed ML data request
|
||||||
(issue 101). Each request of up to 200 files is tried on its own; a failed one
|
(issue 101). Each request of up to 200 files is tried on its own; a failed one
|
||||||
is logged, its files are written with the reason in `mlDataError`, and the
|
is logged, its files are written with the reason in `mlDataError`, and the
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ program
|
|||||||
|
|
||||||
program
|
program
|
||||||
.command("logout")
|
.command("logout")
|
||||||
.description("Delete the saved session")
|
.description("End the session on the server and delete the saved session")
|
||||||
.action(() => run(logoutCommand(context())));
|
.action(() => run(logoutCommand(context())));
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|||||||
+39
-6
@@ -17,7 +17,11 @@ import {
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { Client, type ClientSnapshot } from "./client.js";
|
import { Client, type ClientSnapshot } from "./client.js";
|
||||||
import { init } from "./crypto/index.js";
|
import { init } from "./crypto/index.js";
|
||||||
import { Library, type LibraryClient } from "./library/index.js";
|
import {
|
||||||
|
defaultCacheDirectory,
|
||||||
|
Library,
|
||||||
|
type LibraryClient,
|
||||||
|
} from "./library/index.js";
|
||||||
import {
|
import {
|
||||||
fileListRow,
|
fileListRow,
|
||||||
fileListLine,
|
fileListLine,
|
||||||
@@ -146,14 +150,43 @@ export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ends the session on the server, then deletes the session file even when that
|
||||||
|
// failed, and exits 1 if it did. The cache is left in place; the user is told
|
||||||
|
// where it is.
|
||||||
export const logoutCommand = async (ctx: CliContext): Promise<number> => {
|
export const logoutCommand = async (ctx: CliContext): Promise<number> => {
|
||||||
if (existsSync(sessionPath(ctx))) {
|
const path = sessionPath(ctx);
|
||||||
unlinkSync(sessionPath(ctx));
|
if (!existsSync(path)) {
|
||||||
ctx.stderr.write("Session deleted.\n");
|
|
||||||
} else {
|
|
||||||
ctx.stderr.write("No session found.\n");
|
ctx.stderr.write("No session found.\n");
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
return 0;
|
await init();
|
||||||
|
let cacheDir = ctx.cacheDir;
|
||||||
|
let failure: string | undefined;
|
||||||
|
try {
|
||||||
|
const client = ctx.loadSession(path);
|
||||||
|
if (client) {
|
||||||
|
cacheDir ??= defaultCacheDirectory(client.whoami().userID);
|
||||||
|
await client.logoutOnServer();
|
||||||
|
client.logout();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
failure = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
unlinkSync(path);
|
||||||
|
if (failure === undefined) {
|
||||||
|
ctx.stderr.write("Session ended on the server.\n");
|
||||||
|
} else {
|
||||||
|
ctx.stderr.write(
|
||||||
|
`Could not end the session on the server: ${failure}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ctx.stderr.write("Session deleted.\n");
|
||||||
|
if (cacheDir !== undefined) {
|
||||||
|
ctx.stderr.write(
|
||||||
|
`Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return failure === undefined ? 0 : 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const collectionsCommand = async (
|
export const collectionsCommand = async (
|
||||||
|
|||||||
@@ -217,6 +217,14 @@ export class Client {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ends this client's session on the server (`POST /users/logout`), so the
|
||||||
|
// token stops working everywhere, including in any saved copy of it. This
|
||||||
|
// client is left as it was; call `logout()` to clear it.
|
||||||
|
async logoutOnServer(): Promise<void> {
|
||||||
|
this.assertLoggedIn();
|
||||||
|
await this.api.postJSON("/users/logout", {});
|
||||||
|
}
|
||||||
|
|
||||||
// Zeroes the key buffers in place, so any copy of the reference held
|
// Zeroes the key buffers in place, so any copy of the reference held
|
||||||
// elsewhere is wiped too. Every method checks `assertLoggedIn` before
|
// elsewhere is wiped too. Every method checks `assertLoggedIn` before
|
||||||
// touching the keys, so nothing decrypts with the zeroed keys.
|
// touching the keys, so nothing decrypts with the zeroed keys.
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ export {
|
|||||||
|
|
||||||
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
|
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
|
||||||
|
|
||||||
|
// The account's cache directory when `cacheDirectory` is not given: the
|
||||||
|
// env-paths cache directory plus the user id, so each account has its own.
|
||||||
|
export const defaultCacheDirectory = (userID: number): string =>
|
||||||
|
join(envPaths("quak", { suffix: "" }).cache, String(userID));
|
||||||
|
|
||||||
// Project a metadata store into by-id records, filling each record's cache
|
// Project a metadata store into by-id records, filling each record's cache
|
||||||
// paths from the content cache when one is given. Shared by the live read
|
// paths from the content cache when one is given. Shared by the live read
|
||||||
// projection and the precache's initial seeding at open().
|
// projection and the precache's initial seeding at open().
|
||||||
@@ -344,8 +349,7 @@ export class Library {
|
|||||||
static async open(opts: LibraryOptions): Promise<Library> {
|
static async open(opts: LibraryOptions): Promise<Library> {
|
||||||
const { userID } = opts.client.whoami();
|
const { userID } = opts.client.whoami();
|
||||||
const cacheDirectory =
|
const cacheDirectory =
|
||||||
opts.cacheDirectory ??
|
opts.cacheDirectory ?? defaultCacheDirectory(userID);
|
||||||
join(envPaths("quak", { suffix: "" }).cache, String(userID));
|
|
||||||
const metadataPath = join(cacheDirectory, "metadata.json");
|
const metadataPath = join(cacheDirectory, "metadata.json");
|
||||||
let store = await MetadataStore.load(metadataPath);
|
let store = await MetadataStore.load(metadataPath);
|
||||||
// A cache directory given explicitly can hold another account's cache.
|
// A cache directory given explicitly can hold another account's cache.
|
||||||
|
|||||||
+81
-14
@@ -38,7 +38,8 @@ import { loadSession } from "../../src/cli-session.js";
|
|||||||
import type { Client, ClientSnapshot } from "../../src/client.js";
|
import type { Client, ClientSnapshot } from "../../src/client.js";
|
||||||
import type { ContentSource } from "../../src/library/content.js";
|
import type { ContentSource } from "../../src/library/content.js";
|
||||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||||
import { init } from "../../src/crypto/index.js";
|
import { init, toBase64 } from "../../src/crypto/index.js";
|
||||||
|
import { defaultCacheDirectory } from "../../src/library/index.js";
|
||||||
|
|
||||||
const USER_ID = 42;
|
const USER_ID = 42;
|
||||||
|
|
||||||
@@ -176,19 +177,6 @@ describe("session file", () => {
|
|||||||
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
|
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is removed by logout", async () => {
|
|
||||||
const ctx = context();
|
|
||||||
saveSession(ctx.sessionDir, snapshot);
|
|
||||||
expect(await logoutCommand(ctx)).toBe(0);
|
|
||||||
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
|
|
||||||
expect(stderr.text).toBe("Session deleted.\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logout without a session says so and exits 0", async () => {
|
|
||||||
expect(await logoutCommand(context())).toBe(0);
|
|
||||||
expect(stderr.text).toBe("No session found.\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a missing session exits 1 with 'Not logged in'", async () => {
|
it("a missing session exits 1 with 'Not logged in'", async () => {
|
||||||
const ctx = { ...context(), loadSession };
|
const ctx = { ...context(), loadSession };
|
||||||
expect(await whoamiCommand(ctx)).toBe(1);
|
expect(await whoamiCommand(ctx)).toBe(1);
|
||||||
@@ -211,6 +199,85 @@ describe("session file", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// These use a real client read from the session file, over a fake API that
|
||||||
|
// records each request and answers with `status`.
|
||||||
|
describe("logout", () => {
|
||||||
|
const snapshot: ClientSnapshot = {
|
||||||
|
email: "cli@example.com",
|
||||||
|
userID: USER_ID,
|
||||||
|
token: "saved-token",
|
||||||
|
masterKey: toBase64(new Uint8Array(32)),
|
||||||
|
secretKey: toBase64(new Uint8Array(32)),
|
||||||
|
publicKey: toBase64(new Uint8Array(32)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const requests: Request[] = [];
|
||||||
|
|
||||||
|
const logoutContext = (status: number): CliContext => ({
|
||||||
|
...context(),
|
||||||
|
loadSession: (path) =>
|
||||||
|
loadSession(path, {
|
||||||
|
fetch: async (url, init) => {
|
||||||
|
requests.push(new Request(url, init));
|
||||||
|
return new Response(JSON.stringify({}), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
requests.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends the session on the server, then deletes the file", async () => {
|
||||||
|
const ctx = logoutContext(200);
|
||||||
|
saveSession(ctx.sessionDir, snapshot);
|
||||||
|
expect(await logoutCommand(ctx)).toBe(0);
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0]!.method).toBe("POST");
|
||||||
|
expect(new URL(requests[0]!.url).pathname).toBe("/users/logout");
|
||||||
|
expect(requests[0]!.headers.get("X-Auth-Token")).toBe("saved-token");
|
||||||
|
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
|
||||||
|
expect(stderr.text).toBe(
|
||||||
|
"Session ended on the server.\n" +
|
||||||
|
"Session deleted.\n" +
|
||||||
|
`Cache directory ${ctx.cacheDir} still holds decrypted data; delete it to remove that data.\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still deletes the file when the server call fails, and says so", async () => {
|
||||||
|
const ctx = logoutContext(500);
|
||||||
|
saveSession(ctx.sessionDir, snapshot);
|
||||||
|
expect(await logoutCommand(ctx)).toBe(1);
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
|
||||||
|
expect(stderr.text).toBe(
|
||||||
|
"Could not end the session on the server: HTTP 500\n" +
|
||||||
|
"Session deleted.\n" +
|
||||||
|
`Cache directory ${ctx.cacheDir} still holds decrypted data; delete it to remove that data.\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the account's default cache directory without --cache-dir", async () => {
|
||||||
|
const ctx = { ...logoutContext(200), cacheDir: undefined };
|
||||||
|
saveSession(ctx.sessionDir, snapshot);
|
||||||
|
expect(await logoutCommand(ctx)).toBe(0);
|
||||||
|
expect(stderr.text).toContain(
|
||||||
|
`Cache directory ${defaultCacheDirectory(USER_ID)} still holds decrypted data`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without a session says so, calls nothing and exits 0", async () => {
|
||||||
|
expect(await logoutCommand(logoutContext(200))).toBe(0);
|
||||||
|
expect(requests).toHaveLength(0);
|
||||||
|
expect(stderr.text).toBe("No session found.\n");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("whoami", () => {
|
describe("whoami", () => {
|
||||||
it("prints the account as one line of JSON", async () => {
|
it("prints the account as one line of JSON", async () => {
|
||||||
expect(await whoamiCommand(context())).toBe(0);
|
expect(await whoamiCommand(context())).toBe(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user