/** * Cleo Nexus device store: `/nexus-device.json`. * * One file per CLEO home holds every Nexus device identity of that home, keyed * by API origin and then by user id (cleo-nexus device contract §2.2, §2.4, * §2.5, §3.1, §3.5). Each entry carries: * * - `deviceId`, the client-minted UUIDv7 (§3.1); * - `keys`, the device's X25519 and Ed25519 key pairs, local-only (§2.4); * - `current`, the device credential in use (`cnx_d1_…`, §2.2); * - `pending`, a client-minted rotation credential not yet confirmed (§2.5); * - `pendingSignOut` and `pendingRevoke`, credentials kept only to retry an * unconfirmed sign-out (E9) or revoke (E10) (§3.5, M3); * - `retired`, the unsettled sign-out or revoke of a device this entry * replaced, so a live credential is never dropped on a device change. * * The file is separate from `nexus-credentials.json`, which stays format * version 1 so a downgraded CLI can read it and never touches this file * (finding 8). * * Safety rules, all enforced here: * * - **Sealed at rest.** Every token and private key is encrypted with * AES-256-GCM under the machine key (`crypto/credentials.ts`, * KDF id `nexus-device:` + `JSON.stringify([origin, userId])`, which no * choice of origin and user id can make collide). Each value carries its * field path (for example `current.token`) as GCM associated data, so * values cannot be swapped between fields either. A copy of the file on * another machine, or under another (origin, user), cannot be opened. The * file is also excluded from every backup bundle. Read paths (`get`, * `list`) never create the machine key or salt: without them an entry * reads as unreadable. A lost key never blocks re-login: the login discards * the unreadable entry ({@link NexusDeviceTransaction.discardUnreadable}). * - **Owner-only.** The file is created 0600 (`O_EXCL | O_NOFOLLOW`). Every * read opens it without following links and checks the open descriptor: * a regular file, one link, owned by this user, no group or other bits. * The CLEO home must be owned by this user, inside a parent no other user * can rewrite; a home the user owns that is group- or world-writable is * tightened with `chmod go-w` on the first write, with a warning in * {@link NexusDeviceTransaction.warnings}. Each refusal names the fix. * - **Locked, re-read, atomic.** Every mutation runs through * {@link NexusDeviceStore.update}: it takes this store's own cross-process * lock (about a minute of waiting, see {@link NexusDeviceStoreOptions}), * re-reads the file **after** acquiring it (M6, N1), and writes a temp file * that is fsynced and renamed over the original, then fsyncs the directory. * On macOS, Node's fsync is `F_FULLFSYNC` (libuv), so the data reaches the * disk, not only the drive cache. No rotated backups are made; any found are * purged (L3), and stale temp files are swept under the lock. * - **Never held across a network call (contract v2.9).** The lock covers * only a local read-modify-write. Every change that involves the server * follows one order: under the lock, re-read and persist the intent (a * `pending` credential before E8, §2.5 step 1; the E1 intent before E1, * §3.3 step 5); **return from `update`, releasing the lock**; make the call * (E1, E8, E9, E10) with a plain `AbortSignal.timeout(...)`; then call * `update` again and re-check with a compare-and-swap (is the entry still * the one this process wrote?) before promoting or clearing. `update` * writes when `fn` returns, so the intent is durable before the request. * - **Lock loss aborts.** If the lock is taken over (a stale-lock takeover * after the machine slept, say), {@link NexusDeviceTransaction.signal} is * aborted, no further write happens, and `update` rejects with * `E_NEXUS_DEVICE_LOCK_COMPROMISED`. * - **No downgrade.** A file whose `version` is newer than this CLI knows is * refused, never read as empty and never rewritten. A malformed file is * refused the same way. Unknown fields of a known version, at any depth, * are kept on rewrite. * - **Unreadable entries are carried, not dropped.** An entry whose secrets do * not open under this machine's key (copied in from another machine) is * written back byte-identical and never decrypted, rewritten or deleted. * Updates to other entries go ahead; only an operation on that entry fails * (`E_NEXUS_DEVICE_UNSEAL_FAILED`), and {@link NexusDeviceStore.list} * reports it as an {@link UnreadableNexusDevice}. * - **Fenced writes.** Before each rename the file is compared (inode, size, * mtime in ns) with what this transaction last read or wrote; a change * made by anyone else aborts with `E_NEXUS_DEVICE_LOCK_COMPROMISED`. * - **Sealed handles.** Nothing handed out prints a secret: entries from * {@link NexusDeviceTransaction.get}, {@link SealedNexusDevice.unseal} and * the `apply*` transitions carry non-enumerable `toJSON` and `util.inspect` * guards, and {@link NexusDeviceEnrolment} holds its secrets privately. * {@link redactNexusDeviceSecrets} masks credentials and private keys in any * diagnostic text. The guards are non-enumerable, so spreading a guarded * entry (`{ ...entry }`) produces an unguarded copy: T12868–T12871 must * never spread an entry, or any part of one, into a log line or envelope. * * Behaviour that uses it is on by default and switched off with * `CLEO_NEXUS_DEVICE=0` ({@link isNexusDeviceEnabled}, T12904): login * enrolment and the 9.24 upgrade (`nexus-enrol.ts`, T12868), logout and * revoke (`nexus-logout.ts`, T12870), then rotation and the cloud reads * (T12869, T12871). * * @task T12867 * @epic T12323 */ import { inspect } from 'node:util'; import { z } from 'zod'; /** File name of the store, directly under the CLEO home. */ export declare const NEXUS_DEVICE_FILE = "nexus-device.json"; /** The file format version this CLI reads and writes. A newer version is refused. */ export declare const NEXUS_DEVICE_FILE_VERSION = 1; /** Environment switch that turns on the device-credential behaviour (T12867–T12871). */ export declare const NEXUS_DEVICE_ENV = "CLEO_NEXUS_DEVICE"; /** * The device credential format (contract §2.2, `DEVICE_CREDENTIAL` in §4.1): * `cnx_d1_` followed by 43 base64url characters (32 random bytes). */ export declare const NEXUS_DEVICE_CREDENTIAL_PATTERN: RegExp; /** Every scope a device credential can carry (contract §2.3, `DeviceScope`). */ export declare const NEXUS_DEVICE_SCOPES: readonly ["account:read", "devices:read", "projects:read", "projects:write", "sync:read", "sync:write", "keys:read", "keys:write"]; /** Credential profiles (contract §2.3, `DeviceProfile`). */ export declare const NEXUS_DEVICE_PROFILES: readonly ["device", "read-only"]; /** Why an E1 call is in flight: an interactive login (§3.3) or the 9.24 auto-upgrade (§3.4). */ export declare const NEXUS_DEVICE_INTENT_KINDS: readonly ["login", "upgrade"]; /** * Most credentials one sign-out or revoke slot may hold. A transition that * would need more refuses (`E_NEXUS_DEVICE_SLOT_FULL`) instead of dropping a * live credential. */ export declare const NEXUS_DEVICE_MAX_SLOT_CREDENTIALS = 8; /** Most retired-device items one entry may hold; beyond it a device change refuses. */ export declare const NEXUS_DEVICE_MAX_RETIRED = 8; /** Default time {@link NexusDeviceStore.update} waits for the lock. */ export declare const NEXUS_DEVICE_LOCK_WAIT_MS = 60000; /** Default age after which a lock whose holder stopped refreshing it may be taken over. */ export declare const NEXUS_DEVICE_LOCK_STALE_MS = 30000; declare const entrySchema: z.ZodObject<{ deviceId: z.ZodString; createdAt: z.ZodISODateTime; keys: z.ZodNullable; signing: z.ZodObject<{ publicKey: z.ZodString; privateKey: z.ZodString; }, z.core.$loose>; }, z.core.$loose>>; current: z.ZodNullable; scopes: z.ZodArray>; createdAt: z.ZodISODateTime; }, z.core.$loose>>; pending: z.ZodNullable; token: z.ZodString; createdAt: z.ZodISODateTime; }, z.core.$loose>>; pendingSignOut: z.ZodNullable; token: z.ZodString; }, z.core.$loose>>; requestedAt: z.ZodISODateTime; }, z.core.$loose>>; pendingRevoke: z.ZodNullable; token: z.ZodString; }, z.core.$loose>>; requestedAt: z.ZodISODateTime; }, z.core.$loose>>; retired: z.ZodOptional; credentials: z.ZodArray; token: z.ZodString; }, z.core.$loose>>; requestedAt: z.ZodISODateTime; }, z.core.$loose>>>; /** * An E1 call in flight (contract §3.3 step 5, §3.4 step 1): persisted under * the lock before E1 is sent, cleared at step 7. Holds no secret. */ /** * A credential E1 issued to this process while the file already held * another one for the same device, and the server could not yet say which * is live (§3.3 step 7, ruling A). Kept, sealed, until E2 settles it, so * the only live credential is never lost. */ raceCandidate: z.ZodOptional; scopes: z.ZodArray>; createdAt: z.ZodISODateTime; }, z.core.$loose>>>; enrolIntent: z.ZodOptional; /** Random id of the process that wrote it (a CAS token). */ owner: z.ZodString; startedAt: z.ZodISODateTime; }, z.core.$loose>>>; }, z.core.$loose>; /** One (origin, user) entry of the device file, secrets in plaintext (in memory only). */ export type NexusDeviceEntry = z.infer; /** The device's two key pairs (contract §2.4). */ export type NexusDeviceKeys = NonNullable; /** A stored key pair, base64 (32 raw bytes each). */ export type NexusDeviceKeyPair = NexusDeviceKeys['encryption']; /** The credential in use, as issued by E1 or promoted from `pending`. */ export type NexusDeviceCurrentCredential = NonNullable; /** A client-minted rotation credential awaiting confirmation (contract §2.5). */ export type NexusDevicePendingCredential = NonNullable; /** A `pendingSignOut` or `pendingRevoke` slot (contract §3.5). */ export type NexusDevicePendingEnd = NonNullable; /** One credential kept in a sign-out or revoke slot. */ export type NexusDeviceSlotCredential = NexusDevicePendingEnd['credentials'][number]; /** An unsettled sign-out or revoke of a device an entry replaced. */ export type NexusDeviceRetired = NonNullable[number]; /** An E1 call in flight for an entry (§3.3 step 5, §3.4 step 1). */ export type NexusDeviceEnrolIntent = NonNullable; /** A racing login's unsettled credential (§3.3 step 7). */ export type NexusDeviceRaceCandidate = NonNullable; /** A device credential profile. */ export type NexusDeviceProfile = (typeof NEXUS_DEVICE_PROFILES)[number]; /** A device credential scope. */ export type NexusDeviceScope = (typeof NEXUS_DEVICE_SCOPES)[number]; /** Codes carried by {@link NexusDeviceStoreError}. */ export type NexusDeviceStoreErrorCode = 'E_NEXUS_DEVICE_FILE_PERMISSIONS' | 'E_NEXUS_DEVICE_FILE_UNSAFE' | 'E_NEXUS_DEVICE_FILE_SYMLINK' | 'E_NEXUS_DEVICE_DIR_UNSAFE' | 'E_NEXUS_DEVICE_FILE_NEWER' | 'E_NEXUS_DEVICE_FILE_INVALID' | 'E_NEXUS_DEVICE_UNSEAL_FAILED' | 'E_NEXUS_DEVICE_MACHINE_KEY' | 'E_NEXUS_DEVICE_IO' | 'E_NEXUS_DEVICE_ENTRY_INVALID' | 'E_NEXUS_DEVICE_PENDING_EXISTS' | 'E_NEXUS_DEVICE_REVOKE_PENDING' | 'E_NEXUS_DEVICE_SLOT_FULL' | 'E_NEXUS_DEVICE_BUSY' | 'E_NEXUS_DEVICE_REENTRANT' | 'E_NEXUS_DEVICE_LOCK_COMPROMISED'; /** Thrown when the device file cannot be read or written safely. Never carries a secret. */ export declare class NexusDeviceStoreError extends Error { /** Machine-readable reason. */ readonly code: NexusDeviceStoreErrorCode; /** * @param code - Machine-readable reason. * @param message - Human-readable message; passed through {@link redactNexusDeviceSecrets}. */ constructor(code: NexusDeviceStoreErrorCode, message: string); } /** * Whether the device-credential login is on. It is the default (T12904); * `CLEO_NEXUS_DEVICE=0` (or `false`, `off`, `no`) switches back to the 9.24 * session login. Status and logout always handle a stored device credential. * * @param env - Environment to read; defaults to `process.env`. * @returns `false` only for an off value. */ export declare function isNexusDeviceEnabled(env?: NodeJS.ProcessEnv): boolean; /** * Absolute path of the device store. * * @returns `/nexus-device.json`, through `@cleocode/paths` (arch gate 2). */ export declare function nexusDevicePath(): string; /** * Mask a device credential down to a safe preview. * * The preview keeps the last 4 characters (24 of the token's 256 random * bits), enough to tell two credentials apart in a diagnostic and far too * little to guess the rest. The same rule as `maskNexusToken`. * * @param value - Plaintext credential (read transiently, not retained). * @returns `cnx_d1_…` plus the last 4 characters, or `…` for anything shorter. */ export declare function maskNexusDeviceCredential(value: string): string; /** * Mask every device credential and every private-key field in `text`. Use on * any diagnostic string (errors, logs, envelopes) that might hold one. * * @param text - Arbitrary text. * @returns The text with every `cnx_d1_…` credential masked and every * `privateKey` value replaced by `[redacted]`. */ export declare function redactNexusDeviceSecrets(text: string): string; /** * Attach the store's non-enumerable `toJSON` and `util.inspect` guards to any * value that holds device secrets (tokens, private keys), at every depth, so * printing or serialising it shows only the redacted view. Never spread the * result: a spread drops the guards. * * @param value - An object holding `token` or `privateKey` fields. * @returns The same object, guarded. */ export declare function guardNexusDeviceSecrets(value: T): T; /** * Secret-free diagnostic view of one entry. Public keys are shown; private * keys and tokens never are. * * @param entry - A stored entry. * @returns A view safe to print, log or put in an envelope. */ export declare function redactNexusDeviceEntry(entry: NexusDeviceEntry): Record; /** * Opaque read handle to one entry. Tokens and private keys are held in a * private field; serialising or inspecting the handle yields * {@link redactNexusDeviceEntry} only. The accessors are for the code that * builds an `Authorization` header or signs with the device key. */ export declare class SealedNexusDevice { #private; /** Always `true`: this entry opened under this machine's key. */ readonly readable = true; /** API origin. */ readonly origin: string; /** Nexus user id. */ readonly userId: string; /** Nexus device id (UUIDv7). */ readonly deviceId: string; /** * @param origin - API origin. * @param userId - Nexus user id. * @param entry - Stored entry; captured privately as a copy. */ constructor(origin: string, userId: string, entry: NexusDeviceEntry); /** The current credential's token, or `null`. Call only where the request is built. */ currentBearer(): string | null; /** The pending rotation credential's token, or `null` (§2.5: try it before `current`). */ pendingBearer(): string | null; /** * A guarded copy of the whole entry, secrets included, for the flows that * must act on them. Printing or serialising it shows the redacted view. */ unseal(): NexusDeviceEntry; /** Secret-free JSON form. */ toJSON(): Record; /** Secret-free string form. */ toString(): string; /** Secret-free `util.inspect` form. */ [inspect.custom](): string; } /** * What E1 returned, as it is about to be stored (contract §3.3 step 7). The * issued credential and any device keys are held in private fields, so the * value never prints a secret. */ export declare class NexusDeviceEnrolment { #private; /** The device id that was enrolled. */ readonly deviceId: string; /** * @param input - The enrolled device id, the keys when this enrolment * created or confirmed them (else `null`), and the credential E1 issued. * @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_ENTRY_INVALID` on a malformed credential or keys. */ constructor(input: { readonly deviceId: string; readonly keys: NexusDeviceKeys | null; readonly credential: NexusDeviceCurrentCredential; }); /** A guarded copy of the keys, or `null`. */ keys(): NexusDeviceKeys | null; /** A guarded copy of the issued credential. */ credential(): NexusDeviceCurrentCredential; /** Secret-free JSON form. */ toJSON(): Record; /** Secret-free string form. */ toString(): string; /** Secret-free `util.inspect` form. */ [inspect.custom](): string; } /** * Throw unless a login may enrol `deviceId` over `entry`. While a revoke of * the same device is unconfirmed, enrolling would re-activate a device the * user asked to revoke: retry E10 first (`cleo logout nexus --revoke`), and * if it cannot be confirmed, revoke the device on cleocode.dev. Call this * before E1 is sent; {@link applyEnrolment} checks it again. * * @param entry - The entry read under the lock, or `null`. * @param deviceId - The device id the login would enrol. * @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_REVOKE_PENDING`. */ export declare function assertEnrolmentAllowed(entry: NexusDeviceEntry | null, deviceId: string): void; /** * Record a successful E1 (M6). * * - **Same device:** store the new credential and clear `pending` and * `pendingSignOut`, because E1 revoked every older credential of the device * on the server. Refused while `pendingRevoke` is set * ({@link assertEnrolmentAllowed}). * - **New device** (a fresh identity replaced the entry's device): the old * device's unsettled revoke, and any credential it still holds, move to * `retired`, so they are retried rather than dropped. * * @param entry - The entry read under the lock, or `null` for a new identity. * @param enrolment - E1's result. * @param now - Clock, for `createdAt` of a new entry and retired items. * @returns The updated entry, guarded. * @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_REVOKE_PENDING`, or * `E_NEXUS_DEVICE_SLOT_FULL` when `retired` is full. */ export declare function applyEnrolment(entry: NexusDeviceEntry | null, enrolment: NexusDeviceEnrolment, now?: Date): NexusDeviceEntry; /** * Persist the identity a login or upgrade is about to enrol, with its E1 * intent (§3.3 step 5, §3.4 step 1), before E1 is sent. * * - **No entry:** a new identity with `deviceId` and `keys`. * - **Same device:** the intent is recorded; `keys` fill in only when the * entry has none (lost keys, §3.3 step 6 `device-keys-changed`). Every * credential and slot is kept: E1 has not run yet. * - **Another device** (a fresh identity after 409 `device-revoked`, * `device-other-account`, `device-id-taken` or `device-keys-changed`): the * old device's credentials and unsettled slots move to `retired`, as in * {@link applyEnrolment}, so nothing live is dropped. * * @param entry - The entry read under the lock, or `null`. * @param identity - The device id and keys to enrol, and the intent. * @param now - Clock. * @returns The updated entry, guarded. * @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_REVOKE_PENDING` for the same * device while a revoke is unconfirmed, or `E_NEXUS_DEVICE_SLOT_FULL`. */ export declare function applyEnrolIntent(entry: NexusDeviceEntry | null, identity: { readonly deviceId: string; readonly keys: NexusDeviceKeys; readonly intent: NexusDeviceEnrolIntent; }, now?: Date): NexusDeviceEntry; /** * Clear an E1 intent, as a compare-and-swap on its owner: another process's * intent is never cleared. * * @param entry - The entry read under the lock. * @param owner - The intent owner this process wrote. * @returns The updated entry (guarded), or the entry unchanged. */ export declare function applyClearEnrolIntent(entry: NexusDeviceEntry, owner: string): NexusDeviceEntry; /** * Drop `current`, as a compare-and-swap on its credential id: used when the * server refused it and no other credential is live (§3.3 step 7, both * racing credentials answered 401). Nothing is moved to a sign-out slot: a * refused credential needs no E9. * * @param entry - The entry read under the lock. * @param credentialId - The credential id the caller saw refused. * @returns The updated entry (guarded), or the entry unchanged when `current` moved on. */ export declare function applyDropCurrent(entry: NexusDeviceEntry, credentialId: string): NexusDeviceEntry; /** * Refresh this process's E1 intent right before E1 is sent (compare-and-swap * on the owner), so a live intent never looks stale while its owner is still * working (§3.4 step 1). * * @param entry - The entry read under the lock. * @param owner - The intent owner this process wrote. * @param now - Clock. * @returns The updated entry (guarded), or `null` when the intent is no * longer this process's (another process replaced or took it over). */ export declare function applyRefreshEnrolIntent(entry: NexusDeviceEntry, owner: string, now?: Date): NexusDeviceEntry | null; /** * Park a racing login's credential beside `current` until E2 settles which is * live (§3.3 step 7). An existing candidate is replaced only by a newer one; * the older is dropped, because E1 re-enrolment revoked it. * * @param entry - The entry read under the lock. * @param candidate - The credential E1 issued to this process. * @returns The updated entry, guarded. */ export declare function applySetRaceCandidate(entry: NexusDeviceEntry, candidate: NexusDeviceRaceCandidate): NexusDeviceEntry; /** * Drop the race candidate, as a compare-and-swap on its credential id (it * lost: the server refused it, or `current` is newer). * * @param entry - The entry read under the lock. * @param credentialId - The candidate the caller settled. * @returns The updated entry (guarded), or the entry unchanged. */ export declare function applyDropRaceCandidate(entry: NexusDeviceEntry, credentialId: string): NexusDeviceEntry; /** * Store a freshly minted rotation credential as `pending` (§2.5 step 1). A * second pending credential is never minted: when one exists the caller must * replay it instead. The caller MUST let `update` write it (return from the * transaction, releasing the lock) before sending E8, never send E8 inside it. * * @param entry - The entry read under the lock. * @param newToken - The client-minted `cnx_d1_…` token. * @param now - Clock. * @returns The updated entry, guarded. * @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_PENDING_EXISTS` when `pending` is already set. */ export declare function applyPendingRotation(entry: NexusDeviceEntry, newToken: string, now?: Date): NexusDeviceEntry; /** * Promote `pending` to `current` (§2.5 step 5, or its first successful use), * as a compare-and-swap: only when `pending` still holds `expectedToken`. * The profile and scopes carry over: E8 issues the new credential with the * same profile and scopes as the one it replaced. * * @param entry - The entry read under the lock. * @param expectedToken - The pending token the caller saw succeed. * @param credentialId - The server id of the promoted credential. * @returns The updated entry (guarded), or the entry unchanged when `pending` * moved on or there is nothing to promote. */ export declare function applyPromotePending(entry: NexusDeviceEntry, expectedToken: string, credentialId: string): NexusDeviceEntry; /** * CAS-drop `pending`: only when it still holds `expectedToken` (§2.5). * * @param entry - The entry read under the lock. * @param expectedToken - The pending token the caller saw fail. * @returns The updated entry (guarded), or the entry unchanged when `pending` moved on. */ export declare function applyDropPending(entry: NexusDeviceEntry, expectedToken: string): NexusDeviceEntry; /** * Begin `cleo logout nexus` (§3.5, M3): move the live credentials into * `pendingSignOut`, newest first (`pending`, then `current`, then any * credential an earlier unsettled sign-out holds), so they are used for * nothing but the E9 retry. The device keys stay. * * @param entry - The entry read under the lock. * @param now - Clock. * @returns The updated entry (guarded), unchanged when it holds no credential at all. * @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_SLOT_FULL` rather than drop a credential. */ export declare function applyBeginSignOut(entry: NexusDeviceEntry, now?: Date): NexusDeviceEntry; /** * Begin `cleo logout nexus --revoke` (§3.5, M3): move every credential the * entry holds into `pendingRevoke`, newest first: `pending`, `current`, an * unsettled sign-out's, then an earlier unsettled revoke's. The entry, and its * keys, stay until E10 is confirmed. * * @param entry - The entry read under the lock. * @param now - Clock. * @returns The updated entry (guarded), unchanged when it holds no credential at all. * @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_SLOT_FULL` rather than drop a credential. */ export declare function applyBeginRevoke(entry: NexusDeviceEntry, now?: Date): NexusDeviceEntry; /** * Settle a confirmed sign-out (E9 answered 200, or 401 `device-signed-out` or * `device-revoked`): clear `pendingSignOut`. * * @param entry - The entry read under the lock. * @returns The updated entry, guarded. */ export declare function applySignOutConfirmed(entry: NexusDeviceEntry): NexusDeviceEntry; /** * Forget this device locally after a revoke ended (contract §3.5, L6): the * server confirmed it, found the device signed out (E10 401 * `device-signed-out`, v2.13), or refused every credential as dead. The keys, * every credential and every slot go, so the next login enrols a fresh * device id and can never re-activate the one the user asked to burn. Only * `retired` survives, because those requests may still hold live credentials * of other devices; the caller deletes the whole entry when there are none. * * @param entry - The entry read under the lock. * @returns The stripped entry, guarded. */ export declare function applyForgetDevice(entry: NexusDeviceEntry): NexusDeviceEntry; /** * Whether an entry holds nothing worth keeping: no keys, no credential, no * slot and no retired request (left by {@link applyForgetDevice} once its * retired requests settled). * * @param entry - The entry read under the lock. * @returns `true` when the entry can be deleted. */ export declare function isForgottenDevice(entry: NexusDeviceEntry): boolean; /** * Settle a confirmed sign-out or revoke of a retired device. * * @param entry - The entry read under the lock. * @param deviceId - The retired device the server confirmed. * @param kind - Which request was confirmed. * @returns The updated entry, guarded. */ export declare function applyRetiredSettled(entry: NexusDeviceEntry, deviceId: string, kind: NexusDeviceRetired['kind']): NexusDeviceEntry; /** Read and write access to the file, valid only inside {@link NexusDeviceStore.update}. */ export interface NexusDeviceTransaction { /** * Aborted when the lock is lost (taken over as stale). No HTTP call is ever * made inside `update` (v2.9): persist, return, call, then re-lock and CAS. */ readonly signal: AbortSignal; /** * The entry for (origin of `apiUrl`, `userId`), as re-read under the lock. * * @returns A guarded copy; change it and pass it to {@link NexusDeviceTransaction.set}. */ get(apiUrl: string, userId: string): NexusDeviceEntry | null; /** Replace the entry for (origin of `apiUrl`, `userId`). Validated before it is kept. */ set(apiUrl: string, userId: string, entry: NexusDeviceEntry): void; /** * CAS delete (§2.5): remove the entry only if it still has `expected`'s * device id and current credential id (`null` when it must have none). * Returns `true` when an entry was removed. */ delete(apiUrl: string, userId: string, expected: { readonly deviceId: string; readonly credentialId: string | null; }): boolean; /** * Write the changes so far to disk now, atomically, with the lock still * held. `update` also writes when `fn` returns, which is the normal path: a * `pending` credential or an E1 intent is persisted by returning from * `update` (releasing the lock) before E8 or E1 is sent. `flush` is for a * long transaction that must make a change durable before it continues * locally; never before a network call made with the lock held. * * @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_LOCK_COMPROMISED` once the lock is lost. */ flush(): Promise; /** * Every (origin, userId) pair in the file. `readable: false` marks an entry * this machine's key cannot open: it is carried through untouched, and any * `get`, `set` or `delete` on it fails with `E_NEXUS_DEVICE_UNSEAL_FAILED`. */ keys(): Array<{ readonly origin: string; readonly userId: string; readonly readable: boolean; }>; /** * Drop an entry this machine cannot open (a lost machine key, or one copied * in from elsewhere), so a login can enrol afresh. Compare-and-swap on the * device id, which is stored in clear. The server-side device stays active, * so the returned warning (also added to {@link NexusDeviceTransaction.warnings}) * names the device id to revoke on cleocode.dev. A readable entry is never * discarded this way; use `delete`. * * @returns Whether the entry was dropped, and the warning when it was. */ discardUnreadable(apiUrl: string, userId: string, expected: { readonly deviceId: string; }): { readonly discarded: boolean; readonly warning: string | null; }; /** * One-line warnings from this transaction (for example, the CLEO home was * tightened with `chmod go-w`). The caller puts them in its result envelope. */ readonly warnings: readonly string[]; } /** An entry this machine's key cannot open, as {@link NexusDeviceStore.list} reports it. No secrets. */ export declare class UnreadableNexusDevice { /** Always `false`: the entry could not be opened here. */ readonly readable = false; /** API origin. */ readonly origin: string; /** Nexus user id. */ readonly userId: string; /** The device id stored in clear, when present. */ readonly deviceId: string | null; /** Why it could not be opened. */ readonly reason: string; /** * @param origin - API origin. * @param userId - Nexus user id. * @param deviceId - The device id stored in clear, when present. * @param reason - Why it could not be opened. */ constructor(origin: string, userId: string, deviceId: string | null, reason: string); } /** Tuning for {@link NexusDeviceStore}. The defaults suit production; tests shorten them. */ export interface NexusDeviceStoreOptions { /** How long `update` waits for the lock before `E_NEXUS_DEVICE_BUSY`. Default one minute. */ readonly lockWaitMs?: number; /** Lock staleness threshold (minimum 2000). Default 30 s. */ readonly lockStaleMs?: number; /** CLEO home whose machine key seals the secrets. Default: the store file's directory. */ readonly cleoHome?: string; } /** * File-backed store for Nexus device identities and credentials. See the * module comment for the safety rules. */ export declare class NexusDeviceStore { #private; /** Absolute path of the store file. */ readonly location: string; /** * @param path - Store path; defaults to {@link nexusDevicePath}. * @param options - Lock and sealing options. */ constructor(path?: string, options?: NexusDeviceStoreOptions); /** * A sealed snapshot of one entry, read without the lock. Use it to decide; * any change goes through {@link NexusDeviceStore.update}, which re-reads. * * @param apiUrl - Any URL on the API host. * @param userId - Nexus user id. * @returns The sealed entry, or `null`. * @throws {NexusDeviceStoreError} On an unsafe, newer, malformed or foreign-sealed file. */ get(apiUrl: string, userId: string): Promise; /** * Every entry, sorted by origin then user id. An entry this machine's key * cannot open is listed as an {@link UnreadableNexusDevice} (no secrets) * instead of failing the whole listing. * * @returns The entries. * @throws {NexusDeviceStoreError} On an unsafe, newer or malformed file. */ list(): Promise>; /** * Run `fn` under this store's cross-process lock (M6, N1). * * - The file is re-read **after** the lock is acquired, so `fn` always sees * the latest state, never a snapshot taken before another process wrote. * - `fn` may await local work (another store's lock, taken after this one), * but MUST NOT make a network call: the lock is never held across E1, * E8, E9 or E10 (v2.9). Persist the intent, return, make the call, then * call `update` again and re-check with a CAS. * - `tx.flush()` writes mid-transaction; whatever is still unflushed is * written when `fn` returns. When `fn` throws, unflushed changes are * dropped. * - Waiting for the lock gives up after `lockWaitMs` with * `E_NEXUS_DEVICE_BUSY`. Calling `update` on the same file from inside * `fn` fails at once with `E_NEXUS_DEVICE_REENTRANT` (it would otherwise * wait on itself). * - If the lock is lost while `fn` runs, `tx.signal` aborts, nothing more * is written, and `update` rejects with `E_NEXUS_DEVICE_LOCK_COMPROMISED`. * * @param fn - The read-modify-write step. * @returns What `fn` returned. * @throws {NexusDeviceStoreError} As above, or on an unsafe, newer, malformed or foreign-sealed file. */ update(fn: (tx: NexusDeviceTransaction) => R | Promise): Promise; /** The body of {@link NexusDeviceStore.update}, with the lock held. */ private transact; /** * Load this home's machine key and salt, mapping any failure other than * "missing" to `E_NEXUS_DEVICE_MACHINE_KEY` (review N5), so it is never * mistaken for a file copied from another machine. * * @param create - Whether missing material may be created (write paths only). * @returns The material, or `null` when it is missing and `create` is false. */ private material; /** * Decrypt one sealed entry and validate the plaintext. * * @throws {NexusDeviceStoreError} `E_NEXUS_DEVICE_UNSEAL_FAILED` when there * is no key material or a value fails authentication. */ private open; /** * Seal the in-memory state into the on-disk form, keeping unknown top-level * fields, and put every unopenable entry back exactly as it was read. */ private seal; /** * Read and validate the file. A missing or empty file (the seed's * placeholder) reads as an empty store. Anything else that does not parse * as a known version is refused, never read as empty, so it is never * overwritten. */ private readSealed; /** * Open the file without following links and check the open descriptor * (so nothing can be swapped between the check and the read): a regular * file with one link, owned by this user, with no group or other bits. * * @returns The contents and the file's fencing fingerprint, or `null` when * the file does not exist. */ private readPrivate; /** The remedy for a mode or owner problem, including the sudo case. */ private ownerRemedy; /** A symlink refusal for `op`. */ private symlinkError; /** * Refuse a CLEO home another user could use to replace the file: one owned * by someone else, or one inside a parent that another user owns and that * is group- or world-writable without the sticky bit. A symlinked home is * resolved first, and the checks apply to the real directory and its real * parent (review N4). A home owned by the caller that is group- or * world-writable is allowed here: reads check the open descriptor, and * {@link NexusDeviceStore.prepareDirectory} tightens it on the first write. * Skipped on Windows, where the profile ACL governs. * * @returns The real home, its stat and whether the configured path is a * symlink, or `null` when it does not exist (or on Windows). */ private assertSafeDirectory; /** * Before a write: create the CLEO home 0700 if it is missing, check it, * and tighten a home the caller owns that is group- or world-writable with * `chmod go-w` (the CLEO home is CLEO's own directory). A home reached * through a symlink is never changed: its target may be a directory CLEO * does not own the purpose of, so only a warning is given (review N4). * * @returns A one-line warning when the home was tightened or should be, else `null`. */ private prepareDirectory; /** * Write `state` atomically at 0600: a new temp file (`O_EXCL | O_NOFOLLOW`), * fsync (F_FULLFSYNC on macOS, through libuv), rename over the target, then * fsync the directory so the rename survives a crash. No backup copy is * made. On Windows a rename blocked by a scanner (EPERM, EBUSY) is retried. */ private writeAtomic; /** * Create the file owner-only, in an owner-only directory, if it does not * exist, so the lock never creates it at the default mode. `O_EXCL` plus * `O_NOFOLLOW` close the window between the symlink check and the create. */ private seed; /** * Delete temp files a crashed writer left behind. Runs under the lock, so * no live writer's temp file can match. */ private sweepTempFiles; /** * Delete any rotated backup of the file (L3). This store never makes one, * but a generic `withLock` write elsewhere would, and a backup keeps old * tokens and keys on disk. Best effort. */ private purgeBackups; } export {}; //# sourceMappingURL=nexus-device.d.ts.map