Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions packages/llmux/src/v2/auth/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,21 @@ function isExpired(token: IdentityToken, now: Date = new Date()): boolean {
/**
* File-backed token store. Reads/writes config.dataDir/tokens.json.
*
* Concurrency model matches FileUserStore — single-process daemon, in-
* process write mutex, in-memory cache.
* Concurrency model matches FileUserStore — in-process write mutex, no
* in-memory cache. load() re-reads the file on every call: the daemon is
* NOT the only process that touches tokens.json — `llmux token create` /
* `revoke` run as separate short-lived CLI processes against the same
* file while a daemon may already be running. An in-memory cache here
* meant a revoke issued via the CLI silently didn't take effect against
* an already-running daemon until it was restarted (confirmed live while
* verifying fix/loopback-auth-bypass). The file is a handful of tokens —
* re-parsing it on every call is cheap next to the rest of a request.
*
* lastUsedAt persistence: updated on every successful validate. At the
* daemon's expected request rate this is fine; if it ever becomes a
* hotspot we can debounce writes to once-per-N-seconds-per-token.
*/
export class FileTokenStore implements TokenStore {
private cache: IdentityToken[] | undefined;
private writeQueue: Promise<void> = Promise.resolve();

constructor(private storePath: string) {}
Expand Down Expand Up @@ -247,11 +253,7 @@ export class FileTokenStore implements TokenStore {
// ── private ─────────────────────────────────────────────────────────────

private load(): IdentityToken[] {
if (this.cache) return this.cache;
if (!existsSync(this.storePath)) {
this.cache = [];
return this.cache;
}
if (!existsSync(this.storePath)) return [];
const raw = readFileSync(this.storePath, 'utf-8');
let parsed: unknown;
try {
Expand All @@ -262,8 +264,7 @@ export class FileTokenStore implements TokenStore {
if (!Array.isArray(parsed)) {
throw new Error(`tokens.json: expected array at ${this.storePath}`);
}
this.cache = parsed as IdentityToken[];
return this.cache;
return parsed as IdentityToken[];
}

private save(tokens: IdentityToken[]): void {
Expand All @@ -272,7 +273,6 @@ export class FileTokenStore implements TokenStore {
writeFileSync(tmp, JSON.stringify(tokens, null, 2) + '\n', { mode: 0o600 });
renameSync(tmp, this.storePath);
try { chmodSync(this.storePath, 0o600); } catch { /* best-effort */ }
this.cache = tokens;
}

private withLock<T>(fn: () => Promise<T>): Promise<T> {
Expand Down
23 changes: 12 additions & 11 deletions packages/llmux/src/v2/auth/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,18 @@ function validatePassphrase(passphrase: string): void {
* File-backed user store. Reads/writes /var/lib/llmux/users.json (or
* config.dataDir/users.json).
*
* Concurrency model: in-process mutex on writes (the daemon is single-process).
* Reads load + cache; writes invalidate the cache.
* Concurrency model: in-process mutex on writes, no in-memory cache.
* load() re-reads the file on every call: the daemon is NOT the only
* process that touches users.json — CLI commands (`user reset-passphrase`,
* etc.) run as separate short-lived processes against the same file while
* a daemon may already be running. A cache here meant a passphrase reset
* or demotion issued via the CLI silently didn't take effect against an
* already-running daemon until it was restarted (confirmed live while
* verifying fix/loopback-auth-bypass, alongside the identical bug in
* FileTokenStore). The file is a handful of users — re-parsing it on
* every call is cheap next to the rest of a request.
*/
export class FileUserStore implements UserStore {
private cache: User[] | undefined;
private writeQueue: Promise<void> = Promise.resolve();

constructor(private storePath: string) {}
Expand Down Expand Up @@ -213,11 +220,7 @@ export class FileUserStore implements UserStore {
// ── private ─────────────────────────────────────────────────────────────

private load(): User[] {
if (this.cache) return this.cache;
if (!existsSync(this.storePath)) {
this.cache = [];
return this.cache;
}
if (!existsSync(this.storePath)) return [];
const raw = readFileSync(this.storePath, 'utf-8');
let parsed: unknown;
try {
Expand All @@ -228,8 +231,7 @@ export class FileUserStore implements UserStore {
if (!Array.isArray(parsed)) {
throw new Error(`users.json: expected array at ${this.storePath}`);
}
this.cache = parsed as User[];
return this.cache;
return parsed as User[];
}

private save(users: User[]): void {
Expand All @@ -238,7 +240,6 @@ export class FileUserStore implements UserStore {
writeFileSync(tmp, JSON.stringify(users, null, 2) + '\n', { mode: 0o600 });
renameSync(tmp, this.storePath);
try { chmodSync(this.storePath, 0o600); } catch { /* best-effort */ }
this.cache = users;
}

/**
Expand Down