/** * Per-account encrypted token store for Outlook OAuth credentials. * * Adapted from nsakki55/outlook-mcp@e49d8e68ac8d69db653c25803127dc3b98626cda * (MIT). Adaptations: * - Per-account paths under {ACCOUNTS_DIR}/{accountId}/secrets/outlook/ * - keytar fallback removed (Pi runs headless, no Secret Service) * - node-persist removed; single JSON blob written atomically (temp + rename) * * tokens.enc AES-256-CBC ciphertext ":" of the JSON blob * .key 32 random bytes, base64, mode 0600 (the AES key) * * The JSON blob shape is {accessToken, refreshToken, accessTokenExpiry, * refreshTokenExpiry, lastRefresh, graphUserId, scopes}. * * Rationale: per CLAUDE.md doctrine, account scope is absolute. Each account * holds its own AES key — compromising account A's keyfile cannot decrypt * account B's tokens. */ export interface TokenBlob { accessToken: string; refreshToken: string | null; accessTokenExpiry: number; refreshTokenExpiry: number; lastRefresh: number; graphUserId: string | null; mail: string | null; scopes: string[]; } /** Filesystem-safe mailbox directory name. graphUserId (a GUID) passes through * unchanged; a fallback mail/UPN identity has its unsafe characters folded to * '_'. The true identity is never read back from this name — it lives in the * blob (graphUserId, mail) — so a lossy fold is harmless. */ export declare function sanitizeMailboxKey(key: string): string; /** Per-writer temp path. Two processes writing the same mailbox must never * share one, or each rename races the other's cleanup. */ declare function tempPathFor(tokensPath: string): string; export declare class TokenStore { readonly accountId: string; readonly mailboxKey?: string | undefined; /** Exported for tests only. */ static readonly _internals: { tempPathFor: typeof tempPathFor; }; private cachedBlob; private encryptionKey; private readonly secretsDir; private readonly tokensPath; private readonly keyPath; /** Public so tests can plant a lock. Guards the refresh of this mailbox only. */ readonly lockPath: string; /** * @param mailboxKey When set, tokens live under `mailboxes//tokens.enc` * so N mailboxes coexist under one account. When omitted, the legacy * `tokens.enc` path is used — retained for reading pre-migration installs. * The AES `.key` is always account-level (`secrets/outlook/.key`) and shared * by every mailbox within the account; account isolation is unchanged. */ constructor(accountId: string, accountsDir: string, mailboxKey?: string | undefined); /** Load (or create) the AES key. Idempotent and TOCTOU-safe via wx flag. */ private loadKey; private encrypt; private decrypt; /** Atomic write — per-writer temp + rename. Either old or new tokens, never * partial. The temp name embeds pid and randomness so two processes writing * the same mailbox never share a temp path; a shared name made one writer's * rename find its temp already moved away, which surfaced as ENOENT. */ private writeBlob; /** Read and decrypt the blob from disk; cached after first read. */ read(): TokenBlob | null; /** Read from disk, ignoring any cached blob. A process waiting on a peer's * refresh must use this — `read()` would keep returning its own stale cache * and the wait could never observe the peer's write. */ reload(): TokenBlob | null; /** Read the lock file, or null when absent or unparseable. */ private readLock; /** Whether a live, non-stale refresh lock exists. A lock whose holder died, * or which is older than the staleness bound, does not count as held. */ refreshLockHeld(): boolean; /** * Take the refresh lock, or report that a peer holds it. * * The `wx` flag is O_CREAT|O_EXCL, so the create is atomic and two processes * cannot both believe they won. This deliberately differs from the * check-then-write `acquireLock` in `scripts/complete-registration.ts`, which * has a time-of-check-to-time-of-use gap — tolerable for a singleton runner, * but it would defeat the entire purpose here. */ tryAcquireRefreshLock(): boolean; /** Release the lock, but only if this process owns it. */ releaseRefreshLock(): void; store(accessToken: string, refreshToken: string | null, expiresInSeconds: number, extras?: { graphUserId?: string | null; mail?: string | null; scopes?: string[]; }): TokenBlob; private tryReadSilent; /** * Whether the access token is within the refresh threshold of expiry. Caller * should refresh before next Graph call when this is true. */ needsRefresh(): boolean; hasTokens(): boolean; refreshTokenExpired(): boolean; clear(): void; } /** Exported for tests + the mailbox-info tool. */ export declare const refreshThresholdMs: number; /** Exported for tests. */ export declare const refreshLockStaleMs: number; export {}; //# sourceMappingURL=token-store.d.ts.map