@tinycloud/operations 0.2.1-beta.0 → 0.3.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/profile.cjs CHANGED
@@ -29,12 +29,14 @@ module.exports = __toCommonJS(profile_exports);
29
29
 
30
30
  // src/state.ts
31
31
  var import_node_crypto = require("crypto");
32
+ var import_node_async_hooks = require("async_hooks");
32
33
  var import_promises = require("fs/promises");
33
34
  var import_node_os = require("os");
34
35
  var import_node_path = require("path");
35
36
  var DEFAULT_PROFILE = "default";
37
+ var invocationStateRoot = new import_node_async_hooks.AsyncLocalStorage();
36
38
  function tinycloudHomePath() {
37
- const home = process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? (0, import_node_os.homedir)();
39
+ const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? (0, import_node_os.homedir)();
38
40
  return (0, import_node_path.join)(home, ".tinycloud");
39
41
  }
40
42
  function tinycloudConfigPath() {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/profile.ts","../src/state.ts"],"sourcesContent":["import {\n DEFAULT_PROFILE,\n readJson,\n readProfile,\n tinycloudConfigPath,\n} from \"./state.js\";\nimport type {\n InvocationTarget,\n OperationContextSummary,\n OperationOperatorType,\n TinyCloudPosture,\n} from \"./contract.js\";\n\nconst DEFAULT_HOST = \"https://node.tinycloud.xyz\";\n\nexport type SafeInvocationContext = OperationContextSummary & {\n operatorType: OperationOperatorType;\n};\n\nexport type InvocationContextResolution =\n | { ok: true; context: SafeInvocationContext }\n | {\n ok: false;\n error: {\n code: \"PROFILE_NOT_FOUND\";\n message: string;\n retryable: false;\n };\n };\n\ninterface GlobalConfig {\n defaultProfile?: unknown;\n}\n\n/** Validated profile material retained only inside runtime resolution. */\nexport type StoredProfile = {\n name: string;\n host: string;\n chainId: number;\n spaceName: string;\n did: string;\n createdAt: string;\n sessionDid?: unknown;\n ownerDid?: unknown;\n spaceId?: unknown;\n posture?: unknown;\n operatorType?: unknown;\n authMethod?: unknown;\n defaultSpace?: unknown;\n privateKey?: unknown;\n address?: unknown;\n openkeyHost?: unknown;\n};\n\n/** Profile snapshot plus its derived safe invocation context. */\nexport type InvocationProfileResolution =\n | { ok: true; profile: StoredProfile; context: SafeInvocationContext }\n | Extract<InvocationContextResolution, { ok: false }>;\n\n/**\n * Resolves one selected profile to safe identity data. Resolution is deliberately\n * one-way: after selecting a name, any missing, malformed, or unreadable\n * profile is PROFILE_NOT_FOUND rather than a reason to choose another profile.\n */\nexport async function resolveInvocationContext(\n target: InvocationTarget = {},\n): Promise<InvocationContextResolution> {\n const resolved = await resolveInvocationProfile(target);\n return resolved.ok ? { ok: true, context: resolved.context } : resolved;\n}\n\n/** Resolves the selected profile and its safe context from one profile read. */\nexport async function resolveInvocationProfile(\n target: InvocationTarget = {},\n): Promise<InvocationProfileResolution> {\n const profile = await resolveProfileName(target);\n let storedProfile: StoredProfile | null;\n try {\n storedProfile = await readProfile<StoredProfile>(profile);\n } catch {\n return profileNotFound(profile);\n }\n\n if (!isStoredProfile(storedProfile)) {\n return profileNotFound(profile);\n }\n // An explicit CLI key is a complete identity override. Do not let stale\n // persisted auth posture reject that live local-owner authentication path.\n if (target.privateKey === undefined && !isCoherentProfile(storedProfile)) {\n return profileNotFound(profile);\n }\n\n const host = firstString(target.host, process.env.TC_HOST, storedProfile.host, DEFAULT_HOST);\n const posture = resolvePosture(storedProfile);\n\n return {\n ok: true,\n profile: storedProfile,\n context: {\n profile,\n host,\n posture,\n operatorType: isOperatorType(storedProfile.operatorType) ? storedProfile.operatorType : \"human\",\n principalDid: maybeDid(storedProfile.did),\n sessionDid: maybeDid(storedProfile.sessionDid),\n ownerDid: maybeDid(storedProfile.ownerDid),\n space: typeof storedProfile.spaceId === \"string\" ? storedProfile.spaceId : undefined,\n },\n };\n}\n\n/**\n * Selects the profile name once for a long-lived projection process. The\n * selected name is safe identity data; profile material is read only by the\n * invocation runtime after the projection pins this value.\n */\nexport async function resolveInvocationProfileName(\n explicitProfile?: string,\n): Promise<string> {\n return resolveProfileName(\n explicitProfile === undefined ? {} : { profile: explicitProfile },\n );\n}\n\nasync function resolveProfileName(target: InvocationTarget): Promise<string> {\n if (typeof target.profile === \"string\") return target.profile;\n if (typeof process.env.TC_PROFILE === \"string\") return process.env.TC_PROFILE;\n\n try {\n const config = await readJson<GlobalConfig>(tinycloudConfigPath());\n if (typeof config?.defaultProfile === \"string\") return config.defaultProfile;\n } catch {\n // Config only chooses the initial name. A missing or malformed config falls\n // back to the documented default, never to another existing profile.\n }\n return DEFAULT_PROFILE;\n}\n\n/** Derives the effective posture without consulting any later profile read. */\nexport function resolvePosture(profile: Pick<StoredProfile, \"posture\" | \"authMethod\">): TinyCloudPosture {\n if (isPosture(profile.posture)) return profile.posture;\n if (profile.authMethod === \"local\") return \"local-owner-key\";\n return \"owner-openkey\";\n}\n\nfunction profileNotFound(profile: string): Extract<InvocationContextResolution, { ok: false }> {\n return {\n ok: false,\n error: {\n code: \"PROFILE_NOT_FOUND\",\n message: `Profile \"${profile}\" is not available.`,\n retryable: false,\n },\n };\n}\n\nfunction firstString(...values: unknown[]): string {\n for (const value of values) {\n if (typeof value === \"string\" && value.length > 0) return value;\n }\n return DEFAULT_HOST;\n}\n\nfunction maybeDid(value: unknown): string | undefined {\n if (typeof value !== \"string\" || value.length === 0) return undefined;\n const fragment = value.indexOf(\"#\");\n return fragment === -1 ? value : value.slice(0, fragment);\n}\n\nfunction isPosture(value: unknown): value is TinyCloudPosture {\n return value === \"owner-openkey\" ||\n value === \"delegate-session\" ||\n value === \"local-owner-key\" ||\n value === \"unauthenticated\";\n}\n\nfunction isOperatorType(value: unknown): value is OperationOperatorType {\n return value === \"human\" || value === \"agent\";\n}\n\n/**\n * The CLI's original persisted profile format required these six fields.\n * Posture and operator fields arrived later, so they remain optional while\n * any present typed field must still have its documented shape.\n */\nfunction isStoredProfile(value: unknown): value is StoredProfile {\n return isRecord(value) &&\n isNonEmptyString(value.name) &&\n isNonEmptyString(value.host) &&\n typeof value.chainId === \"number\" &&\n Number.isFinite(value.chainId) &&\n isNonEmptyString(value.spaceName) &&\n isNonEmptyString(value.did) &&\n isNonEmptyString(value.createdAt) &&\n isOptionalNonEmptyString(value.sessionDid) &&\n isOptionalNonEmptyString(value.ownerDid) &&\n isOptionalNonEmptyString(value.spaceId) &&\n isOptionalPosture(value.posture) &&\n isOptionalOperatorType(value.operatorType) &&\n isOptionalAuthMethod(value.authMethod) &&\n isOptionalNonEmptyString(value.defaultSpace) &&\n isOptionalNonEmptyString(value.privateKey) &&\n isOptionalNonEmptyString(value.address) &&\n isOptionalNonEmptyString(value.openkeyHost);\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === \"string\" && value.length > 0;\n}\n\nfunction isOptionalNonEmptyString(value: unknown): boolean {\n return value === undefined || isNonEmptyString(value);\n}\n\nfunction isOptionalPosture(value: unknown): boolean {\n return value === undefined || isPosture(value);\n}\n\nfunction isOptionalOperatorType(value: unknown): boolean {\n return value === undefined || isOperatorType(value);\n}\n\nfunction isOptionalAuthMethod(value: unknown): boolean {\n return value === undefined || value === \"openkey\" || value === \"local\";\n}\n\n/**\n * Local-key authentication is owner authentication. A persisted delegate or\n * OpenKey posture must never be allowed to select that path later in runtime\n * construction, so reject the inconsistent profile before policy inspection.\n */\nfunction isCoherentProfile(profile: StoredProfile): boolean {\n if (profile.authMethod === \"local\") {\n return profile.posture === undefined || profile.posture === \"local-owner-key\";\n }\n if (profile.posture === \"local-owner-key\") {\n return profile.authMethod === \"local\";\n }\n return true;\n}\n","import { randomUUID } from \"node:crypto\";\nimport {\n mkdir,\n readFile,\n rename,\n rmdir,\n rm,\n writeFile,\n} from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join, relative, resolve } from \"node:path\";\n\nconst DEFAULT_PROFILE = \"default\";\nconst DEFAULT_LOCK_TIMEOUT_MS = 2_000;\nconst DEFAULT_LOCK_RETRY_MS = 25;\nconst DEFAULT_STALE_LOCK_MS = 30_000;\nconst TEST_LOCK_CONTENTION_SIGNAL_PATH = \"TC_TEST_PROFILE_LOCK_CONTENTION_SIGNAL_PATH\";\nconst TEST_LOCK_RECOVERY_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_RECOVERY_BARRIER_DIR\";\n\nexport type ProfileStoreName =\n | \"session\"\n | \"additional-delegations\"\n | \"auth-requests\";\n\nexport interface StoreMetadata {\n formatVersion: number;\n}\n\nexport interface ProfileStoreContents<T> {\n formatVersion: number;\n records: T[];\n}\n\nexport interface ProfileLockOptions {\n timeoutMs?: number;\n retryMs?: number;\n staleAfterMs?: number;\n}\n\nexport class ProfileLockTimeoutError extends Error {\n readonly code = \"PROFILE_LOCK_TIMEOUT\";\n\n constructor(profile: string, timeoutMs: number) {\n super(`Timed out waiting for the profile lock for \"${profile}\" after ${timeoutMs}ms.`);\n this.name = \"ProfileLockTimeoutError\";\n }\n}\n\n/**\n * The CLI's delegated-secret path treats TC_HOME as a home directory, rather\n * than a direct TinyCloud directory. Keep the shared store on that convention.\n */\nexport function tinycloudHomePath(): string {\n const home = process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();\n return join(home, \".tinycloud\");\n}\n\nexport function tinycloudConfigPath(): string {\n return join(tinycloudHomePath(), \"config.json\");\n}\n\nexport function profilesPath(): string {\n return join(tinycloudHomePath(), \"profiles\");\n}\n\nexport function profilePath(profile: string): string {\n return join(profilesPath(), validateProfileName(profile));\n}\n\nexport function profileConfigPath(profile: string): string {\n return join(profilePath(profile), \"profile.json\");\n}\n\nexport function sessionPath(profile: string): string {\n return profileStorePath(profile, \"session\");\n}\n\nexport function additionalDelegationsPath(profile: string): string {\n return profileStorePath(profile, \"additional-delegations\");\n}\n\nexport function authRequestsPath(profile: string): string {\n return profileStorePath(profile, \"auth-requests\");\n}\n\nexport function profileStorePath(profile: string, store: ProfileStoreName): string {\n return join(profilePath(profile), `${store}.json`);\n}\n\n/** The format record is deliberately separate from each legacy JSON payload. */\nexport function profileStoreMetadataPath(profile: string, store: ProfileStoreName): string {\n return `${profileStorePath(profile, store)}.metadata.json`;\n}\n\nexport function profileLockPath(profile: string): string {\n return join(profilePath(profile), \".lock\");\n}\n\nexport function profileLockMetadataPath(profile: string): string {\n return join(profileLockPath(profile), \"owner.json\");\n}\n\n/**\n * Reads JSON without hiding malformed or inaccessible files. A missing file is\n * the only condition represented as null.\n */\nexport async function readJson<T>(filePath: string): Promise<T | null> {\n try {\n return JSON.parse(await readFile(filePath, \"utf8\")) as T;\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return null;\n throw error;\n }\n}\n\n/**\n * Writes the same pretty-printed, trailing-newline JSON shape used by the CLI,\n * but publishes it with an atomic rename from the same directory.\n */\nexport async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {\n const directory = dirname(filePath);\n const temporaryPath = join(\n directory,\n `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,\n );\n const contents = `${JSON.stringify(value, null, 2)}\\n`;\n\n await mkdir(directory, { recursive: true });\n try {\n await writeFile(temporaryPath, contents, \"utf8\");\n await rename(temporaryPath, filePath);\n } catch (error) {\n await rm(temporaryPath, { force: true }).catch(() => undefined);\n throw error;\n }\n}\n\nexport async function readProfile<T extends object = Record<string, unknown>>(\n profile: string,\n): Promise<T | null> {\n return readJson<T>(profileConfigPath(profile));\n}\n\nexport async function readSession<T extends object = Record<string, unknown>>(\n profile: string,\n): Promise<T | null> {\n await readStoreMetadata(profile, \"session\");\n return readJson<T>(sessionPath(profile));\n}\n\nexport async function readAdditionalDelegations<T = Record<string, unknown>>(\n profile: string,\n): Promise<T[]> {\n return (await readProfileStore<T>(profile, \"additional-delegations\")).records;\n}\n\nexport async function readAuthRequests<T = Record<string, unknown>>(\n profile: string,\n): Promise<T[]> {\n return (await readProfileStore<T>(profile, \"auth-requests\")).records;\n}\n\nexport async function readStoreMetadata(\n profile: string,\n store: ProfileStoreName,\n): Promise<StoreMetadata> {\n const metadata = await readJson<StoreMetadata>(profileStoreMetadataPath(profile, store));\n if (metadata === null) return { formatVersion: 1 };\n if (\n typeof metadata !== \"object\" ||\n metadata.formatVersion !== 1\n ) {\n throw new TypeError(`Unsupported store format for \"${store}\".`);\n }\n return metadata;\n}\n\nexport async function readProfileStore<T>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n): Promise<ProfileStoreContents<T>> {\n const [metadata, raw] = await Promise.all([\n readStoreMetadata(profile, store),\n readJson<unknown>(profileStorePath(profile, store)),\n ]);\n return {\n formatVersion: metadata.formatVersion,\n records: Array.isArray(raw) ? raw as T[] : [],\n };\n}\n\n/**\n * Runs a small critical section under the one advisory lock shared by all\n * profile stores. Lock release verifies its ownership token before removing\n * the exact metadata instance it acquired.\n */\nexport async function withProfileLock<T>(\n profile: string,\n action: () => Promise<T>,\n options: ProfileLockOptions = {},\n): Promise<T> {\n const normalizedProfile = validateProfileName(profile);\n const release = await acquireProfileLock(normalizedProfile, options);\n try {\n return await action();\n } finally {\n await release();\n }\n}\n\n/**\n * Appends a record or replaces the existing record with the supplied explicit\n * key. The extractor is supplied by the owning store so state.ts never needs\n * to know permission-request or delegation record shapes.\n */\nexport async function upsertProfileRecord<T>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n key: string,\n record: T,\n getKey: (candidate: T) => string | undefined,\n options: ProfileLockOptions = {},\n): Promise<T[]> {\n if (!key) throw new TypeError(\"A non-empty record key is required.\");\n\n return withProfileLock(profile, async () => {\n const current = (await readProfileStore<T>(profile, store)).records;\n const next = current.filter((candidate) => getKey(candidate) !== key);\n next.push(record);\n await writeJsonAtomic(profileStorePath(profile, store), next);\n await writeFormatOneMetadata(profile, store);\n return next;\n }, options);\n}\n\n/**\n * Performs a typed read-modify-write of a legacy array store under the one\n * per-profile lock. The payload stays an array and the sibling metadata stays\n * format 1, so existing CLI readers keep their byte/layout contract.\n */\nexport async function updateProfileStore<T, Result>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n update: (\n records: readonly T[],\n ) => Promise<{ readonly records: readonly T[]; readonly result: Result }> | {\n readonly records: readonly T[];\n readonly result: Result;\n },\n options: ProfileLockOptions = {},\n): Promise<Result> {\n return withProfileLock(\n profile,\n () => updateProfileStoreWhileLocked(profile, store, update),\n options,\n );\n}\n\n/** The caller must already own this profile's lock. */\nasync function updateProfileStoreWhileLocked<T, Result>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n update: (\n records: readonly T[],\n ) => Promise<{ readonly records: readonly T[]; readonly result: Result }> | {\n readonly records: readonly T[];\n readonly result: Result;\n },\n): Promise<Result> {\n const current = await readProfileStore<T>(profile, store);\n const next = await update(current.records);\n await writeJsonAtomic(profileStorePath(profile, store), next.records);\n await writeFormatOneMetadata(profile, store);\n return next.result;\n}\n\nexport async function writeSession<T extends object>(\n profile: string,\n session: T,\n options: ProfileLockOptions = {},\n): Promise<void> {\n await withProfileLock(profile, async () => {\n await readStoreMetadata(profile, \"session\");\n await writeJsonAtomic(sessionPath(profile), session);\n await writeFormatOneMetadata(profile, \"session\");\n }, options);\n}\n\nexport async function removeSession(\n profile: string,\n options: ProfileLockOptions = {},\n): Promise<void> {\n await withProfileLock(profile, async () => {\n await readStoreMetadata(profile, \"session\");\n await rm(sessionPath(profile), { force: true });\n }, options);\n}\n\nasync function writeFormatOneMetadata(profile: string, store: ProfileStoreName): Promise<void> {\n await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });\n}\n\nasync function acquireProfileLock(\n profile: string,\n options: ProfileLockOptions,\n): Promise<() => Promise<void>> {\n const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;\n const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;\n const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;\n const startedAt = Date.now();\n const lockPath = profileLockPath(profile);\n\n await mkdir(profilePath(profile), { recursive: true });\n\n while (true) {\n try {\n await mkdir(lockPath);\n const token = randomUUID();\n try {\n await writeJsonAtomic(profileLockMetadataPath(profile), {\n pid: process.pid,\n createdAt: new Date().toISOString(),\n token,\n });\n } catch (error) {\n await rmdir(lockPath).catch(() => undefined);\n throw error;\n }\n\n return async () => {\n await releaseProfileLock(lockPath, token);\n };\n } catch (error) {\n if (!isLockAlreadyHeld(error)) throw error;\n await signalTestLockContention(profile);\n }\n\n if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;\n\n const elapsedMs = Date.now() - startedAt;\n if (elapsedMs >= timeoutMs) {\n throw new ProfileLockTimeoutError(profile, timeoutMs);\n }\n await sleep(Math.min(retryMs, timeoutMs - elapsedMs));\n }\n}\n\nasync function releaseProfileLock(lockPath: string, token: string): Promise<void> {\n const ownerPath = join(lockPath, \"owner.json\");\n const claimPath = join(lockPath, `.release-${token}.json`);\n try {\n await rename(ownerPath, claimPath);\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return;\n throw error;\n }\n\n const claimed = await readJson<{ token?: unknown }>(claimPath).catch(() => null);\n if (claimed?.token !== token) {\n await rename(claimPath, ownerPath).catch(() => undefined);\n return;\n }\n\n await rm(claimPath, { force: true });\n await rmdir(lockPath).catch(() => undefined);\n}\n\n/**\n * Gives process-level contention tests an event-driven witness that a second\n * writer has reached an already-held lock. This deliberately has no effect\n * outside an explicit test environment, and only creates a new signal file\n * beneath TC_HOME; it never changes lock acquisition or release semantics.\n */\nasync function signalTestLockContention(profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n\n const configuredPath = process.env[TEST_LOCK_CONTENTION_SIGNAL_PATH];\n const configuredHome = process.env.TC_HOME;\n if (!configuredPath || !configuredHome) return;\n\n const home = resolve(configuredHome);\n const signalPath = resolve(configuredPath);\n const pathFromHome = relative(home, signalPath);\n if (\n pathFromHome === \"\" ||\n pathFromHome === \"..\" ||\n pathFromHome.startsWith(`..${process.platform === \"win32\" ? \"\\\\\" : \"/\"}`) ||\n pathFromHome.startsWith(\"../\") ||\n pathFromHome.startsWith(\"..\\\\\")\n ) {\n return;\n }\n\n await writeFile(signalPath, `${profile}\\n`, { encoding: \"utf8\", flag: \"wx\" })\n .catch(() => undefined);\n}\n\nasync function recoverStaleLock(profile: string, lockPath: string, staleAfterMs: number): Promise<boolean> {\n const ownerPath = join(lockPath, \"owner.json\");\n const owner = await readJson<{ pid?: unknown; createdAt?: unknown; token?: unknown }>(ownerPath)\n .catch(() => null);\n if (!isStaleOwner(owner, staleAfterMs)) return false;\n\n // Claim the observed metadata file, rather than renaming/removing the lock\n // directory. A contender can only acquire the directory after it is empty;\n // rmdir below therefore cannot remove a replacement lock instance.\n const observedToken = typeof owner?.token === \"string\" && owner.token.length > 0\n ? owner.token\n : \"legacy\";\n await waitForTestStaleRecoveryBarrier(profile);\n const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);\n try {\n await rename(ownerPath, claimPath);\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return false;\n return false;\n }\n\n const claimed = await readJson<{ pid?: unknown; createdAt?: unknown; token?: unknown }>(claimPath)\n .catch(() => null);\n const sameInstance = claimed !== null && (\n observedToken === \"legacy\"\n ? claimed.token === undefined\n : claimed.token === observedToken\n );\n if (!sameInstance) {\n await rename(claimPath, ownerPath).catch(() => undefined);\n return false;\n }\n\n await rm(claimPath, { force: true });\n await rmdir(lockPath).catch(() => undefined);\n return true;\n}\n\nasync function waitForTestStaleRecoveryBarrier(profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n const barrierDirectory = process.env[TEST_LOCK_RECOVERY_BARRIER_DIR];\n if (!barrierDirectory) return;\n await mkdir(barrierDirectory, { recursive: true });\n const readyPath = join(barrierDirectory, `ready-${process.pid}-${profile}`);\n await writeFile(readyPath, \"ready\\n\", { encoding: \"utf8\", flag: \"wx\" }).catch(() => undefined);\n const releasePath = join(barrierDirectory, \"release\");\n while (true) {\n try {\n await readFile(releasePath, \"utf8\");\n return;\n } catch {\n await sleep(1);\n }\n }\n}\n\nfunction isStaleOwner(\n owner: { pid?: unknown; createdAt?: unknown } | null,\n staleAfterMs: number,\n): boolean {\n const now = Date.now();\n const createdAt = typeof owner?.createdAt === \"string\"\n ? Date.parse(owner.createdAt)\n : Number.NaN;\n\n // Reclaim only a fully published lock whose owner PID is confirmed dead.\n // An ownerless or malformed directory may still be between mkdir and\n // metadata publication, so reclaiming it could let two writers enter the\n // critical section. Current writers claim .lock with exclusive mkdir before\n // publishing owner metadata and therefore take the same conservative path.\n if (!Number.isFinite(createdAt) || now - createdAt < staleAfterMs) return false;\n if (typeof owner?.pid !== \"number\" || !Number.isInteger(owner.pid) || owner.pid <= 0) {\n return false;\n }\n return !isProcessAlive(owner.pid);\n}\n\nfunction isLockAlreadyHeld(error: unknown): boolean {\n return isErrno(error, \"EEXIST\") || isErrno(error, \"ENOTEMPTY\");\n}\n\nfunction isProcessAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return !isErrno(error, \"ESRCH\");\n }\n}\n\nfunction validateProfileName(profile: string): string {\n if (\n !profile ||\n profile === \".\" ||\n profile === \"..\" ||\n profile.includes(\"/\") ||\n profile.includes(\"\\\\\") ||\n profile.includes(\"\\0\")\n ) {\n throw new TypeError(\"Profile names must be non-empty path segments.\");\n }\n return profile;\n}\n\nfunction isErrno(error: unknown, code: string): boolean {\n return typeof error === \"object\" && error !== null && \"code\" in error &&\n (error as { code?: unknown }).code === code;\n}\n\nfunction sleep(milliseconds: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, milliseconds));\n}\n\nexport { DEFAULT_PROFILE };\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/profile.ts","../src/state.ts"],"sourcesContent":["import {\n DEFAULT_PROFILE,\n readJson,\n readProfile,\n tinycloudConfigPath,\n} from \"./state.js\";\nimport type {\n InvocationTarget,\n OperationContextSummary,\n OperationOperatorType,\n TinyCloudPosture,\n} from \"./contract.js\";\n\nconst DEFAULT_HOST = \"https://node.tinycloud.xyz\";\n\nexport type SafeInvocationContext = OperationContextSummary & {\n operatorType: OperationOperatorType;\n};\n\nexport type InvocationContextResolution =\n | { ok: true; context: SafeInvocationContext }\n | {\n ok: false;\n error: {\n code: \"PROFILE_NOT_FOUND\";\n message: string;\n retryable: false;\n };\n };\n\ninterface GlobalConfig {\n defaultProfile?: unknown;\n}\n\n/** Validated profile material retained only inside runtime resolution. */\nexport type StoredProfile = {\n name: string;\n host: string;\n chainId: number;\n spaceName: string;\n did: string;\n createdAt: string;\n sessionDid?: unknown;\n ownerDid?: unknown;\n spaceId?: unknown;\n posture?: unknown;\n operatorType?: unknown;\n authMethod?: unknown;\n defaultSpace?: unknown;\n privateKey?: unknown;\n address?: unknown;\n openkeyHost?: unknown;\n};\n\n/** Profile snapshot plus its derived safe invocation context. */\nexport type InvocationProfileResolution =\n | { ok: true; profile: StoredProfile; context: SafeInvocationContext }\n | Extract<InvocationContextResolution, { ok: false }>;\n\n/**\n * Resolves one selected profile to safe identity data. Resolution is deliberately\n * one-way: after selecting a name, any missing, malformed, or unreadable\n * profile is PROFILE_NOT_FOUND rather than a reason to choose another profile.\n */\nexport async function resolveInvocationContext(\n target: InvocationTarget = {},\n): Promise<InvocationContextResolution> {\n const resolved = await resolveInvocationProfile(target);\n return resolved.ok ? { ok: true, context: resolved.context } : resolved;\n}\n\n/** Resolves the selected profile and its safe context from one profile read. */\nexport async function resolveInvocationProfile(\n target: InvocationTarget = {},\n): Promise<InvocationProfileResolution> {\n const profile = await resolveProfileName(target);\n let storedProfile: StoredProfile | null;\n try {\n storedProfile = await readProfile<StoredProfile>(profile);\n } catch {\n return profileNotFound(profile);\n }\n\n if (!isStoredProfile(storedProfile)) {\n return profileNotFound(profile);\n }\n // An explicit CLI key is a complete identity override. Do not let stale\n // persisted auth posture reject that live local-owner authentication path.\n if (target.privateKey === undefined && !isCoherentProfile(storedProfile)) {\n return profileNotFound(profile);\n }\n\n const host = firstString(target.host, process.env.TC_HOST, storedProfile.host, DEFAULT_HOST);\n const posture = resolvePosture(storedProfile);\n\n return {\n ok: true,\n profile: storedProfile,\n context: {\n profile,\n host,\n posture,\n operatorType: isOperatorType(storedProfile.operatorType) ? storedProfile.operatorType : \"human\",\n principalDid: maybeDid(storedProfile.did),\n sessionDid: maybeDid(storedProfile.sessionDid),\n ownerDid: maybeDid(storedProfile.ownerDid),\n space: typeof storedProfile.spaceId === \"string\" ? storedProfile.spaceId : undefined,\n },\n };\n}\n\n/**\n * Selects the profile name once for a long-lived projection process. The\n * selected name is safe identity data; profile material is read only by the\n * invocation runtime after the projection pins this value.\n */\nexport async function resolveInvocationProfileName(\n explicitProfile?: string,\n): Promise<string> {\n return resolveProfileName(\n explicitProfile === undefined ? {} : { profile: explicitProfile },\n );\n}\n\nasync function resolveProfileName(target: InvocationTarget): Promise<string> {\n if (typeof target.profile === \"string\") return target.profile;\n if (typeof process.env.TC_PROFILE === \"string\") return process.env.TC_PROFILE;\n\n try {\n const config = await readJson<GlobalConfig>(tinycloudConfigPath());\n if (typeof config?.defaultProfile === \"string\") return config.defaultProfile;\n } catch {\n // Config only chooses the initial name. A missing or malformed config falls\n // back to the documented default, never to another existing profile.\n }\n return DEFAULT_PROFILE;\n}\n\n/** Derives the effective posture without consulting any later profile read. */\nexport function resolvePosture(profile: Pick<StoredProfile, \"posture\" | \"authMethod\">): TinyCloudPosture {\n if (isPosture(profile.posture)) return profile.posture;\n if (profile.authMethod === \"local\") return \"local-owner-key\";\n return \"owner-openkey\";\n}\n\nfunction profileNotFound(profile: string): Extract<InvocationContextResolution, { ok: false }> {\n return {\n ok: false,\n error: {\n code: \"PROFILE_NOT_FOUND\",\n message: `Profile \"${profile}\" is not available.`,\n retryable: false,\n },\n };\n}\n\nfunction firstString(...values: unknown[]): string {\n for (const value of values) {\n if (typeof value === \"string\" && value.length > 0) return value;\n }\n return DEFAULT_HOST;\n}\n\nfunction maybeDid(value: unknown): string | undefined {\n if (typeof value !== \"string\" || value.length === 0) return undefined;\n const fragment = value.indexOf(\"#\");\n return fragment === -1 ? value : value.slice(0, fragment);\n}\n\nfunction isPosture(value: unknown): value is TinyCloudPosture {\n return value === \"owner-openkey\" ||\n value === \"delegate-session\" ||\n value === \"local-owner-key\" ||\n value === \"unauthenticated\";\n}\n\nfunction isOperatorType(value: unknown): value is OperationOperatorType {\n return value === \"human\" || value === \"agent\";\n}\n\n/**\n * The CLI's original persisted profile format required these six fields.\n * Posture and operator fields arrived later, so they remain optional while\n * any present typed field must still have its documented shape.\n */\nfunction isStoredProfile(value: unknown): value is StoredProfile {\n return isRecord(value) &&\n isNonEmptyString(value.name) &&\n isNonEmptyString(value.host) &&\n typeof value.chainId === \"number\" &&\n Number.isFinite(value.chainId) &&\n isNonEmptyString(value.spaceName) &&\n isNonEmptyString(value.did) &&\n isNonEmptyString(value.createdAt) &&\n isOptionalNonEmptyString(value.sessionDid) &&\n isOptionalNonEmptyString(value.ownerDid) &&\n isOptionalNonEmptyString(value.spaceId) &&\n isOptionalPosture(value.posture) &&\n isOptionalOperatorType(value.operatorType) &&\n isOptionalAuthMethod(value.authMethod) &&\n isOptionalNonEmptyString(value.defaultSpace) &&\n isOptionalNonEmptyString(value.privateKey) &&\n isOptionalNonEmptyString(value.address) &&\n isOptionalNonEmptyString(value.openkeyHost);\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === \"string\" && value.length > 0;\n}\n\nfunction isOptionalNonEmptyString(value: unknown): boolean {\n return value === undefined || isNonEmptyString(value);\n}\n\nfunction isOptionalPosture(value: unknown): boolean {\n return value === undefined || isPosture(value);\n}\n\nfunction isOptionalOperatorType(value: unknown): boolean {\n return value === undefined || isOperatorType(value);\n}\n\nfunction isOptionalAuthMethod(value: unknown): boolean {\n return value === undefined || value === \"openkey\" || value === \"local\";\n}\n\n/**\n * Local-key authentication is owner authentication. A persisted delegate or\n * OpenKey posture must never be allowed to select that path later in runtime\n * construction, so reject the inconsistent profile before policy inspection.\n */\nfunction isCoherentProfile(profile: StoredProfile): boolean {\n if (profile.authMethod === \"local\") {\n return profile.posture === undefined || profile.posture === \"local-owner-key\";\n }\n if (profile.posture === \"local-owner-key\") {\n return profile.authMethod === \"local\";\n }\n return true;\n}\n","import { randomUUID } from \"node:crypto\";\nimport { AsyncLocalStorage } from \"node:async_hooks\";\nimport {\n mkdir,\n readFile,\n rename,\n rmdir,\n rm,\n writeFile,\n} from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, isAbsolute, join, relative, resolve } from \"node:path\";\n\nconst DEFAULT_PROFILE = \"default\";\nconst DEFAULT_LOCK_TIMEOUT_MS = 2_000;\nconst DEFAULT_LOCK_RETRY_MS = 25;\nconst DEFAULT_STALE_LOCK_MS = 30_000;\nconst TEST_LOCK_CONTENTION_SIGNAL_PATH = \"TC_TEST_PROFILE_LOCK_CONTENTION_SIGNAL_PATH\";\nconst TEST_LOCK_RECOVERY_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_RECOVERY_BARRIER_DIR\";\nconst invocationStateRoot = new AsyncLocalStorage<string>();\n\nexport type ProfileStoreName =\n | \"session\"\n | \"additional-delegations\"\n | \"auth-requests\";\n\nexport interface StoreMetadata {\n formatVersion: number;\n}\n\nexport interface ProfileStoreContents<T> {\n formatVersion: number;\n records: T[];\n}\n\nexport interface ProfileLockOptions {\n timeoutMs?: number;\n retryMs?: number;\n staleAfterMs?: number;\n}\n\nexport class ProfileLockTimeoutError extends Error {\n readonly code = \"PROFILE_LOCK_TIMEOUT\";\n\n constructor(profile: string, timeoutMs: number) {\n super(`Timed out waiting for the profile lock for \"${profile}\" after ${timeoutMs}ms.`);\n this.name = \"ProfileLockTimeoutError\";\n }\n}\n\n/**\n * The CLI's delegated-secret path treats TC_HOME as a home directory, rather\n * than a direct TinyCloud directory. Keep the shared store on that convention.\n */\nexport function tinycloudHomePath(): string {\n const home = invocationStateRoot.getStore() ??\n process.env.TC_HOME ??\n process.env.HOME ??\n process.env.USERPROFILE ??\n homedir();\n return join(home, \".tinycloud\");\n}\n\n/** Run one operation against an isolated TinyCloud home directory. */\nexport function withTinyCloudStateRoot<T>(\n stateRoot: string | undefined,\n action: () => Promise<T>,\n): Promise<T> {\n if (stateRoot === undefined) return action();\n if (!isAbsolute(stateRoot) || stateRoot.includes(\"\\0\")) {\n throw new TypeError(\"The TinyCloud state root must be an absolute path.\");\n }\n return invocationStateRoot.run(resolve(stateRoot), action);\n}\n\nexport function tinycloudConfigPath(): string {\n return join(tinycloudHomePath(), \"config.json\");\n}\n\nexport function profilesPath(): string {\n return join(tinycloudHomePath(), \"profiles\");\n}\n\nexport function profilePath(profile: string): string {\n return join(profilesPath(), validateProfileName(profile));\n}\n\nexport function profileConfigPath(profile: string): string {\n return join(profilePath(profile), \"profile.json\");\n}\n\nexport function sessionPath(profile: string): string {\n return profileStorePath(profile, \"session\");\n}\n\nexport function additionalDelegationsPath(profile: string): string {\n return profileStorePath(profile, \"additional-delegations\");\n}\n\nexport function authRequestsPath(profile: string): string {\n return profileStorePath(profile, \"auth-requests\");\n}\n\nexport function profileStorePath(profile: string, store: ProfileStoreName): string {\n return join(profilePath(profile), `${store}.json`);\n}\n\n/** The format record is deliberately separate from each legacy JSON payload. */\nexport function profileStoreMetadataPath(profile: string, store: ProfileStoreName): string {\n return `${profileStorePath(profile, store)}.metadata.json`;\n}\n\nexport function profileLockPath(profile: string): string {\n return join(profilePath(profile), \".lock\");\n}\n\nexport function profileLockMetadataPath(profile: string): string {\n return join(profileLockPath(profile), \"owner.json\");\n}\n\n/**\n * Reads JSON without hiding malformed or inaccessible files. A missing file is\n * the only condition represented as null.\n */\nexport async function readJson<T>(filePath: string): Promise<T | null> {\n try {\n return JSON.parse(await readFile(filePath, \"utf8\")) as T;\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return null;\n throw error;\n }\n}\n\n/**\n * Writes the same pretty-printed, trailing-newline JSON shape used by the CLI,\n * but publishes it with an atomic rename from the same directory.\n */\nexport async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {\n const directory = dirname(filePath);\n const temporaryPath = join(\n directory,\n `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,\n );\n const contents = `${JSON.stringify(value, null, 2)}\\n`;\n\n await mkdir(directory, { recursive: true });\n try {\n await writeFile(temporaryPath, contents, \"utf8\");\n await rename(temporaryPath, filePath);\n } catch (error) {\n await rm(temporaryPath, { force: true }).catch(() => undefined);\n throw error;\n }\n}\n\nexport async function readProfile<T extends object = Record<string, unknown>>(\n profile: string,\n): Promise<T | null> {\n return readJson<T>(profileConfigPath(profile));\n}\n\nexport async function readSession<T extends object = Record<string, unknown>>(\n profile: string,\n): Promise<T | null> {\n await readStoreMetadata(profile, \"session\");\n return readJson<T>(sessionPath(profile));\n}\n\nexport async function readAdditionalDelegations<T = Record<string, unknown>>(\n profile: string,\n): Promise<T[]> {\n return (await readProfileStore<T>(profile, \"additional-delegations\")).records;\n}\n\nexport async function readAuthRequests<T = Record<string, unknown>>(\n profile: string,\n): Promise<T[]> {\n return (await readProfileStore<T>(profile, \"auth-requests\")).records;\n}\n\nexport async function readStoreMetadata(\n profile: string,\n store: ProfileStoreName,\n): Promise<StoreMetadata> {\n const metadata = await readJson<StoreMetadata>(profileStoreMetadataPath(profile, store));\n if (metadata === null) return { formatVersion: 1 };\n if (\n typeof metadata !== \"object\" ||\n metadata.formatVersion !== 1\n ) {\n throw new TypeError(`Unsupported store format for \"${store}\".`);\n }\n return metadata;\n}\n\nexport async function readProfileStore<T>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n): Promise<ProfileStoreContents<T>> {\n const [metadata, raw] = await Promise.all([\n readStoreMetadata(profile, store),\n readJson<unknown>(profileStorePath(profile, store)),\n ]);\n return {\n formatVersion: metadata.formatVersion,\n records: Array.isArray(raw) ? raw as T[] : [],\n };\n}\n\n/**\n * Runs a small critical section under the one advisory lock shared by all\n * profile stores. Lock release verifies its ownership token before removing\n * the exact metadata instance it acquired.\n */\nexport async function withProfileLock<T>(\n profile: string,\n action: () => Promise<T>,\n options: ProfileLockOptions = {},\n): Promise<T> {\n const normalizedProfile = validateProfileName(profile);\n const release = await acquireProfileLock(normalizedProfile, options);\n try {\n return await action();\n } finally {\n await release();\n }\n}\n\n/**\n * Appends a record or replaces the existing record with the supplied explicit\n * key. The extractor is supplied by the owning store so state.ts never needs\n * to know permission-request or delegation record shapes.\n */\nexport async function upsertProfileRecord<T>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n key: string,\n record: T,\n getKey: (candidate: T) => string | undefined,\n options: ProfileLockOptions = {},\n): Promise<T[]> {\n if (!key) throw new TypeError(\"A non-empty record key is required.\");\n\n return withProfileLock(profile, async () => {\n const current = (await readProfileStore<T>(profile, store)).records;\n const next = current.filter((candidate) => getKey(candidate) !== key);\n next.push(record);\n await writeJsonAtomic(profileStorePath(profile, store), next);\n await writeFormatOneMetadata(profile, store);\n return next;\n }, options);\n}\n\n/**\n * Performs a typed read-modify-write of a legacy array store under the one\n * per-profile lock. The payload stays an array and the sibling metadata stays\n * format 1, so existing CLI readers keep their byte/layout contract.\n */\nexport async function updateProfileStore<T, Result>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n update: (\n records: readonly T[],\n ) => Promise<{ readonly records: readonly T[]; readonly result: Result }> | {\n readonly records: readonly T[];\n readonly result: Result;\n },\n options: ProfileLockOptions = {},\n): Promise<Result> {\n return withProfileLock(\n profile,\n () => updateProfileStoreWhileLocked(profile, store, update),\n options,\n );\n}\n\n/** The caller must already own this profile's lock. */\nasync function updateProfileStoreWhileLocked<T, Result>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n update: (\n records: readonly T[],\n ) => Promise<{ readonly records: readonly T[]; readonly result: Result }> | {\n readonly records: readonly T[];\n readonly result: Result;\n },\n): Promise<Result> {\n const current = await readProfileStore<T>(profile, store);\n const next = await update(current.records);\n await writeJsonAtomic(profileStorePath(profile, store), next.records);\n await writeFormatOneMetadata(profile, store);\n return next.result;\n}\n\nexport async function writeSession<T extends object>(\n profile: string,\n session: T,\n options: ProfileLockOptions = {},\n): Promise<void> {\n await withProfileLock(profile, async () => {\n await readStoreMetadata(profile, \"session\");\n await writeJsonAtomic(sessionPath(profile), session);\n await writeFormatOneMetadata(profile, \"session\");\n }, options);\n}\n\nexport async function removeSession(\n profile: string,\n options: ProfileLockOptions = {},\n): Promise<void> {\n await withProfileLock(profile, async () => {\n await readStoreMetadata(profile, \"session\");\n await rm(sessionPath(profile), { force: true });\n }, options);\n}\n\nasync function writeFormatOneMetadata(profile: string, store: ProfileStoreName): Promise<void> {\n await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });\n}\n\nasync function acquireProfileLock(\n profile: string,\n options: ProfileLockOptions,\n): Promise<() => Promise<void>> {\n const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;\n const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;\n const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;\n const startedAt = Date.now();\n const lockPath = profileLockPath(profile);\n\n await mkdir(profilePath(profile), { recursive: true });\n\n while (true) {\n try {\n await mkdir(lockPath);\n const token = randomUUID();\n try {\n await writeJsonAtomic(profileLockMetadataPath(profile), {\n pid: process.pid,\n createdAt: new Date().toISOString(),\n token,\n });\n } catch (error) {\n await rmdir(lockPath).catch(() => undefined);\n throw error;\n }\n\n return async () => {\n await releaseProfileLock(lockPath, token);\n };\n } catch (error) {\n if (!isLockAlreadyHeld(error)) throw error;\n await signalTestLockContention(profile);\n }\n\n if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;\n\n const elapsedMs = Date.now() - startedAt;\n if (elapsedMs >= timeoutMs) {\n throw new ProfileLockTimeoutError(profile, timeoutMs);\n }\n await sleep(Math.min(retryMs, timeoutMs - elapsedMs));\n }\n}\n\nasync function releaseProfileLock(lockPath: string, token: string): Promise<void> {\n const ownerPath = join(lockPath, \"owner.json\");\n const claimPath = join(lockPath, `.release-${token}.json`);\n try {\n await rename(ownerPath, claimPath);\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return;\n throw error;\n }\n\n const claimed = await readJson<{ token?: unknown }>(claimPath).catch(() => null);\n if (claimed?.token !== token) {\n await rename(claimPath, ownerPath).catch(() => undefined);\n return;\n }\n\n await rm(claimPath, { force: true });\n await rmdir(lockPath).catch(() => undefined);\n}\n\n/**\n * Gives process-level contention tests an event-driven witness that a second\n * writer has reached an already-held lock. This deliberately has no effect\n * outside an explicit test environment, and only creates a new signal file\n * beneath TC_HOME; it never changes lock acquisition or release semantics.\n */\nasync function signalTestLockContention(profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n\n const configuredPath = process.env[TEST_LOCK_CONTENTION_SIGNAL_PATH];\n const configuredHome = process.env.TC_HOME;\n if (!configuredPath || !configuredHome) return;\n\n const home = resolve(configuredHome);\n const signalPath = resolve(configuredPath);\n const pathFromHome = relative(home, signalPath);\n if (\n pathFromHome === \"\" ||\n pathFromHome === \"..\" ||\n pathFromHome.startsWith(`..${process.platform === \"win32\" ? \"\\\\\" : \"/\"}`) ||\n pathFromHome.startsWith(\"../\") ||\n pathFromHome.startsWith(\"..\\\\\")\n ) {\n return;\n }\n\n await writeFile(signalPath, `${profile}\\n`, { encoding: \"utf8\", flag: \"wx\" })\n .catch(() => undefined);\n}\n\nasync function recoverStaleLock(profile: string, lockPath: string, staleAfterMs: number): Promise<boolean> {\n const ownerPath = join(lockPath, \"owner.json\");\n const owner = await readJson<{ pid?: unknown; createdAt?: unknown; token?: unknown }>(ownerPath)\n .catch(() => null);\n if (!isStaleOwner(owner, staleAfterMs)) return false;\n\n // Claim the observed metadata file, rather than renaming/removing the lock\n // directory. A contender can only acquire the directory after it is empty;\n // rmdir below therefore cannot remove a replacement lock instance.\n const observedToken = typeof owner?.token === \"string\" && owner.token.length > 0\n ? owner.token\n : \"legacy\";\n await waitForTestStaleRecoveryBarrier(profile);\n const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);\n try {\n await rename(ownerPath, claimPath);\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return false;\n return false;\n }\n\n const claimed = await readJson<{ pid?: unknown; createdAt?: unknown; token?: unknown }>(claimPath)\n .catch(() => null);\n const sameInstance = claimed !== null && (\n observedToken === \"legacy\"\n ? claimed.token === undefined\n : claimed.token === observedToken\n );\n if (!sameInstance) {\n await rename(claimPath, ownerPath).catch(() => undefined);\n return false;\n }\n\n await rm(claimPath, { force: true });\n await rmdir(lockPath).catch(() => undefined);\n return true;\n}\n\nasync function waitForTestStaleRecoveryBarrier(profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n const barrierDirectory = process.env[TEST_LOCK_RECOVERY_BARRIER_DIR];\n if (!barrierDirectory) return;\n await mkdir(barrierDirectory, { recursive: true });\n const readyPath = join(barrierDirectory, `ready-${process.pid}-${profile}`);\n await writeFile(readyPath, \"ready\\n\", { encoding: \"utf8\", flag: \"wx\" }).catch(() => undefined);\n const releasePath = join(barrierDirectory, \"release\");\n while (true) {\n try {\n await readFile(releasePath, \"utf8\");\n return;\n } catch {\n await sleep(1);\n }\n }\n}\n\nfunction isStaleOwner(\n owner: { pid?: unknown; createdAt?: unknown } | null,\n staleAfterMs: number,\n): boolean {\n const now = Date.now();\n const createdAt = typeof owner?.createdAt === \"string\"\n ? Date.parse(owner.createdAt)\n : Number.NaN;\n\n // Reclaim only a fully published lock whose owner PID is confirmed dead.\n // An ownerless or malformed directory may still be between mkdir and\n // metadata publication, so reclaiming it could let two writers enter the\n // critical section. Current writers claim .lock with exclusive mkdir before\n // publishing owner metadata and therefore take the same conservative path.\n if (!Number.isFinite(createdAt) || now - createdAt < staleAfterMs) return false;\n if (typeof owner?.pid !== \"number\" || !Number.isInteger(owner.pid) || owner.pid <= 0) {\n return false;\n }\n return !isProcessAlive(owner.pid);\n}\n\nfunction isLockAlreadyHeld(error: unknown): boolean {\n return isErrno(error, \"EEXIST\") || isErrno(error, \"ENOTEMPTY\");\n}\n\nfunction isProcessAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return !isErrno(error, \"ESRCH\");\n }\n}\n\nfunction validateProfileName(profile: string): string {\n if (\n !profile ||\n profile === \".\" ||\n profile === \"..\" ||\n profile.includes(\"/\") ||\n profile.includes(\"\\\\\") ||\n profile.includes(\"\\0\")\n ) {\n throw new TypeError(\"Profile names must be non-empty path segments.\");\n }\n return profile;\n}\n\nfunction isErrno(error: unknown, code: string): boolean {\n return typeof error === \"object\" && error !== null && \"code\" in error &&\n (error as { code?: unknown }).code === code;\n}\n\nfunction sleep(milliseconds: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, milliseconds));\n}\n\nexport { DEFAULT_PROFILE };\n"],"mappings":"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","names":[]}
