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
70 changes: 70 additions & 0 deletions src/lib/accounts/_internal/auth-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Internal: auth.json read/state helpers — symlink materialization,
// ensure-exists guard, current-name file I/O. Extracted from the
// monolithic AccountService (Theme N2).

import path from "node:path";
import fsp from "node:fs/promises";
import {
resolveAuthPath,
resolveCurrentNamePath,
} from "../../config/paths";
import {
ensureSecureDir,
secureWriteFile,
} from "../../io/secure-fs";
import { AuthFileMissingError } from "../errors";
import {
setSessionAccountName,
} from "../session/pin";
import { pathExists, removeIfExists } from "./fs-helpers";

export async function ensureAuthFileExists(authPath: string): Promise<void> {
if (!(await pathExists(authPath))) {
throw new AuthFileMissingError(authPath);
}
}

export async function materializeAuthSymlink(authPath: string): Promise<void> {
const stat = await fsp.lstat(authPath);
if (!stat.isSymbolicLink()) {
return;
}

const snapshotData = await fsp.readFile(authPath);
await removeIfExists(authPath);
await secureWriteFile(authPath, snapshotData);
}

export async function writeCurrentName(
name: string,
options?: { authFingerprint?: string },
): Promise<void> {
const currentNamePath = resolveCurrentNamePath();
await ensureSecureDir(path.dirname(currentNamePath));
await secureWriteFile(currentNamePath, `${name}\n`);
await setSessionAccountName(name, options?.authFingerprint);
}

export async function readCurrentNameFile(currentNamePath: string): Promise<string | null> {
try {
const contents = await fsp.readFile(currentNamePath, "utf8");
const trimmed = contents.trim();
return trimmed.length ? trimmed : null;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return null;
}
throw error;
}
}

export async function clearActivePointers(
clearSessionAccountName: () => Promise<void>,
): Promise<void> {
const currentPath = resolveCurrentNamePath();
const authPath = resolveAuthPath();
await removeIfExists(currentPath);
await removeIfExists(authPath);
await clearSessionAccountName();
}
64 changes: 64 additions & 0 deletions src/lib/accounts/_internal/fs-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Internal filesystem helpers shared across the extracted clusters.
// Not part of the public API — only modules under `src/lib/accounts/`
// should import from here.

import fs from "node:fs";
import fsp from "node:fs/promises";