@@ -1,4 +1,4 @@
1
- import { b as OperationContextSummary, O as OperationOperatorType, I as InvocationTarget, T as TinyCloudPosture } from './contract-OnWT3OXa.cjs';
1
+ import { b as OperationContextSummary, O as OperationOperatorType, I as InvocationTarget, T as TinyCloudPosture } from './contract-xMIIMtpF.cjs';
2
2
 
3
3
  type SafeInvocationContext = OperationContextSummary & {
4
4
  operatorType: OperationOperatorType;
package/dist/profile.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { b as OperationContextSummary, O as OperationOperatorType, I as InvocationTarget, T as TinyCloudPosture } from './contract-OnWT3OXa.js';
1
+ import { b as OperationContextSummary, O as OperationOperatorType, I as InvocationTarget, T as TinyCloudPosture } from './contract-xMIIMtpF.js';
2
2
 
3
3
  type SafeInvocationContext = OperationContextSummary & {
4
4
  operatorType: OperationOperatorType;
package/dist/profile.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/state.ts
2
2
  import { randomUUID } from "crypto";
3
+ import { AsyncLocalStorage } from "async_hooks";
3
4
  import {
4
5
  mkdir,
5
6
  readFile,
@@ -9,10 +10,11 @@ import {
9
10
  writeFile
10
11
  } from "fs/promises";
11
12
  import { homedir } from "os";
12
- import { basename, dirname, join, relative, resolve } from "path";
13
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
13
14
  var DEFAULT_PROFILE = "default";
15
+ var invocationStateRoot = new AsyncLocalStorage();
14
16
  function tinycloudHomePath() {
15
- const home = process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
17
+ const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
16
18
  return join(home, ".tinycloud");
17
19
  }
18
20
  function tinycloudConfigPath() {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/state.ts","../src/profile.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport {\n mkdir,\n readFile,\n rename,\n rmdir,\n rm,\n writeFile,\n} from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join, relative, resolve } from \"node:path\";\n\nconst DEFAULT_PROFILE = \"default\";\nconst DEFAULT_LOCK_TIMEOUT_MS = 2_000;\nconst DEFAULT_LOCK_RETRY_MS = 25;\nconst DEFAULT_STALE_LOCK_MS = 30_000;\nconst TEST_LOCK_CONTENTION_SIGNAL_PATH = \"TC_TEST_PROFILE_LOCK_CONTENTION_SIGNAL_PATH\";\nconst TEST_LOCK_RECOVERY_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_RECOVERY_BARRIER_DIR\";\n\nexport type ProfileStoreName =\n | \"session\"\n | \"additional-delegations\"\n | \"auth-requests\";\n\nexport interface StoreMetadata {\n formatVersion: number;\n}\n\nexport interface ProfileStoreContents<T> {\n formatVersion: number;\n records: T[];\n}\n\nexport interface ProfileLockOptions {\n timeoutMs?: number;\n retryMs?: number;\n staleAfterMs?: number;\n}\n\nexport class ProfileLockTimeoutError extends Error {\n readonly code = \"PROFILE_LOCK_TIMEOUT\";\n\n constructor(profile: string, timeoutMs: number) {\n super(`Timed out waiting for the profile lock for \"${profile}\" after ${timeoutMs}ms.`);\n this.name = \"ProfileLockTimeoutError\";\n }\n}\n\n/**\n * The CLI's delegated-secret path treats TC_HOME as a home directory, rather\n * than a direct TinyCloud directory. Keep the shared store on that convention.\n */\nexport function tinycloudHomePath(): string {\n const home = process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();\n return join(home, \".tinycloud\");\n}\n\nexport function tinycloudConfigPath(): string {\n return join(tinycloudHomePath(), \"config.json\");\n}\n\nexport function profilesPath(): string {\n return join(tinycloudHomePath(), \"profiles\");\n}\n\nexport function profilePath(profile: string): string {\n return join(profilesPath(), validateProfileName(profile));\n}\n\nexport function profileConfigPath(profile: string): string {\n return join(profilePath(profile), \"profile.json\");\n}\n\nexport function sessionPath(profile: string): string {\n return profileStorePath(profile, \"session\");\n}\n\nexport function additionalDelegationsPath(profile: string): string {\n return profileStorePath(profile, \"additional-delegations\");\n}\n\nexport function authRequestsPath(profile: string): string {\n return profileStorePath(profile, \"auth-requests\");\n}\n\nexport function profileStorePath(profile: string, store: ProfileStoreName): string {\n return join(profilePath(profile), `${store}.json`);\n}\n\n/** The format record is deliberately separate from each legacy JSON payload. */\nexport function profileStoreMetadataPath(profile: string, store: ProfileStoreName): string {\n return `${profileStorePath(profile, store)}.metadata.json`;\n}\n\nexport function profileLockPath(profile: string): string {\n return join(profilePath(profile), \".lock\");\n}\n\nexport function profileLockMetadataPath(profile: string): string {\n return join(profileLockPath(profile), \"owner.json\");\n}\n\n/**\n * Reads JSON without hiding malformed or inaccessible files. A missing file is\n * the only condition represented as null.\n */\nexport async function readJson<T>(filePath: string): Promise<T | null> {\n try {\n return JSON.parse(await readFile(filePath, \"utf8\")) as T;\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return null;\n throw error;\n }\n}\n\n/**\n * Writes the same pretty-printed, trailing-newline JSON shape used by the CLI,\n * but publishes it with an atomic rename from the same directory.\n */\nexport async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {\n const directory = dirname(filePath);\n const temporaryPath = join(\n directory,\n `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,\n );\n const contents = `${JSON.stringify(value, null, 2)}\\n`;\n\n await mkdir(directory, { recursive: true });\n try {\n await writeFile(temporaryPath, contents, \"utf8\");\n await rename(temporaryPath, filePath);\n } catch (error) {\n await rm(temporaryPath, { force: true }).catch(() => undefined);\n throw error;\n }\n}\n\nexport async function readProfile<T extends object = Record<string, unknown>>(\n profile: string,\n): Promise<T | null> {\n return readJson<T>(profileConfigPath(profile));\n}\n\nexport async function readSession<T extends object = Record<string, unknown>>(\n profile: string,\n): Promise<T | null> {\n await readStoreMetadata(profile, \"session\");\n return readJson<T>(sessionPath(profile));\n}\n\nexport async function readAdditionalDelegations<T = Record<string, unknown>>(\n profile: string,\n): Promise<T[]> {\n return (await readProfileStore<T>(profile, \"additional-delegations\")).records;\n}\n\nexport async function readAuthRequests<T = Record<string, unknown>>(\n profile: string,\n): Promise<T[]> {\n return (await readProfileStore<T>(profile, \"auth-requests\")).records;\n}\n\nexport async function readStoreMetadata(\n profile: string,\n store: ProfileStoreName,\n): Promise<StoreMetadata> {\n const metadata = await readJson<StoreMetadata>(profileStoreMetadataPath(profile, store));\n if (metadata === null) return { formatVersion: 1 };\n if (\n typeof metadata !== \"object\" ||\n metadata.formatVersion !== 1\n ) {\n throw new TypeError(`Unsupported store format for \"${store}\".`);\n }\n return metadata;\n}\n\nexport async function readProfileStore<T>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n): Promise<ProfileStoreContents<T>> {\n const [metadata, raw] = await Promise.all([\n readStoreMetadata(profile, store),\n readJson<unknown>(profileStorePath(profile, store)),\n ]);\n return {\n formatVersion: metadata.formatVersion,\n records: Array.isArray(raw) ? raw as T[] : [],\n };\n}\n\n/**\n * Runs a small critical section under the one advisory lock shared by all\n * profile stores. Lock release verifies its ownership token before removing\n * the exact metadata instance it acquired.\n */\nexport async function withProfileLock<T>(\n profile: string,\n action: () => Promise<T>,\n options: ProfileLockOptions = {},\n): Promise<T> {\n const normalizedProfile = validateProfileName(profile);\n const release = await acquireProfileLock(normalizedProfile, options);\n try {\n return await action();\n } finally {\n await release();\n }\n}\n\n/**\n * Appends a record or replaces the existing record with the supplied explicit\n * key. The extractor is supplied by the owning store so state.ts never needs\n * to know permission-request or delegation record shapes.\n */\nexport async function upsertProfileRecord<T>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n key: string,\n record: T,\n getKey: (candidate: T) => string | undefined,\n options: ProfileLockOptions = {},\n): Promise<T[]> {\n if (!key) throw new TypeError(\"A non-empty record key is required.\");\n\n return withProfileLock(profile, async () => {\n const current = (await readProfileStore<T>(profile, store)).records;\n const next = current.filter((candidate) => getKey(candidate) !== key);\n next.push(record);\n await writeJsonAtomic(profileStorePath(profile, store), next);\n await writeFormatOneMetadata(profile, store);\n return next;\n }, options);\n}\n\n/**\n * Performs a typed read-modify-write of a legacy array store under the one\n * per-profile lock. The payload stays an array and the sibling metadata stays\n * format 1, so existing CLI readers keep their byte/layout contract.\n */\nexport async function updateProfileStore<T, Result>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n update: (\n records: readonly T[],\n ) => Promise<{ readonly records: readonly T[]; readonly result: Result }> | {\n readonly records: readonly T[];\n readonly result: Result;\n },\n options: ProfileLockOptions = {},\n): Promise<Result> {\n return withProfileLock(\n profile,\n () => updateProfileStoreWhileLocked(profile, store, update),\n options,\n );\n}\n\n/** The caller must already own this profile's lock. */\nasync function updateProfileStoreWhileLocked<T, Result>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n update: (\n records: readonly T[],\n ) => Promise<{ readonly records: readonly T[]; readonly result: Result }> | {\n readonly records: readonly T[];\n readonly result: Result;\n },\n): Promise<Result> {\n const current = await readProfileStore<T>(profile, store);\n const next = await update(current.records);\n await writeJsonAtomic(profileStorePath(profile, store), next.records);\n await writeFormatOneMetadata(profile, store);\n return next.result;\n}\n\nexport async function writeSession<T extends object>(\n profile: string,\n session: T,\n options: ProfileLockOptions = {},\n): Promise<void> {\n await withProfileLock(profile, async () => {\n await readStoreMetadata(profile, \"session\");\n await writeJsonAtomic(sessionPath(profile), session);\n await writeFormatOneMetadata(profile, \"session\");\n }, options);\n}\n\nexport async function removeSession(\n profile: string,\n options: ProfileLockOptions = {},\n): Promise<void> {\n await withProfileLock(profile, async () => {\n await readStoreMetadata(profile, \"session\");\n await rm(sessionPath(profile), { force: true });\n }, options);\n}\n\nasync function writeFormatOneMetadata(profile: string, store: ProfileStoreName): Promise<void> {\n await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });\n}\n\nasync function acquireProfileLock(\n profile: string,\n options: ProfileLockOptions,\n): Promise<() => Promise<void>> {\n const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;\n const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;\n const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;\n const startedAt = Date.now();\n const lockPath = profileLockPath(profile);\n\n await mkdir(profilePath(profile), { recursive: true });\n\n while (true) {\n try {\n await mkdir(lockPath);\n const token = randomUUID();\n try {\n await writeJsonAtomic(profileLockMetadataPath(profile), {\n pid: process.pid,\n createdAt: new Date().toISOString(),\n token,\n });\n } catch (error) {\n await rmdir(lockPath).catch(() => undefined);\n throw error;\n }\n\n return async () => {\n await releaseProfileLock(lockPath, token);\n };\n } catch (error) {\n if (!isLockAlreadyHeld(error)) throw error;\n await signalTestLockContention(profile);\n }\n\n if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;\n\n const elapsedMs = Date.now() - startedAt;\n if (elapsedMs >= timeoutMs) {\n throw new ProfileLockTimeoutError(profile, timeoutMs);\n }\n await sleep(Math.min(retryMs, timeoutMs - elapsedMs));\n }\n}\n\nasync function releaseProfileLock(lockPath: string, token: string): Promise<void> {\n const ownerPath = join(lockPath, \"owner.json\");\n const claimPath = join(lockPath, `.release-${token}.json`);\n try {\n await rename(ownerPath, claimPath);\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return;\n throw error;\n }\n\n const claimed = await readJson<{ token?: unknown }>(claimPath).catch(() => null);\n if (claimed?.token !== token) {\n await rename(claimPath, ownerPath).catch(() => undefined);\n return;\n }\n\n await rm(claimPath, { force: true });\n await rmdir(lockPath).catch(() => undefined);\n}\n\n/**\n * Gives process-level contention tests an event-driven witness that a second\n * writer has reached an already-held lock. This deliberately has no effect\n * outside an explicit test environment, and only creates a new signal file\n * beneath TC_HOME; it never changes lock acquisition or release semantics.\n */\nasync function signalTestLockContention(profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n\n const configuredPath = process.env[TEST_LOCK_CONTENTION_SIGNAL_PATH];\n const configuredHome = process.env.TC_HOME;\n if (!configuredPath || !configuredHome) return;\n\n const home = resolve(configuredHome);\n const signalPath = resolve(configuredPath);\n const pathFromHome = relative(home, signalPath);\n if (\n pathFromHome === \"\" ||\n pathFromHome === \"..\" ||\n pathFromHome.startsWith(`..${process.platform === \"win32\" ? \"\\\\\" : \"/\"}`) ||\n pathFromHome.startsWith(\"../\") ||\n pathFromHome.startsWith(\"..\\\\\")\n ) {\n return;\n }\n\n await writeFile(signalPath, `${profile}\\n`, { encoding: \"utf8\", flag: \"wx\" })\n .catch(() => undefined);\n}\n\nasync function recoverStaleLock(profile: string, lockPath: string, staleAfterMs: number): Promise<boolean> {\n const ownerPath = join(lockPath, \"owner.json\");\n const owner = await readJson<{ pid?: unknown; createdAt?: unknown; token?: unknown }>(ownerPath)\n .catch(() => null);\n if (!isStaleOwner(owner, staleAfterMs)) return false;\n\n // Claim the observed metadata file, rather than renaming/removing the lock\n // directory. A contender can only acquire the directory after it is empty;\n // rmdir below therefore cannot remove a replacement lock instance.\n const observedToken = typeof owner?.token === \"string\" && owner.token.length > 0\n ? owner.token\n : \"legacy\";\n await waitForTestStaleRecoveryBarrier(profile);\n const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);\n try {\n await rename(ownerPath, claimPath);\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return false;\n return false;\n }\n\n const claimed = await readJson<{ pid?: unknown; createdAt?: unknown; token?: unknown }>(claimPath)\n .catch(() => null);\n const sameInstance = claimed !== null && (\n observedToken === \"legacy\"\n ? claimed.token === undefined\n : claimed.token === observedToken\n );\n if (!sameInstance) {\n await rename(claimPath, ownerPath).catch(() => undefined);\n return false;\n }\n\n await rm(claimPath, { force: true });\n await rmdir(lockPath).catch(() => undefined);\n return true;\n}\n\nasync function waitForTestStaleRecoveryBarrier(profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n const barrierDirectory = process.env[TEST_LOCK_RECOVERY_BARRIER_DIR];\n if (!barrierDirectory) return;\n await mkdir(barrierDirectory, { recursive: true });\n const readyPath = join(barrierDirectory, `ready-${process.pid}-${profile}`);\n await writeFile(readyPath, \"ready\\n\", { encoding: \"utf8\", flag: \"wx\" }).catch(() => undefined);\n const releasePath = join(barrierDirectory, \"release\");\n while (true) {\n try {\n await readFile(releasePath, \"utf8\");\n return;\n } catch {\n await sleep(1);\n }\n }\n}\n\nfunction isStaleOwner(\n owner: { pid?: unknown; createdAt?: unknown } | null,\n staleAfterMs: number,\n): boolean {\n const now = Date.now();\n const createdAt = typeof owner?.createdAt === \"string\"\n ? Date.parse(owner.createdAt)\n : Number.NaN;\n\n // Reclaim only a fully published lock whose owner PID is confirmed dead.\n // An ownerless or malformed directory may still be between mkdir and\n // metadata publication, so reclaiming it could let two writers enter the\n // critical section. Current writers claim .lock with exclusive mkdir before\n // publishing owner metadata and therefore take the same conservative path.\n if (!Number.isFinite(createdAt) || now - createdAt < staleAfterMs) return false;\n if (typeof owner?.pid !== \"number\" || !Number.isInteger(owner.pid) || owner.pid <= 0) {\n return false;\n }\n return !isProcessAlive(owner.pid);\n}\n\nfunction isLockAlreadyHeld(error: unknown): boolean {\n return isErrno(error, \"EEXIST\") || isErrno(error, \"ENOTEMPTY\");\n}\n\nfunction isProcessAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return !isErrno(error, \"ESRCH\");\n }\n}\n\nfunction validateProfileName(profile: string): string {\n if (\n !profile ||\n profile === \".\" ||\n profile === \"..\" ||\n profile.includes(\"/\") ||\n profile.includes(\"\\\\\") ||\n profile.includes(\"\\0\")\n ) {\n throw new TypeError(\"Profile names must be non-empty path segments.\");\n }\n return profile;\n}\n\nfunction isErrno(error: unknown, code: string): boolean {\n return typeof error === \"object\" && error !== null && \"code\" in error &&\n (error as { code?: unknown }).code === code;\n}\n\nfunction sleep(milliseconds: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, milliseconds));\n}\n\nexport { DEFAULT_PROFILE };\n","import {\n DEFAULT_PROFILE,\n readJson,\n readProfile,\n tinycloudConfigPath,\n} from \"./state.js\";\nimport type {\n InvocationTarget,\n OperationContextSummary,\n OperationOperatorType,\n TinyCloudPosture,\n} from \"./contract.js\";\n\nconst DEFAULT_HOST = \"https://node.tinycloud.xyz\";\n\nexport type SafeInvocationContext = OperationContextSummary & {\n operatorType: OperationOperatorType;\n};\n\nexport type InvocationContextResolution =\n | { ok: true; context: SafeInvocationContext }\n | {\n ok: false;\n error: {\n code: \"PROFILE_NOT_FOUND\";\n message: string;\n retryable: false;\n };\n };\n\ninterface GlobalConfig {\n defaultProfile?: unknown;\n}\n\n/** Validated profile material retained only inside runtime resolution. */\nexport type StoredProfile = {\n name: string;\n host: string;\n chainId: number;\n spaceName: string;\n did: string;\n createdAt: string;\n sessionDid?: unknown;\n ownerDid?: unknown;\n spaceId?: unknown;\n posture?: unknown;\n operatorType?: unknown;\n authMethod?: unknown;\n defaultSpace?: unknown;\n privateKey?: unknown;\n address?: unknown;\n openkeyHost?: unknown;\n};\n\n/** Profile snapshot plus its derived safe invocation context. */\nexport type InvocationProfileResolution =\n | { ok: true; profile: StoredProfile; context: SafeInvocationContext }\n | Extract<InvocationContextResolution, { ok: false }>;\n\n/**\n * Resolves one selected profile to safe identity data. Resolution is deliberately\n * one-way: after selecting a name, any missing, malformed, or unreadable\n * profile is PROFILE_NOT_FOUND rather than a reason to choose another profile.\n */\nexport async function resolveInvocationContext(\n target: InvocationTarget = {},\n): Promise<InvocationContextResolution> {\n const resolved = await resolveInvocationProfile(target);\n return resolved.ok ? { ok: true, context: resolved.context } : resolved;\n}\n\n/** Resolves the selected profile and its safe context from one profile read. */\nexport async function resolveInvocationProfile(\n target: InvocationTarget = {},\n): Promise<InvocationProfileResolution> {\n const profile = await resolveProfileName(target);\n let storedProfile: StoredProfile | null;\n try {\n storedProfile = await readProfile<StoredProfile>(profile);\n } catch {\n return profileNotFound(profile);\n }\n\n if (!isStoredProfile(storedProfile)) {\n return profileNotFound(profile);\n }\n // An explicit CLI key is a complete identity override. Do not let stale\n // persisted auth posture reject that live local-owner authentication path.\n if (target.privateKey === undefined && !isCoherentProfile(storedProfile)) {\n return profileNotFound(profile);\n }\n\n const host = firstString(target.host, process.env.TC_HOST, storedProfile.host, DEFAULT_HOST);\n const posture = resolvePosture(storedProfile);\n\n return {\n ok: true,\n profile: storedProfile,\n context: {\n profile,\n host,\n posture,\n operatorType: isOperatorType(storedProfile.operatorType) ? storedProfile.operatorType : \"human\",\n principalDid: maybeDid(storedProfile.did),\n sessionDid: maybeDid(storedProfile.sessionDid),\n ownerDid: maybeDid(storedProfile.ownerDid),\n space: typeof storedProfile.spaceId === \"string\" ? storedProfile.spaceId : undefined,\n },\n };\n}\n\n/**\n * Selects the profile name once for a long-lived projection process. The\n * selected name is safe identity data; profile material is read only by the\n * invocation runtime after the projection pins this value.\n */\nexport async function resolveInvocationProfileName(\n explicitProfile?: string,\n): Promise<string> {\n return resolveProfileName(\n explicitProfile === undefined ? {} : { profile: explicitProfile },\n );\n}\n\nasync function resolveProfileName(target: InvocationTarget): Promise<string> {\n if (typeof target.profile === \"string\") return target.profile;\n if (typeof process.env.TC_PROFILE === \"string\") return process.env.TC_PROFILE;\n\n try {\n const config = await readJson<GlobalConfig>(tinycloudConfigPath());\n if (typeof config?.defaultProfile === \"string\") return config.defaultProfile;\n } catch {\n // Config only chooses the initial name. A missing or malformed config falls\n // back to the documented default, never to another existing profile.\n }\n return DEFAULT_PROFILE;\n}\n\n/** Derives the effective posture without consulting any later profile read. */\nexport function resolvePosture(profile: Pick<StoredProfile, \"posture\" | \"authMethod\">): TinyCloudPosture {\n if (isPosture(profile.posture)) return profile.posture;\n if (profile.authMethod === \"local\") return \"local-owner-key\";\n return \"owner-openkey\";\n}\n\nfunction profileNotFound(profile: string): Extract<InvocationContextResolution, { ok: false }> {\n return {\n ok: false,\n error: {\n code: \"PROFILE_NOT_FOUND\",\n message: `Profile \"${profile}\" is not available.`,\n retryable: false,\n },\n };\n}\n\nfunction firstString(...values: unknown[]): string {\n for (const value of values) {\n if (typeof value === \"string\" && value.length > 0) return value;\n }\n return DEFAULT_HOST;\n}\n\nfunction maybeDid(value: unknown): string | undefined {\n if (typeof value !== \"string\" || value.length === 0) return undefined;\n const fragment = value.indexOf(\"#\");\n return fragment === -1 ? value : value.slice(0, fragment);\n}\n\nfunction isPosture(value: unknown): value is TinyCloudPosture {\n return value === \"owner-openkey\" ||\n value === \"delegate-session\" ||\n value === \"local-owner-key\" ||\n value === \"unauthenticated\";\n}\n\nfunction isOperatorType(value: unknown): value is OperationOperatorType {\n return value === \"human\" || value === \"agent\";\n}\n\n/**\n * The CLI's original persisted profile format required these six fields.\n * Posture and operator fields arrived later, so they remain optional while\n * any present typed field must still have its documented shape.\n */\nfunction isStoredProfile(value: unknown): value is StoredProfile {\n return isRecord(value) &&\n isNonEmptyString(value.name) &&\n isNonEmptyString(value.host) &&\n typeof value.chainId === \"number\" &&\n Number.isFinite(value.chainId) &&\n isNonEmptyString(value.spaceName) &&\n isNonEmptyString(value.did) &&\n isNonEmptyString(value.createdAt) &&\n isOptionalNonEmptyString(value.sessionDid) &&\n isOptionalNonEmptyString(value.ownerDid) &&\n isOptionalNonEmptyString(value.spaceId) &&\n isOptionalPosture(value.posture) &&\n isOptionalOperatorType(value.operatorType) &&\n isOptionalAuthMethod(value.authMethod) &&\n isOptionalNonEmptyString(value.defaultSpace) &&\n isOptionalNonEmptyString(value.privateKey) &&\n isOptionalNonEmptyString(value.address) &&\n isOptionalNonEmptyString(value.openkeyHost);\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === \"string\" && value.length > 0;\n}\n\nfunction isOptionalNonEmptyString(value: unknown): boolean {\n return value === undefined || isNonEmptyString(value);\n}\n\nfunction isOptionalPosture(value: unknown): boolean {\n return value === undefined || isPosture(value);\n}\n\nfunction isOptionalOperatorType(value: unknown): boolean {\n return value === undefined || isOperatorType(value);\n}\n\nfunction isOptionalAuthMethod(value: unknown): boolean {\n return value === undefined || value === \"openkey\" || value === \"local\";\n}\n\n/**\n * Local-key authentication is owner authentication. A persisted delegate or\n * OpenKey posture must never be allowed to select that path later in runtime\n * construction, so reject the inconsistent profile before policy inspection.\n */\nfunction isCoherentProfile(profile: StoredProfile): boolean {\n if (profile.authMethod === \"local\") {\n return profile.posture === undefined || profile.posture === \"local-owner-key\";\n }\n if (profile.posture === \"local-owner-key\") {\n return profile.authMethod === \"local\";\n }\n return true;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/state.ts","../src/profile.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { AsyncLocalStorage } from \"node:async_hooks\";\nimport {\n mkdir,\n readFile,\n rename,\n rmdir,\n rm,\n writeFile,\n} from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, isAbsolute, join, relative, resolve } from \"node:path\";\n\nconst DEFAULT_PROFILE = \"default\";\nconst DEFAULT_LOCK_TIMEOUT_MS = 2_000;\nconst DEFAULT_LOCK_RETRY_MS = 25;\nconst DEFAULT_STALE_LOCK_MS = 30_000;\nconst TEST_LOCK_CONTENTION_SIGNAL_PATH = \"TC_TEST_PROFILE_LOCK_CONTENTION_SIGNAL_PATH\";\nconst TEST_LOCK_RECOVERY_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_RECOVERY_BARRIER_DIR\";\nconst invocationStateRoot = new AsyncLocalStorage<string>();\n\nexport type ProfileStoreName =\n | \"session\"\n | \"additional-delegations\"\n | \"auth-requests\";\n\nexport interface StoreMetadata {\n formatVersion: number;\n}\n\nexport interface ProfileStoreContents<T> {\n formatVersion: number;\n records: T[];\n}\n\nexport interface ProfileLockOptions {\n timeoutMs?: number;\n retryMs?: number;\n staleAfterMs?: number;\n}\n\nexport class ProfileLockTimeoutError extends Error {\n readonly code = \"PROFILE_LOCK_TIMEOUT\";\n\n constructor(profile: string, timeoutMs: number) {\n super(`Timed out waiting for the profile lock for \"${profile}\" after ${timeoutMs}ms.`);\n this.name = \"ProfileLockTimeoutError\";\n }\n}\n\n/**\n * The CLI's delegated-secret path treats TC_HOME as a home directory, rather\n * than a direct TinyCloud directory. Keep the shared store on that convention.\n */\nexport function tinycloudHomePath(): string {\n const home = invocationStateRoot.getStore() ??\n process.env.TC_HOME ??\n process.env.HOME ??\n process.env.USERPROFILE ??\n homedir();\n return join(home, \".tinycloud\");\n}\n\n/** Run one operation against an isolated TinyCloud home directory. */\nexport function withTinyCloudStateRoot<T>(\n stateRoot: string | undefined,\n action: () => Promise<T>,\n): Promise<T> {\n if (stateRoot === undefined) return action();\n if (!isAbsolute(stateRoot) || stateRoot.includes(\"\\0\")) {\n throw new TypeError(\"The TinyCloud state root must be an absolute path.\");\n }\n return invocationStateRoot.run(resolve(stateRoot), action);\n}\n\nexport function tinycloudConfigPath(): string {\n return join(tinycloudHomePath(), \"config.json\");\n}\n\nexport function profilesPath(): string {\n return join(tinycloudHomePath(), \"profiles\");\n}\n\nexport function profilePath(profile: string): string {\n return join(profilesPath(), validateProfileName(profile));\n}\n\nexport function profileConfigPath(profile: string): string {\n return join(profilePath(profile), \"profile.json\");\n}\n\nexport function sessionPath(profile: string): string {\n return profileStorePath(profile, \"session\");\n}\n\nexport function additionalDelegationsPath(profile: string): string {\n return profileStorePath(profile, \"additional-delegations\");\n}\n\nexport function authRequestsPath(profile: string): string {\n return profileStorePath(profile, \"auth-requests\");\n}\n\nexport function profileStorePath(profile: string, store: ProfileStoreName): string {\n return join(profilePath(profile), `${store}.json`);\n}\n\n/** The format record is deliberately separate from each legacy JSON payload. */\nexport function profileStoreMetadataPath(profile: string, store: ProfileStoreName): string {\n return `${profileStorePath(profile, store)}.metadata.json`;\n}\n\nexport function profileLockPath(profile: string): string {\n return join(profilePath(profile), \".lock\");\n}\n\nexport function profileLockMetadataPath(profile: string): string {\n return join(profileLockPath(profile), \"owner.json\");\n}\n\n/**\n * Reads JSON without hiding malformed or inaccessible files. A missing file is\n * the only condition represented as null.\n */\nexport async function readJson<T>(filePath: string): Promise<T | null> {\n try {\n return JSON.parse(await readFile(filePath, \"utf8\")) as T;\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return null;\n throw error;\n }\n}\n\n/**\n * Writes the same pretty-printed, trailing-newline JSON shape used by the CLI,\n * but publishes it with an atomic rename from the same directory.\n */\nexport async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {\n const directory = dirname(filePath);\n const temporaryPath = join(\n directory,\n `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,\n );\n const contents = `${JSON.stringify(value, null, 2)}\\n`;\n\n await mkdir(directory, { recursive: true });\n try {\n await writeFile(temporaryPath, contents, \"utf8\");\n await rename(temporaryPath, filePath);\n } catch (error) {\n await rm(temporaryPath, { force: true }).catch(() => undefined);\n throw error;\n }\n}\n\nexport async function readProfile<T extends object = Record<string, unknown>>(\n profile: string,\n): Promise<T | null> {\n return readJson<T>(profileConfigPath(profile));\n}\n\nexport async function readSession<T extends object = Record<string, unknown>>(\n profile: string,\n): Promise<T | null> {\n await readStoreMetadata(profile, \"session\");\n return readJson<T>(sessionPath(profile));\n}\n\nexport async function readAdditionalDelegations<T = Record<string, unknown>>(\n profile: string,\n): Promise<T[]> {\n return (await readProfileStore<T>(profile, \"additional-delegations\")).records;\n}\n\nexport async function readAuthRequests<T = Record<string, unknown>>(\n profile: string,\n): Promise<T[]> {\n return (await readProfileStore<T>(profile, \"auth-requests\")).records;\n}\n\nexport async function readStoreMetadata(\n profile: string,\n store: ProfileStoreName,\n): Promise<StoreMetadata> {\n const metadata = await readJson<StoreMetadata>(profileStoreMetadataPath(profile, store));\n if (metadata === null) return { formatVersion: 1 };\n if (\n typeof metadata !== \"object\" ||\n metadata.formatVersion !== 1\n ) {\n throw new TypeError(`Unsupported store format for \"${store}\".`);\n }\n return metadata;\n}\n\nexport async function readProfileStore<T>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n): Promise<ProfileStoreContents<T>> {\n const [metadata, raw] = await Promise.all([\n readStoreMetadata(profile, store),\n readJson<unknown>(profileStorePath(profile, store)),\n ]);\n return {\n formatVersion: metadata.formatVersion,\n records: Array.isArray(raw) ? raw as T[] : [],\n };\n}\n\n/**\n * Runs a small critical section under the one advisory lock shared by all\n * profile stores. Lock release verifies its ownership token before removing\n * the exact metadata instance it acquired.\n */\nexport async function withProfileLock<T>(\n profile: string,\n action: () => Promise<T>,\n options: ProfileLockOptions = {},\n): Promise<T> {\n const normalizedProfile = validateProfileName(profile);\n const release = await acquireProfileLock(normalizedProfile, options);\n try {\n return await action();\n } finally {\n await release();\n }\n}\n\n/**\n * Appends a record or replaces the existing record with the supplied explicit\n * key. The extractor is supplied by the owning store so state.ts never needs\n * to know permission-request or delegation record shapes.\n */\nexport async function upsertProfileRecord<T>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n key: string,\n record: T,\n getKey: (candidate: T) => string | undefined,\n options: ProfileLockOptions = {},\n): Promise<T[]> {\n if (!key) throw new TypeError(\"A non-empty record key is required.\");\n\n return withProfileLock(profile, async () => {\n const current = (await readProfileStore<T>(profile, store)).records;\n const next = current.filter((candidate) => getKey(candidate) !== key);\n next.push(record);\n await writeJsonAtomic(profileStorePath(profile, store), next);\n await writeFormatOneMetadata(profile, store);\n return next;\n }, options);\n}\n\n/**\n * Performs a typed read-modify-write of a legacy array store under the one\n * per-profile lock. The payload stays an array and the sibling metadata stays\n * format 1, so existing CLI readers keep their byte/layout contract.\n */\nexport async function updateProfileStore<T, Result>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n update: (\n records: readonly T[],\n ) => Promise<{ readonly records: readonly T[]; readonly result: Result }> | {\n readonly records: readonly T[];\n readonly result: Result;\n },\n options: ProfileLockOptions = {},\n): Promise<Result> {\n return withProfileLock(\n profile,\n () => updateProfileStoreWhileLocked(profile, store, update),\n options,\n );\n}\n\n/** The caller must already own this profile's lock. */\nasync function updateProfileStoreWhileLocked<T, Result>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n update: (\n records: readonly T[],\n ) => Promise<{ readonly records: readonly T[]; readonly result: Result }> | {\n readonly records: readonly T[];\n readonly result: Result;\n },\n): Promise<Result> {\n const current = await readProfileStore<T>(profile, store);\n const next = await update(current.records);\n await writeJsonAtomic(profileStorePath(profile, store), next.records);\n await writeFormatOneMetadata(profile, store);\n return next.result;\n}\n\nexport async function writeSession<T extends object>(\n profile: string,\n session: T,\n options: ProfileLockOptions = {},\n): Promise<void> {\n await withProfileLock(profile, async () => {\n await readStoreMetadata(profile, \"session\");\n await writeJsonAtomic(sessionPath(profile), session);\n await writeFormatOneMetadata(profile, \"session\");\n }, options);\n}\n\nexport async function removeSession(\n profile: string,\n options: ProfileLockOptions = {},\n): Promise<void> {\n await withProfileLock(profile, async () => {\n await readStoreMetadata(profile, \"session\");\n await rm(sessionPath(profile), { force: true });\n }, options);\n}\n\nasync function writeFormatOneMetadata(profile: string, store: ProfileStoreName): Promise<void> {\n await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });\n}\n\nasync function acquireProfileLock(\n profile: string,\n options: ProfileLockOptions,\n): Promise<() => Promise<void>> {\n const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;\n const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;\n const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;\n const startedAt = Date.now();\n const lockPath = profileLockPath(profile);\n\n await mkdir(profilePath(profile), { recursive: true });\n\n while (true) {\n try {\n await mkdir(lockPath);\n const token = randomUUID();\n try {\n await writeJsonAtomic(profileLockMetadataPath(profile), {\n pid: process.pid,\n createdAt: new Date().toISOString(),\n token,\n });\n } catch (error) {\n await rmdir(lockPath).catch(() => undefined);\n throw error;\n }\n\n return async () => {\n await releaseProfileLock(lockPath, token);\n };\n } catch (error) {\n if (!isLockAlreadyHeld(error)) throw error;\n await signalTestLockContention(profile);\n }\n\n if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;\n\n const elapsedMs = Date.now() - startedAt;\n if (elapsedMs >= timeoutMs) {\n throw new ProfileLockTimeoutError(profile, timeoutMs);\n }\n await sleep(Math.min(retryMs, timeoutMs - elapsedMs));\n }\n}\n\nasync function releaseProfileLock(lockPath: string, token: string): Promise<void> {\n const ownerPath = join(lockPath, \"owner.json\");\n const claimPath = join(lockPath, `.release-${token}.json`);\n try {\n await rename(ownerPath, claimPath);\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return;\n throw error;\n }\n\n const claimed = await readJson<{ token?: unknown }>(claimPath).catch(() => null);\n if (claimed?.token !== token) {\n await rename(claimPath, ownerPath).catch(() => undefined);\n return;\n }\n\n await rm(claimPath, { force: true });\n await rmdir(lockPath).catch(() => undefined);\n}\n\n/**\n * Gives process-level contention tests an event-driven witness that a second\n * writer has reached an already-held lock. This deliberately has no effect\n * outside an explicit test environment, and only creates a new signal file\n * beneath TC_HOME; it never changes lock acquisition or release semantics.\n */\nasync function signalTestLockContention(profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n\n const configuredPath = process.env[TEST_LOCK_CONTENTION_SIGNAL_PATH];\n const configuredHome = process.env.TC_HOME;\n if (!configuredPath || !configuredHome) return;\n\n const home = resolve(configuredHome);\n const signalPath = resolve(configuredPath);\n const pathFromHome = relative(home, signalPath);\n if (\n pathFromHome === \"\" ||\n pathFromHome === \"..\" ||\n pathFromHome.startsWith(`..${process.platform === \"win32\" ? \"\\\\\" : \"/\"}`) ||\n pathFromHome.startsWith(\"../\") ||\n pathFromHome.startsWith(\"..\\\\\")\n ) {\n return;\n }\n\n await writeFile(signalPath, `${profile}\\n`, { encoding: \"utf8\", flag: \"wx\" })\n .catch(() => undefined);\n}\n\nasync function recoverStaleLock(profile: string, lockPath: string, staleAfterMs: number): Promise<boolean> {\n const ownerPath = join(lockPath, \"owner.json\");\n const owner = await readJson<{ pid?: unknown; createdAt?: unknown; token?: unknown }>(ownerPath)\n .catch(() => null);\n if (!isStaleOwner(owner, staleAfterMs)) return false;\n\n // Claim the observed metadata file, rather than renaming/removing the lock\n // directory. A contender can only acquire the directory after it is empty;\n // rmdir below therefore cannot remove a replacement lock instance.\n const observedToken = typeof owner?.token === \"string\" && owner.token.length > 0\n ? owner.token\n : \"legacy\";\n await waitForTestStaleRecoveryBarrier(profile);\n const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);\n try {\n await rename(ownerPath, claimPath);\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return false;\n return false;\n }\n\n const claimed = await readJson<{ pid?: unknown; createdAt?: unknown; token?: unknown }>(claimPath)\n .catch(() => null);\n const sameInstance = claimed !== null && (\n observedToken === \"legacy\"\n ? claimed.token === undefined\n : claimed.token === observedToken\n );\n if (!sameInstance) {\n await rename(claimPath, ownerPath).catch(() => undefined);\n return false;\n }\n\n await rm(claimPath, { force: true });\n await rmdir(lockPath).catch(() => undefined);\n return true;\n}\n\nasync function waitForTestStaleRecoveryBarrier(profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n const barrierDirectory = process.env[TEST_LOCK_RECOVERY_BARRIER_DIR];\n if (!barrierDirectory) return;\n await mkdir(barrierDirectory, { recursive: true });\n const readyPath = join(barrierDirectory, `ready-${process.pid}-${profile}`);\n await writeFile(readyPath, \"ready\\n\", { encoding: \"utf8\", flag: \"wx\" }).catch(() => undefined);\n const releasePath = join(barrierDirectory, \"release\");\n while (true) {\n try {\n await readFile(releasePath, \"utf8\");\n return;\n } catch {\n await sleep(1);\n }\n }\n}\n\nfunction isStaleOwner(\n owner: { pid?: unknown; createdAt?: unknown } | null,\n staleAfterMs: number,\n): boolean {\n const now = Date.now();\n const createdAt = typeof owner?.createdAt === \"string\"\n ? Date.parse(owner.createdAt)\n : Number.NaN;\n\n // Reclaim only a fully published lock whose owner PID is confirmed dead.\n // An ownerless or malformed directory may still be between mkdir and\n // metadata publication, so reclaiming it could let two writers enter the\n // critical section. Current writers claim .lock with exclusive mkdir before\n // publishing owner metadata and therefore take the same conservative path.\n if (!Number.isFinite(createdAt) || now - createdAt < staleAfterMs) return false;\n if (typeof owner?.pid !== \"number\" || !Number.isInteger(owner.pid) || owner.pid <= 0) {\n return false;\n }\n return !isProcessAlive(owner.pid);\n}\n\nfunction isLockAlreadyHeld(error: unknown): boolean {\n return isErrno(error, \"EEXIST\") || isErrno(error, \"ENOTEMPTY\");\n}\n\nfunction isProcessAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return !isErrno(error, \"ESRCH\");\n }\n}\n\nfunction validateProfileName(profile: string): string {\n if (\n !profile ||\n profile === \".\" ||\n profile === \"..\" ||\n profile.includes(\"/\") ||\n profile.includes(\"\\\\\") ||\n profile.includes(\"\\0\")\n ) {\n throw new TypeError(\"Profile names must be non-empty path segments.\");\n }\n return profile;\n}\n\nfunction isErrno(error: unknown, code: string): boolean {\n return typeof error === \"object\" && error !== null && \"code\" in error &&\n (error as { code?: unknown }).code === code;\n}\n\nfunction sleep(milliseconds: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, milliseconds));\n}\n\nexport { DEFAULT_PROFILE };\n","import {\n DEFAULT_PROFILE,\n readJson,\n readProfile,\n tinycloudConfigPath,\n} from \"./state.js\";\nimport type {\n InvocationTarget,\n OperationContextSummary,\n OperationOperatorType,\n TinyCloudPosture,\n} from \"./contract.js\";\n\nconst DEFAULT_HOST = \"https://node.tinycloud.xyz\";\n\nexport type SafeInvocationContext = OperationContextSummary & {\n operatorType: OperationOperatorType;\n};\n\nexport type InvocationContextResolution =\n | { ok: true; context: SafeInvocationContext }\n | {\n ok: false;\n error: {\n code: \"PROFILE_NOT_FOUND\";\n message: string;\n retryable: false;\n };\n };\n\ninterface GlobalConfig {\n defaultProfile?: unknown;\n}\n\n/** Validated profile material retained only inside runtime resolution. */\nexport type StoredProfile = {\n name: string;\n host: string;\n chainId: number;\n spaceName: string;\n did: string;\n createdAt: string;\n sessionDid?: unknown;\n ownerDid?: unknown;\n spaceId?: unknown;\n posture?: unknown;\n operatorType?: unknown;\n authMethod?: unknown;\n defaultSpace?: unknown;\n privateKey?: unknown;\n address?: unknown;\n openkeyHost?: unknown;\n};\n\n/** Profile snapshot plus its derived safe invocation context. */\nexport type InvocationProfileResolution =\n | { ok: true; profile: StoredProfile; context: SafeInvocationContext }\n | Extract<InvocationContextResolution, { ok: false }>;\n\n/**\n * Resolves one selected profile to safe identity data. Resolution is deliberately\n * one-way: after selecting a name, any missing, malformed, or unreadable\n * profile is PROFILE_NOT_FOUND rather than a reason to choose another profile.\n */\nexport async function resolveInvocationContext(\n target: InvocationTarget = {},\n): Promise<InvocationContextResolution> {\n const resolved = await resolveInvocationProfile(target);\n return resolved.ok ? { ok: true, context: resolved.context } : resolved;\n}\n\n/** Resolves the selected profile and its safe context from one profile read. */\nexport async function resolveInvocationProfile(\n target: InvocationTarget = {},\n): Promise<InvocationProfileResolution> {\n const profile = await resolveProfileName(target);\n let storedProfile: StoredProfile | null;\n try {\n storedProfile = await readProfile<StoredProfile>(profile);\n } catch {\n return profileNotFound(profile);\n }\n\n if (!isStoredProfile(storedProfile)) {\n return profileNotFound(profile);\n }\n // An explicit CLI key is a complete identity override. Do not let stale\n // persisted auth posture reject that live local-owner authentication path.\n if (target.privateKey === undefined && !isCoherentProfile(storedProfile)) {\n return profileNotFound(profile);\n }\n\n const host = firstString(target.host, process.env.TC_HOST, storedProfile.host, DEFAULT_HOST);\n const posture = resolvePosture(storedProfile);\n\n return {\n ok: true,\n profile: storedProfile,\n context: {\n profile,\n host,\n posture,\n operatorType: isOperatorType(storedProfile.operatorType) ? storedProfile.operatorType : \"human\",\n principalDid: maybeDid(storedProfile.did),\n sessionDid: maybeDid(storedProfile.sessionDid),\n ownerDid: maybeDid(storedProfile.ownerDid),\n space: typeof storedProfile.spaceId === \"string\" ? storedProfile.spaceId : undefined,\n },\n };\n}\n\n/**\n * Selects the profile name once for a long-lived projection process. The\n * selected name is safe identity data; profile material is read only by the\n * invocation runtime after the projection pins this value.\n */\nexport async function resolveInvocationProfileName(\n explicitProfile?: string,\n): Promise<string> {\n return resolveProfileName(\n explicitProfile === undefined ? {} : { profile: explicitProfile },\n );\n}\n\nasync function resolveProfileName(target: InvocationTarget): Promise<string> {\n if (typeof target.profile === \"string\") return target.profile;\n if (typeof process.env.TC_PROFILE === \"string\") return process.env.TC_PROFILE;\n\n try {\n const config = await readJson<GlobalConfig>(tinycloudConfigPath());\n if (typeof config?.defaultProfile === \"string\") return config.defaultProfile;\n } catch {\n // Config only chooses the initial name. A missing or malformed config falls\n // back to the documented default, never to another existing profile.\n }\n return DEFAULT_PROFILE;\n}\n\n/** Derives the effective posture without consulting any later profile read. */\nexport function resolvePosture(profile: Pick<StoredProfile, \"posture\" | \"authMethod\">): TinyCloudPosture {\n if (isPosture(profile.posture)) return profile.posture;\n if (profile.authMethod === \"local\") return \"local-owner-key\";\n return \"owner-openkey\";\n}\n\nfunction profileNotFound(profile: string): Extract<InvocationContextResolution, { ok: false }> {\n return {\n ok: false,\n error: {\n code: \"PROFILE_NOT_FOUND\",\n message: `Profile \"${profile}\" is not available.`,\n retryable: false,\n },\n };\n}\n\nfunction firstString(...values: unknown[]): string {\n for (const value of values) {\n if (typeof value === \"string\" && value.length > 0) return value;\n }\n return DEFAULT_HOST;\n}\n\nfunction maybeDid(value: unknown): string | undefined {\n if (typeof value !== \"string\" || value.length === 0) return undefined;\n const fragment = value.indexOf(\"#\");\n return fragment === -1 ? value : value.slice(0, fragment);\n}\n\nfunction isPosture(value: unknown): value is TinyCloudPosture {\n return value === \"owner-openkey\" ||\n value === \"delegate-session\" ||\n value === \"local-owner-key\" ||\n value === \"unauthenticated\";\n}\n\nfunction isOperatorType(value: unknown): value is OperationOperatorType {\n return value === \"human\" || value === \"agent\";\n}\n\n/**\n * The CLI's original persisted profile format required these six fields.\n * Posture and operator fields arrived later, so they remain optional while\n * any present typed field must still have its documented shape.\n */\nfunction isStoredProfile(value: unknown): value is StoredProfile {\n return isRecord(value) &&\n isNonEmptyString(value.name) &&\n isNonEmptyString(value.host) &&\n typeof value.chainId === \"number\" &&\n Number.isFinite(value.chainId) &&\n isNonEmptyString(value.spaceName) &&\n isNonEmptyString(value.did) &&\n isNonEmptyString(value.createdAt) &&\n isOptionalNonEmptyString(value.sessionDid) &&\n isOptionalNonEmptyString(value.ownerDid) &&\n isOptionalNonEmptyString(value.spaceId) &&\n isOptionalPosture(value.posture) &&\n isOptionalOperatorType(value.operatorType) &&\n isOptionalAuthMethod(value.authMethod) &&\n isOptionalNonEmptyString(value.defaultSpace) &&\n isOptionalNonEmptyString(value.privateKey) &&\n isOptionalNonEmptyString(value.address) &&\n isOptionalNonEmptyString(value.openkeyHost);\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === \"string\" && value.length > 0;\n}\n\nfunction isOptionalNonEmptyString(value: unknown): boolean {\n return value === undefined || isNonEmptyString(value);\n}\n\nfunction isOptionalPosture(value: unknown): boolean {\n return value === undefined || isPosture(value);\n}\n\nfunction isOptionalOperatorType(value: unknown): boolean {\n return value === undefined || isOperatorType(value);\n}\n\nfunction isOptionalAuthMethod(value: unknown): boolean {\n return value === undefined || value === \"openkey\" || value === \"local\";\n}\n\n/**\n * Local-key authentication is owner authentication. A persisted delegate or\n * OpenKey posture must never be allowed to select that path later in runtime\n * construction, so reject the inconsistent profile before policy inspection.\n */\nfunction isCoherentProfile(profile: StoredProfile): boolean {\n if (profile.authMethod === \"local\") {\n return profile.posture === undefined || profile.posture === \"local-owner-key\";\n }\n if (profile.posture === \"local-owner-key\") {\n return profile.authMethod === \"local\";\n }\n return true;\n}\n"],"mappings":"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","names":[]}
package/dist/state.cjs CHANGED
@@ -45,11 +45,13 @@ __export(state_exports, {
45
45
  updateProfileStore: () => updateProfileStore,
46
46
  upsertProfileRecord: () => upsertProfileRecord,
47
47
  withProfileLock: () => withProfileLock,
48
+ withTinyCloudStateRoot: () => withTinyCloudStateRoot,
48
49
  writeJsonAtomic: () => writeJsonAtomic,
49
50
  writeSession: () => writeSession
50
51
  });
51
52
  module.exports = __toCommonJS(state_exports);
52
53
  var import_node_crypto = require("crypto");
54
+ var import_node_async_hooks = require("async_hooks");
53
55
  var import_promises = require("fs/promises");
54
56
  var import_node_os = require("os");
55
57
  var import_node_path = require("path");
@@ -59,6 +61,7 @@ var DEFAULT_LOCK_RETRY_MS = 25;
59
61
  var DEFAULT_STALE_LOCK_MS = 3e4;
60
62
  var TEST_LOCK_CONTENTION_SIGNAL_PATH = "TC_TEST_PROFILE_LOCK_CONTENTION_SIGNAL_PATH";
61
63
  var TEST_LOCK_RECOVERY_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RECOVERY_BARRIER_DIR";
64
+ var invocationStateRoot = new import_node_async_hooks.AsyncLocalStorage();
62
65
  var ProfileLockTimeoutError = class extends Error {
63
66
  constructor(profile, timeoutMs) {
64
67
  super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.`);
@@ -67,9 +70,16 @@ var ProfileLockTimeoutError = class extends Error {
67
70
  }
68
71
  };
69
72
  function tinycloudHomePath() {
70
- const home = process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? (0, import_node_os.homedir)();
73
+ const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? (0, import_node_os.homedir)();
71
74
  return (0, import_node_path.join)(home, ".tinycloud");
72
75
  }
76
+ function withTinyCloudStateRoot(stateRoot, action) {
77
+ if (stateRoot === void 0) return action();
78
+ if (!(0, import_node_path.isAbsolute)(stateRoot) || stateRoot.includes("\0")) {
79
+ throw new TypeError("The TinyCloud state root must be an absolute path.");
80
+ }
81
+ return invocationStateRoot.run((0, import_node_path.resolve)(stateRoot), action);
82
+ }
73
83
  function tinycloudConfigPath() {
74
84
  return (0, import_node_path.join)(tinycloudHomePath(), "config.json");
75
85
  }
@@ -375,6 +385,7 @@ function sleep(milliseconds) {
375
385
  updateProfileStore,
376
386
  upsertProfileRecord,
377
387
  withProfileLock,
388
+ withTinyCloudStateRoot,
378
389
  writeJsonAtomic,
379
390
  writeSession
380
391
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/state.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport {\n mkdir,\n readFile,\n rename,\n rmdir,\n rm,\n writeFile,\n} from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join, relative, resolve } from \"node:path\";\n\nconst DEFAULT_PROFILE = \"default\";\nconst DEFAULT_LOCK_TIMEOUT_MS = 2_000;\nconst DEFAULT_LOCK_RETRY_MS = 25;\nconst DEFAULT_STALE_LOCK_MS = 30_000;\nconst TEST_LOCK_CONTENTION_SIGNAL_PATH = \"TC_TEST_PROFILE_LOCK_CONTENTION_SIGNAL_PATH\";\nconst TEST_LOCK_RECOVERY_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_RECOVERY_BARRIER_DIR\";\n\nexport type ProfileStoreName =\n | \"session\"\n | \"additional-delegations\"\n | \"auth-requests\";\n\nexport interface StoreMetadata {\n formatVersion: number;\n}\n\nexport interface ProfileStoreContents<T> {\n formatVersion: number;\n records: T[];\n}\n\nexport interface ProfileLockOptions {\n timeoutMs?: number;\n retryMs?: number;\n staleAfterMs?: number;\n}\n\nexport class ProfileLockTimeoutError extends Error {\n readonly code = \"PROFILE_LOCK_TIMEOUT\";\n\n constructor(profile: string, timeoutMs: number) {\n super(`Timed out waiting for the profile lock for \"${profile}\" after ${timeoutMs}ms.`);\n this.name = \"ProfileLockTimeoutError\";\n }\n}\n\n/**\n * The CLI's delegated-secret path treats TC_HOME as a home directory, rather\n * than a direct TinyCloud directory. Keep the shared store on that convention.\n */\nexport function tinycloudHomePath(): string {\n const home = process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();\n return join(home, \".tinycloud\");\n}\n\nexport function tinycloudConfigPath(): string {\n return join(tinycloudHomePath(), \"config.json\");\n}\n\nexport function profilesPath(): string {\n return join(tinycloudHomePath(), \"profiles\");\n}\n\nexport function profilePath(profile: string): string {\n return join(profilesPath(), validateProfileName(profile));\n}\n\nexport function profileConfigPath(profile: string): string {\n return join(profilePath(profile), \"profile.json\");\n}\n\nexport function sessionPath(profile: string): string {\n return profileStorePath(profile, \"session\");\n}\n\nexport function additionalDelegationsPath(profile: string): string {\n return profileStorePath(profile, \"additional-delegations\");\n}\n\nexport function authRequestsPath(profile: string): string {\n return profileStorePath(profile, \"auth-requests\");\n}\n\nexport function profileStorePath(profile: string, store: ProfileStoreName): string {\n return join(profilePath(profile), `${store}.json`);\n}\n\n/** The format record is deliberately separate from each legacy JSON payload. */\nexport function profileStoreMetadataPath(profile: string, store: ProfileStoreName): string {\n return `${profileStorePath(profile, store)}.metadata.json`;\n}\n\nexport function profileLockPath(profile: string): string {\n return join(profilePath(profile), \".lock\");\n}\n\nexport function profileLockMetadataPath(profile: string): string {\n return join(profileLockPath(profile), \"owner.json\");\n}\n\n/**\n * Reads JSON without hiding malformed or inaccessible files. A missing file is\n * the only condition represented as null.\n */\nexport async function readJson<T>(filePath: string): Promise<T | null> {\n try {\n return JSON.parse(await readFile(filePath, \"utf8\")) as T;\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return null;\n throw error;\n }\n}\n\n/**\n * Writes the same pretty-printed, trailing-newline JSON shape used by the CLI,\n * but publishes it with an atomic rename from the same directory.\n */\nexport async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {\n const directory = dirname(filePath);\n const temporaryPath = join(\n directory,\n `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,\n );\n const contents = `${JSON.stringify(value, null, 2)}\\n`;\n\n await mkdir(directory, { recursive: true });\n try {\n await writeFile(temporaryPath, contents, \"utf8\");\n await rename(temporaryPath, filePath);\n } catch (error) {\n await rm(temporaryPath, { force: true }).catch(() => undefined);\n throw error;\n }\n}\n\nexport async function readProfile<T extends object = Record<string, unknown>>(\n profile: string,\n): Promise<T | null> {\n return readJson<T>(profileConfigPath(profile));\n}\n\nexport async function readSession<T extends object = Record<string, unknown>>(\n profile: string,\n): Promise<T | null> {\n await readStoreMetadata(profile, \"session\");\n return readJson<T>(sessionPath(profile));\n}\n\nexport async function readAdditionalDelegations<T = Record<string, unknown>>(\n profile: string,\n): Promise<T[]> {\n return (await readProfileStore<T>(profile, \"additional-delegations\")).records;\n}\n\nexport async function readAuthRequests<T = Record<string, unknown>>(\n profile: string,\n): Promise<T[]> {\n return (await readProfileStore<T>(profile, \"auth-requests\")).records;\n}\n\nexport async function readStoreMetadata(\n profile: string,\n store: ProfileStoreName,\n): Promise<StoreMetadata> {\n const metadata = await readJson<StoreMetadata>(profileStoreMetadataPath(profile, store));\n if (metadata === null) return { formatVersion: 1 };\n if (\n typeof metadata !== \"object\" ||\n metadata.formatVersion !== 1\n ) {\n throw new TypeError(`Unsupported store format for \"${store}\".`);\n }\n return metadata;\n}\n\nexport async function readProfileStore<T>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n): Promise<ProfileStoreContents<T>> {\n const [metadata, raw] = await Promise.all([\n readStoreMetadata(profile, store),\n readJson<unknown>(profileStorePath(profile, store)),\n ]);\n return {\n formatVersion: metadata.formatVersion,\n records: Array.isArray(raw) ? raw as T[] : [],\n };\n}\n\n/**\n * Runs a small critical section under the one advisory lock shared by all\n * profile stores. Lock release verifies its ownership token before removing\n * the exact metadata instance it acquired.\n */\nexport async function withProfileLock<T>(\n profile: string,\n action: () => Promise<T>,\n options: ProfileLockOptions = {},\n): Promise<T> {\n const normalizedProfile = validateProfileName(profile);\n const release = await acquireProfileLock(normalizedProfile, options);\n try {\n return await action();\n } finally {\n await release();\n }\n}\n\n/**\n * Appends a record or replaces the existing record with the supplied explicit\n * key. The extractor is supplied by the owning store so state.ts never needs\n * to know permission-request or delegation record shapes.\n */\nexport async function upsertProfileRecord<T>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n key: string,\n record: T,\n getKey: (candidate: T) => string | undefined,\n options: ProfileLockOptions = {},\n): Promise<T[]> {\n if (!key) throw new TypeError(\"A non-empty record key is required.\");\n\n return withProfileLock(profile, async () => {\n const current = (await readProfileStore<T>(profile, store)).records;\n const next = current.filter((candidate) => getKey(candidate) !== key);\n next.push(record);\n await writeJsonAtomic(profileStorePath(profile, store), next);\n await writeFormatOneMetadata(profile, store);\n return next;\n }, options);\n}\n\n/**\n * Performs a typed read-modify-write of a legacy array store under the one\n * per-profile lock. The payload stays an array and the sibling metadata stays\n * format 1, so existing CLI readers keep their byte/layout contract.\n */\nexport async function updateProfileStore<T, Result>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n update: (\n records: readonly T[],\n ) => Promise<{ readonly records: readonly T[]; readonly result: Result }> | {\n readonly records: readonly T[];\n readonly result: Result;\n },\n options: ProfileLockOptions = {},\n): Promise<Result> {\n return withProfileLock(\n profile,\n () => updateProfileStoreWhileLocked(profile, store, update),\n options,\n );\n}\n\n/** The caller must already own this profile's lock. */\nasync function updateProfileStoreWhileLocked<T, Result>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n update: (\n records: readonly T[],\n ) => Promise<{ readonly records: readonly T[]; readonly result: Result }> | {\n readonly records: readonly T[];\n readonly result: Result;\n },\n): Promise<Result> {\n const current = await readProfileStore<T>(profile, store);\n const next = await update(current.records);\n await writeJsonAtomic(profileStorePath(profile, store), next.records);\n await writeFormatOneMetadata(profile, store);\n return next.result;\n}\n\nexport async function writeSession<T extends object>(\n profile: string,\n session: T,\n options: ProfileLockOptions = {},\n): Promise<void> {\n await withProfileLock(profile, async () => {\n await readStoreMetadata(profile, \"session\");\n await writeJsonAtomic(sessionPath(profile), session);\n await writeFormatOneMetadata(profile, \"session\");\n }, options);\n}\n\nexport async function removeSession(\n profile: string,\n options: ProfileLockOptions = {},\n): Promise<void> {\n await withProfileLock(profile, async () => {\n await readStoreMetadata(profile, \"session\");\n await rm(sessionPath(profile), { force: true });\n }, options);\n}\n\nasync function writeFormatOneMetadata(profile: string, store: ProfileStoreName): Promise<void> {\n await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });\n}\n\nasync function acquireProfileLock(\n profile: string,\n options: ProfileLockOptions,\n): Promise<() => Promise<void>> {\n const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;\n const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;\n const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;\n const startedAt = Date.now();\n const lockPath = profileLockPath(profile);\n\n await mkdir(profilePath(profile), { recursive: true });\n\n while (true) {\n try {\n await mkdir(lockPath);\n const token = randomUUID();\n try {\n await writeJsonAtomic(profileLockMetadataPath(profile), {\n pid: process.pid,\n createdAt: new Date().toISOString(),\n token,\n });\n } catch (error) {\n await rmdir(lockPath).catch(() => undefined);\n throw error;\n }\n\n return async () => {\n await releaseProfileLock(lockPath, token);\n };\n } catch (error) {\n if (!isLockAlreadyHeld(error)) throw error;\n await signalTestLockContention(profile);\n }\n\n if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;\n\n const elapsedMs = Date.now() - startedAt;\n if (elapsedMs >= timeoutMs) {\n throw new ProfileLockTimeoutError(profile, timeoutMs);\n }\n await sleep(Math.min(retryMs, timeoutMs - elapsedMs));\n }\n}\n\nasync function releaseProfileLock(lockPath: string, token: string): Promise<void> {\n const ownerPath = join(lockPath, \"owner.json\");\n const claimPath = join(lockPath, `.release-${token}.json`);\n try {\n await rename(ownerPath, claimPath);\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return;\n throw error;\n }\n\n const claimed = await readJson<{ token?: unknown }>(claimPath).catch(() => null);\n if (claimed?.token !== token) {\n await rename(claimPath, ownerPath).catch(() => undefined);\n return;\n }\n\n await rm(claimPath, { force: true });\n await rmdir(lockPath).catch(() => undefined);\n}\n\n/**\n * Gives process-level contention tests an event-driven witness that a second\n * writer has reached an already-held lock. This deliberately has no effect\n * outside an explicit test environment, and only creates a new signal file\n * beneath TC_HOME; it never changes lock acquisition or release semantics.\n */\nasync function signalTestLockContention(profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n\n const configuredPath = process.env[TEST_LOCK_CONTENTION_SIGNAL_PATH];\n const configuredHome = process.env.TC_HOME;\n if (!configuredPath || !configuredHome) return;\n\n const home = resolve(configuredHome);\n const signalPath = resolve(configuredPath);\n const pathFromHome = relative(home, signalPath);\n if (\n pathFromHome === \"\" ||\n pathFromHome === \"..\" ||\n pathFromHome.startsWith(`..${process.platform === \"win32\" ? \"\\\\\" : \"/\"}`) ||\n pathFromHome.startsWith(\"../\") ||\n pathFromHome.startsWith(\"..\\\\\")\n ) {\n return;\n }\n\n await writeFile(signalPath, `${profile}\\n`, { encoding: \"utf8\", flag: \"wx\" })\n .catch(() => undefined);\n}\n\nasync function recoverStaleLock(profile: string, lockPath: string, staleAfterMs: number): Promise<boolean> {\n const ownerPath = join(lockPath, \"owner.json\");\n const owner = await readJson<{ pid?: unknown; createdAt?: unknown; token?: unknown }>(ownerPath)\n .catch(() => null);\n if (!isStaleOwner(owner, staleAfterMs)) return false;\n\n // Claim the observed metadata file, rather than renaming/removing the lock\n // directory. A contender can only acquire the directory after it is empty;\n // rmdir below therefore cannot remove a replacement lock instance.\n const observedToken = typeof owner?.token === \"string\" && owner.token.length > 0\n ? owner.token\n : \"legacy\";\n await waitForTestStaleRecoveryBarrier(profile);\n const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);\n try {\n await rename(ownerPath, claimPath);\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return false;\n return false;\n }\n\n const claimed = await readJson<{ pid?: unknown; createdAt?: unknown; token?: unknown }>(claimPath)\n .catch(() => null);\n const sameInstance = claimed !== null && (\n observedToken === \"legacy\"\n ? claimed.token === undefined\n : claimed.token === observedToken\n );\n if (!sameInstance) {\n await rename(claimPath, ownerPath).catch(() => undefined);\n return false;\n }\n\n await rm(claimPath, { force: true });\n await rmdir(lockPath).catch(() => undefined);\n return true;\n}\n\nasync function waitForTestStaleRecoveryBarrier(profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n const barrierDirectory = process.env[TEST_LOCK_RECOVERY_BARRIER_DIR];\n if (!barrierDirectory) return;\n await mkdir(barrierDirectory, { recursive: true });\n const readyPath = join(barrierDirectory, `ready-${process.pid}-${profile}`);\n await writeFile(readyPath, \"ready\\n\", { encoding: \"utf8\", flag: \"wx\" }).catch(() => undefined);\n const releasePath = join(barrierDirectory, \"release\");\n while (true) {\n try {\n await readFile(releasePath, \"utf8\");\n return;\n } catch {\n await sleep(1);\n }\n }\n}\n\nfunction isStaleOwner(\n owner: { pid?: unknown; createdAt?: unknown } | null,\n staleAfterMs: number,\n): boolean {\n const now = Date.now();\n const createdAt = typeof owner?.createdAt === \"string\"\n ? Date.parse(owner.createdAt)\n : Number.NaN;\n\n // Reclaim only a fully published lock whose owner PID is confirmed dead.\n // An ownerless or malformed directory may still be between mkdir and\n // metadata publication, so reclaiming it could let two writers enter the\n // critical section. Current writers claim .lock with exclusive mkdir before\n // publishing owner metadata and therefore take the same conservative path.\n if (!Number.isFinite(createdAt) || now - createdAt < staleAfterMs) return false;\n if (typeof owner?.pid !== \"number\" || !Number.isInteger(owner.pid) || owner.pid <= 0) {\n return false;\n }\n return !isProcessAlive(owner.pid);\n}\n\nfunction isLockAlreadyHeld(error: unknown): boolean {\n return isErrno(error, \"EEXIST\") || isErrno(error, \"ENOTEMPTY\");\n}\n\nfunction isProcessAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return !isErrno(error, \"ESRCH\");\n }\n}\n\nfunction validateProfileName(profile: string): string {\n if (\n !profile ||\n profile === \".\" ||\n profile === \"..\" ||\n profile.includes(\"/\") ||\n profile.includes(\"\\\\\") ||\n profile.includes(\"\\0\")\n ) {\n throw new TypeError(\"Profile names must be non-empty path segments.\");\n }\n return profile;\n}\n\nfunction isErrno(error: unknown, code: string): boolean {\n return typeof error === \"object\" && error !== null && \"code\" in error &&\n (error as { code?: unknown }).code === code;\n}\n\nfunction sleep(milliseconds: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, milliseconds));\n}\n\nexport { DEFAULT_PROFILE };\n"],"mappings":"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","names":["resolve"]}
1
+ {"version":3,"sources":["../src/state.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { AsyncLocalStorage } from \"node:async_hooks\";\nimport {\n mkdir,\n readFile,\n rename,\n rmdir,\n rm,\n writeFile,\n} from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, isAbsolute, join, relative, resolve } from \"node:path\";\n\nconst DEFAULT_PROFILE = \"default\";\nconst DEFAULT_LOCK_TIMEOUT_MS = 2_000;\nconst DEFAULT_LOCK_RETRY_MS = 25;\nconst DEFAULT_STALE_LOCK_MS = 30_000;\nconst TEST_LOCK_CONTENTION_SIGNAL_PATH = \"TC_TEST_PROFILE_LOCK_CONTENTION_SIGNAL_PATH\";\nconst TEST_LOCK_RECOVERY_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_RECOVERY_BARRIER_DIR\";\nconst invocationStateRoot = new AsyncLocalStorage<string>();\n\nexport type ProfileStoreName =\n | \"session\"\n | \"additional-delegations\"\n | \"auth-requests\";\n\nexport interface StoreMetadata {\n formatVersion: number;\n}\n\nexport interface ProfileStoreContents<T> {\n formatVersion: number;\n records: T[];\n}\n\nexport interface ProfileLockOptions {\n timeoutMs?: number;\n retryMs?: number;\n staleAfterMs?: number;\n}\n\nexport class ProfileLockTimeoutError extends Error {\n readonly code = \"PROFILE_LOCK_TIMEOUT\";\n\n constructor(profile: string, timeoutMs: number) {\n super(`Timed out waiting for the profile lock for \"${profile}\" after ${timeoutMs}ms.`);\n this.name = \"ProfileLockTimeoutError\";\n }\n}\n\n/**\n * The CLI's delegated-secret path treats TC_HOME as a home directory, rather\n * than a direct TinyCloud directory. Keep the shared store on that convention.\n */\nexport function tinycloudHomePath(): string {\n const home = invocationStateRoot.getStore() ??\n process.env.TC_HOME ??\n process.env.HOME ??\n process.env.USERPROFILE ??\n homedir();\n return join(home, \".tinycloud\");\n}\n\n/** Run one operation against an isolated TinyCloud home directory. */\nexport function withTinyCloudStateRoot<T>(\n stateRoot: string | undefined,\n action: () => Promise<T>,\n): Promise<T> {\n if (stateRoot === undefined) return action();\n if (!isAbsolute(stateRoot) || stateRoot.includes(\"\\0\")) {\n throw new TypeError(\"The TinyCloud state root must be an absolute path.\");\n }\n return invocationStateRoot.run(resolve(stateRoot), action);\n}\n\nexport function tinycloudConfigPath(): string {\n return join(tinycloudHomePath(), \"config.json\");\n}\n\nexport function profilesPath(): string {\n return join(tinycloudHomePath(), \"profiles\");\n}\n\nexport function profilePath(profile: string): string {\n return join(profilesPath(), validateProfileName(profile));\n}\n\nexport function profileConfigPath(profile: string): string {\n return join(profilePath(profile), \"profile.json\");\n}\n\nexport function sessionPath(profile: string): string {\n return profileStorePath(profile, \"session\");\n}\n\nexport function additionalDelegationsPath(profile: string): string {\n return profileStorePath(profile, \"additional-delegations\");\n}\n\nexport function authRequestsPath(profile: string): string {\n return profileStorePath(profile, \"auth-requests\");\n}\n\nexport function profileStorePath(profile: string, store: ProfileStoreName): string {\n return join(profilePath(profile), `${store}.json`);\n}\n\n/** The format record is deliberately separate from each legacy JSON payload. */\nexport function profileStoreMetadataPath(profile: string, store: ProfileStoreName): string {\n return `${profileStorePath(profile, store)}.metadata.json`;\n}\n\nexport function profileLockPath(profile: string): string {\n return join(profilePath(profile), \".lock\");\n}\n\nexport function profileLockMetadataPath(profile: string): string {\n return join(profileLockPath(profile), \"owner.json\");\n}\n\n/**\n * Reads JSON without hiding malformed or inaccessible files. A missing file is\n * the only condition represented as null.\n */\nexport async function readJson<T>(filePath: string): Promise<T | null> {\n try {\n return JSON.parse(await readFile(filePath, \"utf8\")) as T;\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return null;\n throw error;\n }\n}\n\n/**\n * Writes the same pretty-printed, trailing-newline JSON shape used by the CLI,\n * but publishes it with an atomic rename from the same directory.\n */\nexport async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {\n const directory = dirname(filePath);\n const temporaryPath = join(\n directory,\n `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,\n );\n const contents = `${JSON.stringify(value, null, 2)}\\n`;\n\n await mkdir(directory, { recursive: true });\n try {\n await writeFile(temporaryPath, contents, \"utf8\");\n await rename(temporaryPath, filePath);\n } catch (error) {\n await rm(temporaryPath, { force: true }).catch(() => undefined);\n throw error;\n }\n}\n\nexport async function readProfile<T extends object = Record<string, unknown>>(\n profile: string,\n): Promise<T | null> {\n return readJson<T>(profileConfigPath(profile));\n}\n\nexport async function readSession<T extends object = Record<string, unknown>>(\n profile: string,\n): Promise<T | null> {\n await readStoreMetadata(profile, \"session\");\n return readJson<T>(sessionPath(profile));\n}\n\nexport async function readAdditionalDelegations<T = Record<string, unknown>>(\n profile: string,\n): Promise<T[]> {\n return (await readProfileStore<T>(profile, \"additional-delegations\")).records;\n}\n\nexport async function readAuthRequests<T = Record<string, unknown>>(\n profile: string,\n): Promise<T[]> {\n return (await readProfileStore<T>(profile, \"auth-requests\")).records;\n}\n\nexport async function readStoreMetadata(\n profile: string,\n store: ProfileStoreName,\n): Promise<StoreMetadata> {\n const metadata = await readJson<StoreMetadata>(profileStoreMetadataPath(profile, store));\n if (metadata === null) return { formatVersion: 1 };\n if (\n typeof metadata !== \"object\" ||\n metadata.formatVersion !== 1\n ) {\n throw new TypeError(`Unsupported store format for \"${store}\".`);\n }\n return metadata;\n}\n\nexport async function readProfileStore<T>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n): Promise<ProfileStoreContents<T>> {\n const [metadata, raw] = await Promise.all([\n readStoreMetadata(profile, store),\n readJson<unknown>(profileStorePath(profile, store)),\n ]);\n return {\n formatVersion: metadata.formatVersion,\n records: Array.isArray(raw) ? raw as T[] : [],\n };\n}\n\n/**\n * Runs a small critical section under the one advisory lock shared by all\n * profile stores. Lock release verifies its ownership token before removing\n * the exact metadata instance it acquired.\n */\nexport async function withProfileLock<T>(\n profile: string,\n action: () => Promise<T>,\n options: ProfileLockOptions = {},\n): Promise<T> {\n const normalizedProfile = validateProfileName(profile);\n const release = await acquireProfileLock(normalizedProfile, options);\n try {\n return await action();\n } finally {\n await release();\n }\n}\n\n/**\n * Appends a record or replaces the existing record with the supplied explicit\n * key. The extractor is supplied by the owning store so state.ts never needs\n * to know permission-request or delegation record shapes.\n */\nexport async function upsertProfileRecord<T>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n key: string,\n record: T,\n getKey: (candidate: T) => string | undefined,\n options: ProfileLockOptions = {},\n): Promise<T[]> {\n if (!key) throw new TypeError(\"A non-empty record key is required.\");\n\n return withProfileLock(profile, async () => {\n const current = (await readProfileStore<T>(profile, store)).records;\n const next = current.filter((candidate) => getKey(candidate) !== key);\n next.push(record);\n await writeJsonAtomic(profileStorePath(profile, store), next);\n await writeFormatOneMetadata(profile, store);\n return next;\n }, options);\n}\n\n/**\n * Performs a typed read-modify-write of a legacy array store under the one\n * per-profile lock. The payload stays an array and the sibling metadata stays\n * format 1, so existing CLI readers keep their byte/layout contract.\n */\nexport async function updateProfileStore<T, Result>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n update: (\n records: readonly T[],\n ) => Promise<{ readonly records: readonly T[]; readonly result: Result }> | {\n readonly records: readonly T[];\n readonly result: Result;\n },\n options: ProfileLockOptions = {},\n): Promise<Result> {\n return withProfileLock(\n profile,\n () => updateProfileStoreWhileLocked(profile, store, update),\n options,\n );\n}\n\n/** The caller must already own this profile's lock. */\nasync function updateProfileStoreWhileLocked<T, Result>(\n profile: string,\n store: Exclude<ProfileStoreName, \"session\">,\n update: (\n records: readonly T[],\n ) => Promise<{ readonly records: readonly T[]; readonly result: Result }> | {\n readonly records: readonly T[];\n readonly result: Result;\n },\n): Promise<Result> {\n const current = await readProfileStore<T>(profile, store);\n const next = await update(current.records);\n await writeJsonAtomic(profileStorePath(profile, store), next.records);\n await writeFormatOneMetadata(profile, store);\n return next.result;\n}\n\nexport async function writeSession<T extends object>(\n profile: string,\n session: T,\n options: ProfileLockOptions = {},\n): Promise<void> {\n await withProfileLock(profile, async () => {\n await readStoreMetadata(profile, \"session\");\n await writeJsonAtomic(sessionPath(profile), session);\n await writeFormatOneMetadata(profile, \"session\");\n }, options);\n}\n\nexport async function removeSession(\n profile: string,\n options: ProfileLockOptions = {},\n): Promise<void> {\n await withProfileLock(profile, async () => {\n await readStoreMetadata(profile, \"session\");\n await rm(sessionPath(profile), { force: true });\n }, options);\n}\n\nasync function writeFormatOneMetadata(profile: string, store: ProfileStoreName): Promise<void> {\n await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });\n}\n\nasync function acquireProfileLock(\n profile: string,\n options: ProfileLockOptions,\n): Promise<() => Promise<void>> {\n const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;\n const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;\n const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;\n const startedAt = Date.now();\n const lockPath = profileLockPath(profile);\n\n await mkdir(profilePath(profile), { recursive: true });\n\n while (true) {\n try {\n await mkdir(lockPath);\n const token = randomUUID();\n try {\n await writeJsonAtomic(profileLockMetadataPath(profile), {\n pid: process.pid,\n createdAt: new Date().toISOString(),\n token,\n });\n } catch (error) {\n await rmdir(lockPath).catch(() => undefined);\n throw error;\n }\n\n return async () => {\n await releaseProfileLock(lockPath, token);\n };\n } catch (error) {\n if (!isLockAlreadyHeld(error)) throw error;\n await signalTestLockContention(profile);\n }\n\n if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;\n\n const elapsedMs = Date.now() - startedAt;\n if (elapsedMs >= timeoutMs) {\n throw new ProfileLockTimeoutError(profile, timeoutMs);\n }\n await sleep(Math.min(retryMs, timeoutMs - elapsedMs));\n }\n}\n\nasync function releaseProfileLock(lockPath: string, token: string): Promise<void> {\n const ownerPath = join(lockPath, \"owner.json\");\n const claimPath = join(lockPath, `.release-${token}.json`);\n try {\n await rename(ownerPath, claimPath);\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return;\n throw error;\n }\n\n const claimed = await readJson<{ token?: unknown }>(claimPath).catch(() => null);\n if (claimed?.token !== token) {\n await rename(claimPath, ownerPath).catch(() => undefined);\n return;\n }\n\n await rm(claimPath, { force: true });\n await rmdir(lockPath).catch(() => undefined);\n}\n\n/**\n * Gives process-level contention tests an event-driven witness that a second\n * writer has reached an already-held lock. This deliberately has no effect\n * outside an explicit test environment, and only creates a new signal file\n * beneath TC_HOME; it never changes lock acquisition or release semantics.\n */\nasync function signalTestLockContention(profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n\n const configuredPath = process.env[TEST_LOCK_CONTENTION_SIGNAL_PATH];\n const configuredHome = process.env.TC_HOME;\n if (!configuredPath || !configuredHome) return;\n\n const home = resolve(configuredHome);\n const signalPath = resolve(configuredPath);\n const pathFromHome = relative(home, signalPath);\n if (\n pathFromHome === \"\" ||\n pathFromHome === \"..\" ||\n pathFromHome.startsWith(`..${process.platform === \"win32\" ? \"\\\\\" : \"/\"}`) ||\n pathFromHome.startsWith(\"../\") ||\n pathFromHome.startsWith(\"..\\\\\")\n ) {\n return;\n }\n\n await writeFile(signalPath, `${profile}\\n`, { encoding: \"utf8\", flag: \"wx\" })\n .catch(() => undefined);\n}\n\nasync function recoverStaleLock(profile: string, lockPath: string, staleAfterMs: number): Promise<boolean> {\n const ownerPath = join(lockPath, \"owner.json\");\n const owner = await readJson<{ pid?: unknown; createdAt?: unknown; token?: unknown }>(ownerPath)\n .catch(() => null);\n if (!isStaleOwner(owner, staleAfterMs)) return false;\n\n // Claim the observed metadata file, rather than renaming/removing the lock\n // directory. A contender can only acquire the directory after it is empty;\n // rmdir below therefore cannot remove a replacement lock instance.\n const observedToken = typeof owner?.token === \"string\" && owner.token.length > 0\n ? owner.token\n : \"legacy\";\n await waitForTestStaleRecoveryBarrier(profile);\n const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);\n try {\n await rename(ownerPath, claimPath);\n } catch (error) {\n if (isErrno(error, \"ENOENT\")) return false;\n return false;\n }\n\n const claimed = await readJson<{ pid?: unknown; createdAt?: unknown; token?: unknown }>(claimPath)\n .catch(() => null);\n const sameInstance = claimed !== null && (\n observedToken === \"legacy\"\n ? claimed.token === undefined\n : claimed.token === observedToken\n );\n if (!sameInstance) {\n await rename(claimPath, ownerPath).catch(() => undefined);\n return false;\n }\n\n await rm(claimPath, { force: true });\n await rmdir(lockPath).catch(() => undefined);\n return true;\n}\n\nasync function waitForTestStaleRecoveryBarrier(profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n const barrierDirectory = process.env[TEST_LOCK_RECOVERY_BARRIER_DIR];\n if (!barrierDirectory) return;\n await mkdir(barrierDirectory, { recursive: true });\n const readyPath = join(barrierDirectory, `ready-${process.pid}-${profile}`);\n await writeFile(readyPath, \"ready\\n\", { encoding: \"utf8\", flag: \"wx\" }).catch(() => undefined);\n const releasePath = join(barrierDirectory, \"release\");\n while (true) {\n try {\n await readFile(releasePath, \"utf8\");\n return;\n } catch {\n await sleep(1);\n }\n }\n}\n\nfunction isStaleOwner(\n owner: { pid?: unknown; createdAt?: unknown } | null,\n staleAfterMs: number,\n): boolean {\n const now = Date.now();\n const createdAt = typeof owner?.createdAt === \"string\"\n ? Date.parse(owner.createdAt)\n : Number.NaN;\n\n // Reclaim only a fully published lock whose owner PID is confirmed dead.\n // An ownerless or malformed directory may still be between mkdir and\n // metadata publication, so reclaiming it could let two writers enter the\n // critical section. Current writers claim .lock with exclusive mkdir before\n // publishing owner metadata and therefore take the same conservative path.\n if (!Number.isFinite(createdAt) || now - createdAt < staleAfterMs) return false;\n if (typeof owner?.pid !== \"number\" || !Number.isInteger(owner.pid) || owner.pid <= 0) {\n return false;\n }\n return !isProcessAlive(owner.pid);\n}\n\nfunction isLockAlreadyHeld(error: unknown): boolean {\n return isErrno(error, \"EEXIST\") || isErrno(error, \"ENOTEMPTY\");\n}\n\nfunction isProcessAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return !isErrno(error, \"ESRCH\");\n }\n}\n\nfunction validateProfileName(profile: string): string {\n if (\n !profile ||\n profile === \".\" ||\n profile === \"..\" ||\n profile.includes(\"/\") ||\n profile.includes(\"\\\\\") ||\n profile.includes(\"\\0\")\n ) {\n throw new TypeError(\"Profile names must be non-empty path segments.\");\n }\n return profile;\n}\n\nfunction isErrno(error: unknown, code: string): boolean {\n return typeof error === \"object\" && error !== null && \"code\" in error &&\n (error as { code?: unknown }).code === code;\n}\n\nfunction sleep(milliseconds: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, milliseconds));\n}\n\nexport { DEFAULT_PROFILE };\n"],"mappings":"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","names":["resolve"]}
package/dist/state.d.cts CHANGED
@@ -21,6 +21,8 @@ declare class ProfileLockTimeoutError extends Error {
21
21
  * than a direct TinyCloud directory. Keep the shared store on that convention.
22
22
  */
23
23
  declare function tinycloudHomePath(): string;
24
+ /** Run one operation against an isolated TinyCloud home directory. */
25
+ declare function withTinyCloudStateRoot<T>(stateRoot: string | undefined, action: () => Promise<T>): Promise<T>;
24
26
  declare function tinycloudConfigPath(): string;
25
27
  declare function profilesPath(): string;
26
28
  declare function profilePath(profile: string): string;
@@ -76,4 +78,4 @@ declare function updateProfileStore<T, Result>(profile: string, store: Exclude<P
76
78
  declare function writeSession<T extends object>(profile: string, session: T, options?: ProfileLockOptions): Promise<void>;
77
79
  declare function removeSession(profile: string, options?: ProfileLockOptions): Promise<void>;
78
80
 
79
- export { DEFAULT_PROFILE, type ProfileLockOptions, ProfileLockTimeoutError, type ProfileStoreContents, type ProfileStoreName, type StoreMetadata, additionalDelegationsPath, authRequestsPath, profileConfigPath, profileLockMetadataPath, profileLockPath, profilePath, profileStoreMetadataPath, profileStorePath, profilesPath, readAdditionalDelegations, readAuthRequests, readJson, readProfile, readProfileStore, readSession, readStoreMetadata, removeSession, sessionPath, tinycloudConfigPath, tinycloudHomePath, updateProfileStore, upsertProfileRecord, withProfileLock, writeJsonAtomic, writeSession };
81
+ export { DEFAULT_PROFILE, type ProfileLockOptions, ProfileLockTimeoutError, type ProfileStoreContents, type ProfileStoreName, type StoreMetadata, additionalDelegationsPath, authRequestsPath, profileConfigPath, profileLockMetadataPath, profileLockPath, profilePath, profileStoreMetadataPath, profileStorePath, profilesPath, readAdditionalDelegations, readAuthRequests, readJson, readProfile, readProfileStore, readSession, readStoreMetadata, removeSession, sessionPath, tinycloudConfigPath, tinycloudHomePath, updateProfileStore, upsertProfileRecord, withProfileLock, withTinyCloudStateRoot, writeJsonAtomic, writeSession };
package/dist/state.d.ts CHANGED
@@ -21,6 +21,8 @@ declare class ProfileLockTimeoutError extends Error {
21
21
  * than a direct TinyCloud directory. Keep the shared store on that convention.
22
22
  */
23
23
  declare function tinycloudHomePath(): string;
24
+ /** Run one operation against an isolated TinyCloud home directory. */
25
+ declare function withTinyCloudStateRoot<T>(stateRoot: string | undefined, action: () => Promise<T>): Promise<T>;
24
26
  declare function tinycloudConfigPath(): string;
25
27
  declare function profilesPath(): string;
26
28
  declare function profilePath(profile: string): string;
@@ -76,4 +78,4 @@ declare function updateProfileStore<T, Result>(profile: string, store: Exclude<P
76
78
  declare function writeSession<T extends object>(profile: string, session: T, options?: ProfileLockOptions): Promise<void>;
77
79
  declare function removeSession(profile: string, options?: ProfileLockOptions): Promise<void>;
78
80
 
79
- export { DEFAULT_PROFILE, type ProfileLockOptions, ProfileLockTimeoutError, type ProfileStoreContents, type ProfileStoreName, type StoreMetadata, additionalDelegationsPath, authRequestsPath, profileConfigPath, profileLockMetadataPath, profileLockPath, profilePath, profileStoreMetadataPath, profileStorePath, profilesPath, readAdditionalDelegations, readAuthRequests, readJson, readProfile, readProfileStore, readSession, readStoreMetadata, removeSession, sessionPath, tinycloudConfigPath, tinycloudHomePath, updateProfileStore, upsertProfileRecord, withProfileLock, writeJsonAtomic, writeSession };
81
+ export { DEFAULT_PROFILE, type ProfileLockOptions, ProfileLockTimeoutError, type ProfileStoreContents, type ProfileStoreName, type StoreMetadata, additionalDelegationsPath, authRequestsPath, profileConfigPath, profileLockMetadataPath, profileLockPath, profilePath, profileStoreMetadataPath, profileStorePath, profilesPath, readAdditionalDelegations, readAuthRequests, readJson, readProfile, readProfileStore, readSession, readStoreMetadata, removeSession, sessionPath, tinycloudConfigPath, tinycloudHomePath, updateProfileStore, upsertProfileRecord, withProfileLock, withTinyCloudStateRoot, writeJsonAtomic, writeSession };
package/dist/state.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/state.ts
2
2
  import { randomUUID } from "crypto";
3
+ import { AsyncLocalStorage } from "async_hooks";
3
4
  import {
4
5
  mkdir,
5
6
  readFile,
@@ -9,13 +10,14 @@ import {
9
10
  writeFile
10
11
  } from "fs/promises";
11
12
  import { homedir } from "os";
12
- import { basename, dirname, join, relative, resolve } from "path";
13
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
13
14
  var DEFAULT_PROFILE = "default";
14
15
  var DEFAULT_LOCK_TIMEOUT_MS = 2e3;
15
16
  var DEFAULT_LOCK_RETRY_MS = 25;
16
17
  var DEFAULT_STALE_LOCK_MS = 3e4;
17
18
  var TEST_LOCK_CONTENTION_SIGNAL_PATH = "TC_TEST_PROFILE_LOCK_CONTENTION_SIGNAL_PATH";
18
19
  var TEST_LOCK_RECOVERY_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RECOVERY_BARRIER_DIR";
20
+ var invocationStateRoot = new AsyncLocalStorage();
19
21
  var ProfileLockTimeoutError = class extends Error {
20
22
  constructor(profile, timeoutMs) {
21
23
  super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.`);
@@ -24,9 +26,16 @@ var ProfileLockTimeoutError = class extends Error {
24
26
  }
25
27
  };
26
28
  function tinycloudHomePath() {
27
- const home = process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
29
+ const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
28
30
  return join(home, ".tinycloud");
29
31
  }
32
+ function withTinyCloudStateRoot(stateRoot, action) {
33
+ if (stateRoot === void 0) return action();
34
+ if (!isAbsolute(stateRoot) || stateRoot.includes("\0")) {
35
+ throw new TypeError("The TinyCloud state root must be an absolute path.");
36
+ }
37
+ return invocationStateRoot.run(resolve(stateRoot), action);
38
+ }
30
39
  function tinycloudConfigPath() {
31
40
  return join(tinycloudHomePath(), "config.json");
32
41
  }
@@ -331,6 +340,7 @@ export {
331
340
  updateProfileStore,
332
341
  upsertProfileRecord,
333
342
  withProfileLock,
343
+ withTinyCloudStateRoot,
334
344
  writeJsonAtomic,
335
345
  writeSession
336
346
  };