export async function pathExists(targetPath: string): Promise<boolean> {
try {
await fsp.access(targetPath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}

export async function filesMatch(firstPath: string, secondPath: string): Promise<boolean> {
try {
const [first, second] = await Promise.all([
fsp.readFile(firstPath),
fsp.readFile(secondPath),
]);
return first.equals(second);
} catch {
return false;
}
}

export async function removeIfExists(target: string): Promise<void> {
try {
await fsp.rm(target, { force: true });
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== "ENOENT") {
throw error;
}
}
}

export interface AuthSyncState {
fingerprint: string;
isSymbolicLink: boolean;
}

export async function readAuthSyncState(authPath: string): Promise<AuthSyncState | null> {
try {
const stat = await fsp.lstat(authPath);
return {
fingerprint: createAuthSyncFingerprint(stat),
isSymbolicLink: stat.isSymbolicLink(),
};
} catch {
return null;
}
}

export function createAuthSyncFingerprint(stat: fs.Stats): string {
return [
stat.isSymbolicLink() ? "symlink" : "file",
typeof stat.ino === "number" ? Math.trunc(stat.ino) : 0,
Math.trunc(stat.size),
Math.trunc(stat.mtimeMs),
].join(":");
}
196 changes: 196 additions & 0 deletions src/lib/accounts/_internal/name-resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// Internal: resolve the snapshot file name for a given live auth snapshot.
// Used by save/use/external-sync — kept under _internal/ because the
// public surface only exposes the orchestrator wrappers.

import {
AccountNameInferenceError,
} from "../errors";
import { parseAuthSnapshotFile } from "../auth-parser";
import { loadRegistry } from "../registry";
import { ParsedAuthSnapshot } from "../types";
import { listAccountNames } from "../read/listing";
import { accountFilePath, normalizeAccountName } from "../naming";
import { pathExists } from "./fs-helpers";
import {
registryEntrySharesEmail,
registryEntrySharesIdentity,
snapshotsShareEmail,
snapshotsShareIdentity,
} from "../identity/equality";

export type ResolvedAccountNameSource = "active" | "existing" | "inferred";

export interface ResolvedDefaultAccountName {
name: string;
source: ResolvedAccountNameSource;
forceOverwrite?: boolean;
}

export interface ResolvedLoginAccountName {
name: string;
source: ResolvedAccountNameSource;
forceOverwrite?: boolean;
}

function orderReloginSnapshotCandidates(
accountNames: string[],
incomingSnapshot: ParsedAuthSnapshot,
activeName: string | null,
): string[] {
const ordered: string[] = [];
const add = (name: string | null | undefined): void => {
if (!name || !accountNames.includes(name) || ordered.includes(name)) return;
ordered.push(name);
};

add(activeName);

const incomingEmail = incomingSnapshot.email?.trim().toLowerCase();
if (incomingEmail) {
try {
add(normalizeAccountName(incomingEmail));
} catch {
// Invalid email-shaped snapshot names fall through to identity scan.
}
}

for (const name of accountNames) {
add(name);
}

return ordered;
}

async function resolveRegistryAccountNameForIncomingSnapshot(
incomingSnapshot: ParsedAuthSnapshot,
candidates: string[],
activeName: string | null,
): Promise<ResolvedDefaultAccountName | null> {
const registry = await loadRegistry();
let activeEmailMatch: ResolvedDefaultAccountName | null = null;

for (const name of candidates) {
const entry = registry.accounts[name];
if (!entry || !(await pathExists(accountFilePath(name)))) continue;

if (registryEntrySharesIdentity(entry, incomingSnapshot)) {
return {
name,
source: activeName === name ? "active" : "existing",
};
}

if (!activeEmailMatch && registryEntrySharesEmail(entry, incomingSnapshot)) {
activeEmailMatch = {
name,
source: activeName === name ? "active" : "existing",
forceOverwrite: true,
};
}
}

return activeEmailMatch;
}

export async function resolveExistingAccountNameForIncomingSnapshot(
incomingSnapshot: ParsedAuthSnapshot,
activeName: string | null,
): Promise<ResolvedDefaultAccountName | null> {
let emailMatch: ResolvedDefaultAccountName | null = null;
const accountNames = await listAccountNames();
const candidates = orderReloginSnapshotCandidates(
accountNames,
incomingSnapshot,
activeName,
);
const registryMatch = await resolveRegistryAccountNameForIncomingSnapshot(
incomingSnapshot,
candidates,
activeName,
);
if (registryMatch) {
return registryMatch;
}

for (const name of candidates) {
const snapshotPath = accountFilePath(name);
if (!(await pathExists(snapshotPath))) continue;

const existingSnapshot = await parseAuthSnapshotFile(snapshotPath);
if (snapshotsShareIdentity(existingSnapshot, incomingSnapshot)) {
return {
name,
source: activeName === name ? "active" : "existing",
};
}

if (!emailMatch && snapshotsShareEmail(existingSnapshot, incomingSnapshot)) {
emailMatch = {
name,
source: activeName === name ? "active" : "existing",
forceOverwrite: true,
};
}
}

return emailMatch;
}

export async function resolveUniqueInferredName(
baseName: string,
incomingSnapshot: ParsedAuthSnapshot,
): Promise<string> {
const hasMatchingIdentity = async (name: string): Promise<boolean> => {
const parsed = await parseAuthSnapshotFile(accountFilePath(name));
return snapshotsShareIdentity(parsed, incomingSnapshot);
};

const basePath = accountFilePath(baseName);
if (!(await pathExists(basePath))) {
return baseName;
}
if (await hasMatchingIdentity(baseName)) {
return baseName;
}

for (let i = 2; i <= 99; i += 1) {
const candidate = normalizeAccountName(`${baseName}--dup-${i}`);
const candidatePath = accountFilePath(candidate);
if (!(await pathExists(candidatePath))) {
return candidate;
}
if (await hasMatchingIdentity(candidate)) {
return candidate;
}
}

throw new AccountNameInferenceError();
}

export async function inferAccountNameFromSnapshot(
incomingSnapshot: ParsedAuthSnapshot,
): Promise<string> {
const email = incomingSnapshot.email?.trim().toLowerCase();
if (!email || !email.includes("@")) {
throw new AccountNameInferenceError();
}

const baseCandidate = normalizeAccountName(email);
return resolveUniqueInferredName(baseCandidate, incomingSnapshot);
}

export async function resolveLoginAccountNameForSnapshot(
incomingSnapshot: ParsedAuthSnapshot,
activeName: string | null,
): Promise<ResolvedLoginAccountName> {
const existing = await resolveExistingAccountNameForIncomingSnapshot(
incomingSnapshot,
activeName,
);
if (existing) return existing;

return {
name: await inferAccountNameFromSnapshot(incomingSnapshot),
source: "inferred",
};
}
50 changes: 50 additions & 0 deletions src/lib/accounts/_internal/registry-ops.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Internal registry helpers shared across write clusters.
// All durable writes funnel through `persistRegistryAtomic` (Theme N1) —
// this module just adds the reconcile-by-account-name step on top.

import {
persistRegistryAtomic,
reconcileRegistryWithAccounts,
} from "../registry";
import { RegistryData } from "../types";
import { parseAuthSnapshotFile } from "../auth-parser";
import { accountFilePath } from "../naming";
import { listAccountNames } from "../read/listing";

export async function persistRegistry(registry: RegistryData): Promise<void> {
const reconciled = reconcileRegistryWithAccounts(
registry,
await listAccountNames(),
);
await persistRegistryAtomic(reconciled);
}

export async function hydrateSnapshotMetadata(
registry: RegistryData,
accountName: string,
): Promise<void> {
const parsed = await parseAuthSnapshotFile(accountFilePath(accountName));
const entry = registry.accounts[accountName] ?? {
name: accountName,
createdAt: new Date().toISOString(),
};

if (parsed.email) entry.email = parsed.email;
if (parsed.accountId) entry.accountId = parsed.accountId;
if (parsed.userId) entry.userId = parsed.userId;
if (parsed.planType) entry.planType = parsed.planType;

registry.accounts[accountName] = entry;
}

export async function hydrateSnapshotMetadataIfMissing(
registry: RegistryData,
accountName: string,
): Promise<void> {
const entry = registry.accounts[accountName];
if (entry?.email && entry.accountId && entry.userId && entry.planType) {
return;
}

await hydrateSnapshotMetadata(registry, accountName);
}
Loading
Loading