@tinycloud/operations 0.3.3-beta.9 → 0.3.3
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/coverage.json +1 -0
- package/dist/artifacts.cjs +115 -54
- package/dist/artifacts.cjs.map +1 -1
- package/dist/artifacts.js +119 -54
- package/dist/artifacts.js.map +1 -1
- package/dist/cli-runtime.cjs +117 -56
- package/dist/cli-runtime.cjs.map +1 -1
- package/dist/cli-runtime.js +121 -56
- package/dist/cli-runtime.js.map +1 -1
- package/dist/index.cjs +117 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +121 -56
- package/dist/index.js.map +1 -1
- package/dist/profile.cjs +2 -1
- package/dist/profile.cjs.map +1 -1
- package/dist/profile.js +6 -1
- package/dist/profile.js.map +1 -1
- package/dist/state.cjs +105 -42
- package/dist/state.cjs.map +1 -1
- package/dist/state.d.cts +8 -0
- package/dist/state.d.ts +8 -0
- package/dist/state.js +109 -42
- package/dist/state.js.map +1 -1
- package/package.json +3 -3
package/dist/profile.cjs
CHANGED
|
@@ -35,6 +35,7 @@ var import_node_os = require("os");
|
|
|
35
35
|
var import_node_path = require("path");
|
|
36
36
|
var DEFAULT_PROFILE = "default";
|
|
37
37
|
var invocationStateRoot = new import_node_async_hooks.AsyncLocalStorage();
|
|
38
|
+
var heldProfileLocks = new import_node_async_hooks.AsyncLocalStorage();
|
|
38
39
|
function tinycloudHomePath() {
|
|
39
40
|
const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? (0, import_node_os.homedir)();
|
|
40
41
|
return (0, import_node_path.join)(home, ".tinycloud");
|
|
@@ -73,7 +74,7 @@ function isErrno(error, code) {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
// src/profile.ts
|
|
76
|
-
var DEFAULT_HOST = "https://node.tinycloud.xyz";
|
|
77
|
+
var DEFAULT_HOST = "https://tee.node.tinycloud.xyz";
|
|
77
78
|
async function resolveInvocationContext(target = {}) {
|
|
78
79
|
const resolved = await resolveInvocationProfile(target);
|
|
79
80
|
return resolved.ok ? { ok: true, context: resolved.context } : resolved;
|
package/dist/profile.cjs.map
CHANGED
|
@@ -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 { 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
|
+
{"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://tee.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 chmod,\n link,\n mkdir,\n readFile,\n readdir,\n rename,\n rmdir,\n rm,\n stat,\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\";\n// Profile state holds keys, sessions and delegations: owner-only access.\nconst PRIVATE_FILE_MODE = 0o600;\nconst PRIVATE_DIR_MODE = 0o700;\nconst TEST_LOCK_RECOVERY_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_RECOVERY_BARRIER_DIR\";\nconst TEST_LOCK_OWNERLESS_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_OWNERLESS_BARRIER_DIR\";\nconst TEST_LOCK_PUBLISH_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_PUBLISH_BARRIER_DIR\";\nconst TEST_LOCK_RELEASE_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR\";\nconst TEST_LOCK_CLAIM_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR\";\nconst TEST_LOCK_CLAIMED_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR\";\nconst TEST_LOCK_FENCED_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR\";\nconst TEST_LOCK_VERIFIED_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR\";\nconst invocationStateRoot = new AsyncLocalStorage<string>();\n/** One acquisition of a profile lock; `active` is cleared before release. */\ninterface HeldProfileLock {\n readonly lockPath: string;\n active: boolean;\n}\n/** Profile lock acquisitions held by the current async call chain (see withProfileLock). */\nconst heldProfileLocks = new AsyncLocalStorage<readonly HeldProfileLock[]>();\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, mode: PRIVATE_DIR_MODE });\n try {\n await writeFile(temporaryPath, contents, { encoding: \"utf8\", mode: PRIVATE_FILE_MODE });\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 *\n * Reentrant within one async call chain: a critical section that already\n * holds a profile's lock (for example a login's compare-and-commit) can call\n * store writers that take the same lock without deadlocking. Ownership is\n * keyed by the resolved lock path (so switching state roots inside a lock\n * still takes the other root's lock) and lasts exactly as long as the\n * acquisition: it is revoked before release, so work deferred past the\n * critical section waits for the lock like any other caller.\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 lockPath = profileLockPath(normalizedProfile);\n const held = (heldProfileLocks.getStore() ?? []).filter((ownership) => ownership.active);\n if (held.some((ownership) => ownership.lockPath === lockPath)) return action();\n const release = await acquireProfileLock(normalizedProfile, options);\n const ownership: HeldProfileLock = { lockPath, active: true };\n try {\n return await heldProfileLocks.run([...held, ownership], action);\n } finally {\n ownership.active = false;\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, mode: PRIVATE_DIR_MODE });\n // Every store write takes this lock: tighten the tree older releases\n // created 0775 before writing sessions or delegations into it.\n for (const directory of [tinycloudHomePath(), profilesPath(), profilePath(profile)]) {\n await chmod(directory, PRIVATE_DIR_MODE);\n }\n\n while (true) {\n const token = randomUUID();\n if (await publishProfileLock(profile, lockPath, token)) {\n return async () => {\n await releaseProfileLock(profile, lockPath, token);\n };\n }\n await signalTestLockContention(profile);\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\n/**\n * One attempt to take the lock. Nothing here replaces an existing path:\n *\n * 1. `mkdir(.lock)` (never recursive) is exclusive, and is the primitive\n * older releases use too, so they exclude each other.\n * 2. Ownership is published by `link`ing a fully written owner file (staged\n * beside `.lock`, on the same filesystem) to `.lock/owner.json`. `link`\n * fails with EEXIST if an owner exists, and with ENOENT if the directory\n * was removed meanwhile (an aged ownerless-lock reclaim). Only a\n * successful link holds the lock; on either failure nothing is released,\n * since nothing was acquired, and the caller retries.\n *\n * Only one owner.json can exist at the lock path, and it is removed only by\n * its holder's release or by recovery of a dead holder, so at most one\n * process holds the lock. Returns false when the lock is not acquired.\n */\nasync function publishProfileLock(profile: string, lockPath: string, token: string): Promise<boolean> {\n try {\n await mkdir(lockPath, { mode: PRIVATE_DIR_MODE });\n } catch (error) {\n if (isErrno(error, \"EEXIST\")) return false;\n throw error;\n }\n await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);\n const staged = join(dirname(lockPath), `.lock-owner-${token}.tmp`);\n const owner = { pid: process.pid, createdAt: new Date().toISOString(), token };\n try {\n await writeFile(staged, `${JSON.stringify(owner, null, 2)}\\n`, { encoding: \"utf8\", mode: PRIVATE_FILE_MODE, flag: \"wx\" });\n await link(staged, join(lockPath, \"owner.json\"));\n return true;\n } catch (error) {\n if (isErrno(error, \"EEXIST\") || isErrno(error, \"ENOENT\")) return false;\n // Do not leave an ownerless lock behind for others to wait out. rmdir\n // removes only an empty directory; anyone who created it after ours went\n // away sees ENOENT on link and retries.\n await rmdir(lockPath).catch(() => undefined);\n throw error;\n } finally {\n await rm(staged, { force: true }).catch(() => undefined);\n }\n}\n\n/**\n * Releases the lock if `.lock/owner.json` is still this acquisition's. No\n * one else can put an owner record there while it exists (owners are only\n * linked into a directory their own `mkdir` created, and recovery removes\n * only a dead holder's record), so the record read is the one unlinked.\n */\nasync function releaseProfileLock(profile: string, lockPath: string, token: string): Promise<void> {\n const ownerPath = join(lockPath, \"owner.json\");\n const owner = await readJson<{ token?: unknown }>(ownerPath).catch(() => null);\n if (owner?.token !== token) return;\n await rm(ownerPath, { force: true });\n // `.lock` is empty from here until the rmdir; a contender that removes it\n // and creates its own meanwhile is not harmed: rmdir fails on its owner\n // file, or the contender's link sees ENOENT and retries. If a recoverer's\n // claim file is still inside, rmdir fails and that recoverer removes the\n // directory when it drops its claim.\n await waitForTestBarrier(TEST_LOCK_RELEASE_BARRIER_DIR, profile);\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\n/**\n * Reclaims a lock directory with no owner record that is older than the\n * stale threshold: one left by a crash, or by an older release. A process\n * killed while recovering (or releasing, in older releases) can leave its\n * `.stale-*` / `.release-*` claim file behind; such claim files are removed\n * first. owner.json is never touched, and the directory itself is only\n * `rmdir`ed, which succeeds only while it is empty, so a published lock is\n * never removed. If the directory was a contender's lock not yet published,\n * that contender's `link` fails with ENOENT and it retries without holding\n * anything.\n */\nasync function recoverOwnerlessLock(profile: string, lockPath: string, staleAfterMs: number): Promise<boolean> {\n try {\n const { mtimeMs } = await stat(lockPath);\n if (Date.now() - mtimeMs < staleAfterMs) return false;\n } catch {\n return false;\n }\n const checkedAt = performance.now();\n await waitForTestBarrier(TEST_LOCK_OWNERLESS_BARRIER_DIR, profile);\n try {\n const entries = await readdir(lockPath);\n if (entries.includes(\"owner.json\")) return false;\n // The age check above is only good for a while; see recoveryFenced.\n if (recoveryFenced(checkedAt, staleAfterMs)) return false;\n for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {\n await rm(join(lockPath, name), { force: true });\n }\n await rmdir(lockPath);\n } catch {\n return false;\n }\n await removeAgedOwnerFiles(lockPath, staleAfterMs);\n return true;\n}\n\n/**\n * Whether a recovery step decided at `since` (a monotonic timestamp) is too\n * old to act on. Claim-only and ownerless cleanup act only on a `.lock`\n * unchanged for `staleAfterMs`, and a recoverer's claim (or the change that\n * made a directory look abandoned) dates the directory no earlier than\n * `since`. Acting within half that window means no such cleanup can have\n * removed the directory and let a new holder in meanwhile; past it, the\n * recoverer drops only its own claim and starts the acquisition over.\n */\nfunction recoveryFenced(since: number, staleAfterMs: number): boolean {\n return performance.now() - since >= staleAfterMs / 2;\n}\n\n/** Claim files of stale recovery (and of release in older releases). */\nconst ABANDONED_CLAIM = /^\\.(?:release|stale)-[0-9a-f-]+\\.json$/;\n/** Owner files staged by publishProfileLock beside `.lock`. */\nconst STAGED_OWNER = /^\\.lock-owner-[0-9a-f-]+\\.tmp$/;\n\n/**\n * Removes owner files a crashed acquirer staged beside `.lock`. A live\n * acquirer's staged file exists only for its write and link, so only files\n * older than the stale threshold are removed. Runs after a recovery, never\n * on the uncontended path.\n */\nasync function removeAgedOwnerFiles(lockPath: string, staleAfterMs: number): Promise<void> {\n const directory = dirname(lockPath);\n const names = await readdir(directory).catch(() => []);\n for (const name of names.filter((entry) => STAGED_OWNER.test(entry))) {\n const path = join(directory, name);\n const aged = await stat(path).then(({ mtimeMs }) => Date.now() - mtimeMs >= staleAfterMs, () => false);\n if (aged) await rm(path, { force: true }).catch(() => undefined);\n }\n}\n\n/**\n * Removes a dead holder's lock. The claim is a hard link to owner.json, so\n * the owner record is never moved away: a recoverer whose observation is\n * outdated (the dead holder's lock was already recovered and a live holder\n * now owns `.lock`) sees another token, drops its link and leaves the live\n * owner record in place. Only after the claimed record is confirmed to be\n * the observed dead holder's is owner.json unlinked. Nothing can link a new\n * owner.json meanwhile: the claim keeps `.lock` non-empty, so it cannot be\n * removed and recreated.\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 (owner === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);\n if (!isStaleOwner(owner, staleAfterMs)) return false;\n\n const observedToken = typeof owner?.token === \"string\" && owner.token.length > 0\n ? owner.token\n : \"legacy\";\n await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);\n const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);\n try {\n await link(ownerPath, claimPath);\n } catch {\n return false;\n }\n const claimedAt = performance.now();\n await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);\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 await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);\n if (sameInstance && recoveryFenced(claimedAt, staleAfterMs)) {\n // Paused too long: another recoverer may have finished this recovery and\n // a cleanup may have removed this claim, so owner.json may now be a live\n // holder's. Touch nothing but this claim.\n await rm(claimPath, { force: true });\n await waitForTestBarrier(TEST_LOCK_FENCED_BARRIER_DIR, profile);\n return false;\n }\n if (sameInstance) {\n await rm(ownerPath, { force: true });\n await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);\n }\n await rm(claimPath, { force: true });\n // Empty now if the claimed holder is gone or a live holder released while\n // this claim kept `.lock` from being removed: remove it rather than leave\n // an ownerless lock to age out. rmdir fails while an owner record exists.\n await rmdir(lockPath).catch(() => undefined);\n if (!sameInstance) return false;\n await removeAgedOwnerFiles(lockPath, staleAfterMs);\n return true;\n}\n\n/** Test-only rendezvous (NODE_ENV=test and the named barrier directory set). */\nasync function waitForTestBarrier(environmentName: string, profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n const barrierDirectory = process.env[environmentName];\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 // A lock without an owner record is handled by recoverOwnerlessLock: a\n // process holds the lock only once its owner.json is linked in.\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 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":[]}
|
package/dist/profile.js
CHANGED
|
@@ -2,17 +2,22 @@
|
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
3
|
import { AsyncLocalStorage } from "async_hooks";
|
|
4
4
|
import {
|
|
5
|
+
chmod,
|
|
6
|
+
link,
|
|
5
7
|
mkdir,
|
|
6
8
|
readFile,
|
|
9
|
+
readdir,
|
|
7
10
|
rename,
|
|
8
11
|
rmdir,
|
|
9
12
|
rm,
|
|
13
|
+
stat,
|
|
10
14
|
writeFile
|
|
11
15
|
} from "fs/promises";
|
|
12
16
|
import { homedir } from "os";
|
|
13
17
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
|
|
14
18
|
var DEFAULT_PROFILE = "default";
|
|
15
19
|
var invocationStateRoot = new AsyncLocalStorage();
|
|
20
|
+
var heldProfileLocks = new AsyncLocalStorage();
|
|
16
21
|
function tinycloudHomePath() {
|
|
17
22
|
const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
|
|
18
23
|
return join(home, ".tinycloud");
|
|
@@ -51,7 +56,7 @@ function isErrno(error, code) {
|
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
// src/profile.ts
|
|
54
|
-
var DEFAULT_HOST = "https://node.tinycloud.xyz";
|
|
59
|
+
var DEFAULT_HOST = "https://tee.node.tinycloud.xyz";
|
|
55
60
|
async function resolveInvocationContext(target = {}) {
|
|
56
61
|
const resolved = await resolveInvocationProfile(target);
|
|
57
62
|
return resolved.ok ? { ok: true, context: resolved.context } : resolved;
|
package/dist/profile.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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":[]}
|
|
1
|
+
{"version":3,"sources":["../src/state.ts","../src/profile.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { AsyncLocalStorage } from \"node:async_hooks\";\nimport {\n chmod,\n link,\n mkdir,\n readFile,\n readdir,\n rename,\n rmdir,\n rm,\n stat,\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\";\n// Profile state holds keys, sessions and delegations: owner-only access.\nconst PRIVATE_FILE_MODE = 0o600;\nconst PRIVATE_DIR_MODE = 0o700;\nconst TEST_LOCK_RECOVERY_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_RECOVERY_BARRIER_DIR\";\nconst TEST_LOCK_OWNERLESS_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_OWNERLESS_BARRIER_DIR\";\nconst TEST_LOCK_PUBLISH_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_PUBLISH_BARRIER_DIR\";\nconst TEST_LOCK_RELEASE_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR\";\nconst TEST_LOCK_CLAIM_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR\";\nconst TEST_LOCK_CLAIMED_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR\";\nconst TEST_LOCK_FENCED_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR\";\nconst TEST_LOCK_VERIFIED_BARRIER_DIR = \"TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR\";\nconst invocationStateRoot = new AsyncLocalStorage<string>();\n/** One acquisition of a profile lock; `active` is cleared before release. */\ninterface HeldProfileLock {\n readonly lockPath: string;\n active: boolean;\n}\n/** Profile lock acquisitions held by the current async call chain (see withProfileLock). */\nconst heldProfileLocks = new AsyncLocalStorage<readonly HeldProfileLock[]>();\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, mode: PRIVATE_DIR_MODE });\n try {\n await writeFile(temporaryPath, contents, { encoding: \"utf8\", mode: PRIVATE_FILE_MODE });\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 *\n * Reentrant within one async call chain: a critical section that already\n * holds a profile's lock (for example a login's compare-and-commit) can call\n * store writers that take the same lock without deadlocking. Ownership is\n * keyed by the resolved lock path (so switching state roots inside a lock\n * still takes the other root's lock) and lasts exactly as long as the\n * acquisition: it is revoked before release, so work deferred past the\n * critical section waits for the lock like any other caller.\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 lockPath = profileLockPath(normalizedProfile);\n const held = (heldProfileLocks.getStore() ?? []).filter((ownership) => ownership.active);\n if (held.some((ownership) => ownership.lockPath === lockPath)) return action();\n const release = await acquireProfileLock(normalizedProfile, options);\n const ownership: HeldProfileLock = { lockPath, active: true };\n try {\n return await heldProfileLocks.run([...held, ownership], action);\n } finally {\n ownership.active = false;\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, mode: PRIVATE_DIR_MODE });\n // Every store write takes this lock: tighten the tree older releases\n // created 0775 before writing sessions or delegations into it.\n for (const directory of [tinycloudHomePath(), profilesPath(), profilePath(profile)]) {\n await chmod(directory, PRIVATE_DIR_MODE);\n }\n\n while (true) {\n const token = randomUUID();\n if (await publishProfileLock(profile, lockPath, token)) {\n return async () => {\n await releaseProfileLock(profile, lockPath, token);\n };\n }\n await signalTestLockContention(profile);\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\n/**\n * One attempt to take the lock. Nothing here replaces an existing path:\n *\n * 1. `mkdir(.lock)` (never recursive) is exclusive, and is the primitive\n * older releases use too, so they exclude each other.\n * 2. Ownership is published by `link`ing a fully written owner file (staged\n * beside `.lock`, on the same filesystem) to `.lock/owner.json`. `link`\n * fails with EEXIST if an owner exists, and with ENOENT if the directory\n * was removed meanwhile (an aged ownerless-lock reclaim). Only a\n * successful link holds the lock; on either failure nothing is released,\n * since nothing was acquired, and the caller retries.\n *\n * Only one owner.json can exist at the lock path, and it is removed only by\n * its holder's release or by recovery of a dead holder, so at most one\n * process holds the lock. Returns false when the lock is not acquired.\n */\nasync function publishProfileLock(profile: string, lockPath: string, token: string): Promise<boolean> {\n try {\n await mkdir(lockPath, { mode: PRIVATE_DIR_MODE });\n } catch (error) {\n if (isErrno(error, \"EEXIST\")) return false;\n throw error;\n }\n await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);\n const staged = join(dirname(lockPath), `.lock-owner-${token}.tmp`);\n const owner = { pid: process.pid, createdAt: new Date().toISOString(), token };\n try {\n await writeFile(staged, `${JSON.stringify(owner, null, 2)}\\n`, { encoding: \"utf8\", mode: PRIVATE_FILE_MODE, flag: \"wx\" });\n await link(staged, join(lockPath, \"owner.json\"));\n return true;\n } catch (error) {\n if (isErrno(error, \"EEXIST\") || isErrno(error, \"ENOENT\")) return false;\n // Do not leave an ownerless lock behind for others to wait out. rmdir\n // removes only an empty directory; anyone who created it after ours went\n // away sees ENOENT on link and retries.\n await rmdir(lockPath).catch(() => undefined);\n throw error;\n } finally {\n await rm(staged, { force: true }).catch(() => undefined);\n }\n}\n\n/**\n * Releases the lock if `.lock/owner.json` is still this acquisition's. No\n * one else can put an owner record there while it exists (owners are only\n * linked into a directory their own `mkdir` created, and recovery removes\n * only a dead holder's record), so the record read is the one unlinked.\n */\nasync function releaseProfileLock(profile: string, lockPath: string, token: string): Promise<void> {\n const ownerPath = join(lockPath, \"owner.json\");\n const owner = await readJson<{ token?: unknown }>(ownerPath).catch(() => null);\n if (owner?.token !== token) return;\n await rm(ownerPath, { force: true });\n // `.lock` is empty from here until the rmdir; a contender that removes it\n // and creates its own meanwhile is not harmed: rmdir fails on its owner\n // file, or the contender's link sees ENOENT and retries. If a recoverer's\n // claim file is still inside, rmdir fails and that recoverer removes the\n // directory when it drops its claim.\n await waitForTestBarrier(TEST_LOCK_RELEASE_BARRIER_DIR, profile);\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\n/**\n * Reclaims a lock directory with no owner record that is older than the\n * stale threshold: one left by a crash, or by an older release. A process\n * killed while recovering (or releasing, in older releases) can leave its\n * `.stale-*` / `.release-*` claim file behind; such claim files are removed\n * first. owner.json is never touched, and the directory itself is only\n * `rmdir`ed, which succeeds only while it is empty, so a published lock is\n * never removed. If the directory was a contender's lock not yet published,\n * that contender's `link` fails with ENOENT and it retries without holding\n * anything.\n */\nasync function recoverOwnerlessLock(profile: string, lockPath: string, staleAfterMs: number): Promise<boolean> {\n try {\n const { mtimeMs } = await stat(lockPath);\n if (Date.now() - mtimeMs < staleAfterMs) return false;\n } catch {\n return false;\n }\n const checkedAt = performance.now();\n await waitForTestBarrier(TEST_LOCK_OWNERLESS_BARRIER_DIR, profile);\n try {\n const entries = await readdir(lockPath);\n if (entries.includes(\"owner.json\")) return false;\n // The age check above is only good for a while; see recoveryFenced.\n if (recoveryFenced(checkedAt, staleAfterMs)) return false;\n for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {\n await rm(join(lockPath, name), { force: true });\n }\n await rmdir(lockPath);\n } catch {\n return false;\n }\n await removeAgedOwnerFiles(lockPath, staleAfterMs);\n return true;\n}\n\n/**\n * Whether a recovery step decided at `since` (a monotonic timestamp) is too\n * old to act on. Claim-only and ownerless cleanup act only on a `.lock`\n * unchanged for `staleAfterMs`, and a recoverer's claim (or the change that\n * made a directory look abandoned) dates the directory no earlier than\n * `since`. Acting within half that window means no such cleanup can have\n * removed the directory and let a new holder in meanwhile; past it, the\n * recoverer drops only its own claim and starts the acquisition over.\n */\nfunction recoveryFenced(since: number, staleAfterMs: number): boolean {\n return performance.now() - since >= staleAfterMs / 2;\n}\n\n/** Claim files of stale recovery (and of release in older releases). */\nconst ABANDONED_CLAIM = /^\\.(?:release|stale)-[0-9a-f-]+\\.json$/;\n/** Owner files staged by publishProfileLock beside `.lock`. */\nconst STAGED_OWNER = /^\\.lock-owner-[0-9a-f-]+\\.tmp$/;\n\n/**\n * Removes owner files a crashed acquirer staged beside `.lock`. A live\n * acquirer's staged file exists only for its write and link, so only files\n * older than the stale threshold are removed. Runs after a recovery, never\n * on the uncontended path.\n */\nasync function removeAgedOwnerFiles(lockPath: string, staleAfterMs: number): Promise<void> {\n const directory = dirname(lockPath);\n const names = await readdir(directory).catch(() => []);\n for (const name of names.filter((entry) => STAGED_OWNER.test(entry))) {\n const path = join(directory, name);\n const aged = await stat(path).then(({ mtimeMs }) => Date.now() - mtimeMs >= staleAfterMs, () => false);\n if (aged) await rm(path, { force: true }).catch(() => undefined);\n }\n}\n\n/**\n * Removes a dead holder's lock. The claim is a hard link to owner.json, so\n * the owner record is never moved away: a recoverer whose observation is\n * outdated (the dead holder's lock was already recovered and a live holder\n * now owns `.lock`) sees another token, drops its link and leaves the live\n * owner record in place. Only after the claimed record is confirmed to be\n * the observed dead holder's is owner.json unlinked. Nothing can link a new\n * owner.json meanwhile: the claim keeps `.lock` non-empty, so it cannot be\n * removed and recreated.\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 (owner === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);\n if (!isStaleOwner(owner, staleAfterMs)) return false;\n\n const observedToken = typeof owner?.token === \"string\" && owner.token.length > 0\n ? owner.token\n : \"legacy\";\n await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);\n const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);\n try {\n await link(ownerPath, claimPath);\n } catch {\n return false;\n }\n const claimedAt = performance.now();\n await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);\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 await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);\n if (sameInstance && recoveryFenced(claimedAt, staleAfterMs)) {\n // Paused too long: another recoverer may have finished this recovery and\n // a cleanup may have removed this claim, so owner.json may now be a live\n // holder's. Touch nothing but this claim.\n await rm(claimPath, { force: true });\n await waitForTestBarrier(TEST_LOCK_FENCED_BARRIER_DIR, profile);\n return false;\n }\n if (sameInstance) {\n await rm(ownerPath, { force: true });\n await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);\n }\n await rm(claimPath, { force: true });\n // Empty now if the claimed holder is gone or a live holder released while\n // this claim kept `.lock` from being removed: remove it rather than leave\n // an ownerless lock to age out. rmdir fails while an owner record exists.\n await rmdir(lockPath).catch(() => undefined);\n if (!sameInstance) return false;\n await removeAgedOwnerFiles(lockPath, staleAfterMs);\n return true;\n}\n\n/** Test-only rendezvous (NODE_ENV=test and the named barrier directory set). */\nasync function waitForTestBarrier(environmentName: string, profile: string): Promise<void> {\n if (process.env.NODE_ENV !== \"test\") return;\n const barrierDirectory = process.env[environmentName];\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 // A lock without an owner record is handled by recoverOwnerlessLock: a\n // process holds the lock only once its owner.json is linked in.\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 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://tee.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
|
@@ -60,8 +60,18 @@ var DEFAULT_LOCK_TIMEOUT_MS = 2e3;
|
|
|
60
60
|
var DEFAULT_LOCK_RETRY_MS = 25;
|
|
61
61
|
var DEFAULT_STALE_LOCK_MS = 3e4;
|
|
62
62
|
var TEST_LOCK_CONTENTION_SIGNAL_PATH = "TC_TEST_PROFILE_LOCK_CONTENTION_SIGNAL_PATH";
|
|
63
|
+
var PRIVATE_FILE_MODE = 384;
|
|
64
|
+
var PRIVATE_DIR_MODE = 448;
|
|
63
65
|
var TEST_LOCK_RECOVERY_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RECOVERY_BARRIER_DIR";
|
|
66
|
+
var TEST_LOCK_OWNERLESS_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_OWNERLESS_BARRIER_DIR";
|
|
67
|
+
var TEST_LOCK_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_PUBLISH_BARRIER_DIR";
|
|
68
|
+
var TEST_LOCK_RELEASE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR";
|
|
69
|
+
var TEST_LOCK_CLAIM_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR";
|
|
70
|
+
var TEST_LOCK_CLAIMED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR";
|
|
71
|
+
var TEST_LOCK_FENCED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR";
|
|
72
|
+
var TEST_LOCK_VERIFIED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR";
|
|
64
73
|
var invocationStateRoot = new import_node_async_hooks.AsyncLocalStorage();
|
|
74
|
+
var heldProfileLocks = new import_node_async_hooks.AsyncLocalStorage();
|
|
65
75
|
var ProfileLockTimeoutError = class extends Error {
|
|
66
76
|
constructor(profile, timeoutMs) {
|
|
67
77
|
super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.`);
|
|
@@ -129,9 +139,9 @@ async function writeJsonAtomic(filePath, value) {
|
|
|
129
139
|
);
|
|
130
140
|
const contents = `${JSON.stringify(value, null, 2)}
|
|
131
141
|
`;
|
|
132
|
-
await (0, import_promises.mkdir)(directory, { recursive: true });
|
|
142
|
+
await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
133
143
|
try {
|
|
134
|
-
await (0, import_promises.writeFile)(temporaryPath, contents, "utf8");
|
|
144
|
+
await (0, import_promises.writeFile)(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
|
|
135
145
|
await (0, import_promises.rename)(temporaryPath, filePath);
|
|
136
146
|
} catch (error) {
|
|
137
147
|
await (0, import_promises.rm)(temporaryPath, { force: true }).catch(() => void 0);
|
|
@@ -171,10 +181,15 @@ async function readProfileStore(profile, store) {
|
|
|
171
181
|
}
|
|
172
182
|
async function withProfileLock(profile, action, options = {}) {
|
|
173
183
|
const normalizedProfile = validateProfileName(profile);
|
|
184
|
+
const lockPath = profileLockPath(normalizedProfile);
|
|
185
|
+
const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
|
|
186
|
+
if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
|
|
174
187
|
const release = await acquireProfileLock(normalizedProfile, options);
|
|
188
|
+
const ownership = { lockPath, active: true };
|
|
175
189
|
try {
|
|
176
|
-
return await action
|
|
190
|
+
return await heldProfileLocks.run([...held, ownership], action);
|
|
177
191
|
} finally {
|
|
192
|
+
ownership.active = false;
|
|
178
193
|
await release();
|
|
179
194
|
}
|
|
180
195
|
}
|
|
@@ -225,28 +240,18 @@ async function acquireProfileLock(profile, options) {
|
|
|
225
240
|
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
|
|
226
241
|
const startedAt = Date.now();
|
|
227
242
|
const lockPath = profileLockPath(profile);
|
|
228
|
-
await (0, import_promises.mkdir)(profilePath(profile), { recursive: true });
|
|
243
|
+
await (0, import_promises.mkdir)(profilePath(profile), { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
244
|
+
for (const directory of [tinycloudHomePath(), profilesPath(), profilePath(profile)]) {
|
|
245
|
+
await (0, import_promises.chmod)(directory, PRIVATE_DIR_MODE);
|
|
246
|
+
}
|
|
229
247
|
while (true) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const token = (0, import_node_crypto.randomUUID)();
|
|
233
|
-
try {
|
|
234
|
-
await writeJsonAtomic(profileLockMetadataPath(profile), {
|
|
235
|
-
pid: process.pid,
|
|
236
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
237
|
-
token
|
|
238
|
-
});
|
|
239
|
-
} catch (error) {
|
|
240
|
-
await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
|
|
241
|
-
throw error;
|
|
242
|
-
}
|
|
248
|
+
const token = (0, import_node_crypto.randomUUID)();
|
|
249
|
+
if (await publishProfileLock(profile, lockPath, token)) {
|
|
243
250
|
return async () => {
|
|
244
|
-
await releaseProfileLock(lockPath, token);
|
|
251
|
+
await releaseProfileLock(profile, lockPath, token);
|
|
245
252
|
};
|
|
246
|
-
} catch (error) {
|
|
247
|
-
if (!isLockAlreadyHeld(error)) throw error;
|
|
248
|
-
await signalTestLockContention(profile);
|
|
249
253
|
}
|
|
254
|
+
await signalTestLockContention(profile);
|
|
250
255
|
if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
|
|
251
256
|
const elapsedMs = Date.now() - startedAt;
|
|
252
257
|
if (elapsedMs >= timeoutMs) {
|
|
@@ -255,21 +260,35 @@ async function acquireProfileLock(profile, options) {
|
|
|
255
260
|
await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
|
|
256
261
|
}
|
|
257
262
|
}
|
|
258
|
-
async function
|
|
259
|
-
const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
|
|
260
|
-
const claimPath = (0, import_node_path.join)(lockPath, `.release-${token}.json`);
|
|
263
|
+
async function publishProfileLock(profile, lockPath, token) {
|
|
261
264
|
try {
|
|
262
|
-
await (0, import_promises.
|
|
265
|
+
await (0, import_promises.mkdir)(lockPath, { mode: PRIVATE_DIR_MODE });
|
|
263
266
|
} catch (error) {
|
|
264
|
-
if (isErrno(error, "
|
|
267
|
+
if (isErrno(error, "EEXIST")) return false;
|
|
265
268
|
throw error;
|
|
266
269
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
270
|
+
await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
|
|
271
|
+
const staged = (0, import_node_path.join)((0, import_node_path.dirname)(lockPath), `.lock-owner-${token}.tmp`);
|
|
272
|
+
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token };
|
|
273
|
+
try {
|
|
274
|
+
await (0, import_promises.writeFile)(staged, `${JSON.stringify(owner, null, 2)}
|
|
275
|
+
`, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
|
|
276
|
+
await (0, import_promises.link)(staged, (0, import_node_path.join)(lockPath, "owner.json"));
|
|
277
|
+
return true;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
|
|
280
|
+
await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
|
|
281
|
+
throw error;
|
|
282
|
+
} finally {
|
|
283
|
+
await (0, import_promises.rm)(staged, { force: true }).catch(() => void 0);
|
|
271
284
|
}
|
|
272
|
-
|
|
285
|
+
}
|
|
286
|
+
async function releaseProfileLock(profile, lockPath, token) {
|
|
287
|
+
const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
|
|
288
|
+
const owner = await readJson(ownerPath).catch(() => null);
|
|
289
|
+
if (owner?.token !== token) return;
|
|
290
|
+
await (0, import_promises.rm)(ownerPath, { force: true });
|
|
291
|
+
await waitForTestBarrier(TEST_LOCK_RELEASE_BARRIER_DIR, profile);
|
|
273
292
|
await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
|
|
274
293
|
}
|
|
275
294
|
async function signalTestLockContention(profile) {
|
|
@@ -286,32 +305,79 @@ async function signalTestLockContention(profile) {
|
|
|
286
305
|
await (0, import_promises.writeFile)(signalPath, `${profile}
|
|
287
306
|
`, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
|
|
288
307
|
}
|
|
308
|
+
async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
309
|
+
try {
|
|
310
|
+
const { mtimeMs } = await (0, import_promises.stat)(lockPath);
|
|
311
|
+
if (Date.now() - mtimeMs < staleAfterMs) return false;
|
|
312
|
+
} catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
const checkedAt = performance.now();
|
|
316
|
+
await waitForTestBarrier(TEST_LOCK_OWNERLESS_BARRIER_DIR, profile);
|
|
317
|
+
try {
|
|
318
|
+
const entries = await (0, import_promises.readdir)(lockPath);
|
|
319
|
+
if (entries.includes("owner.json")) return false;
|
|
320
|
+
if (recoveryFenced(checkedAt, staleAfterMs)) return false;
|
|
321
|
+
for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
|
|
322
|
+
await (0, import_promises.rm)((0, import_node_path.join)(lockPath, name), { force: true });
|
|
323
|
+
}
|
|
324
|
+
await (0, import_promises.rmdir)(lockPath);
|
|
325
|
+
} catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
await removeAgedOwnerFiles(lockPath, staleAfterMs);
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
function recoveryFenced(since, staleAfterMs) {
|
|
332
|
+
return performance.now() - since >= staleAfterMs / 2;
|
|
333
|
+
}
|
|
334
|
+
var ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
|
|
335
|
+
var STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
|
|
336
|
+
async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
|
|
337
|
+
const directory = (0, import_node_path.dirname)(lockPath);
|
|
338
|
+
const names = await (0, import_promises.readdir)(directory).catch(() => []);
|
|
339
|
+
for (const name of names.filter((entry) => STAGED_OWNER.test(entry))) {
|
|
340
|
+
const path = (0, import_node_path.join)(directory, name);
|
|
341
|
+
const aged = await (0, import_promises.stat)(path).then(({ mtimeMs }) => Date.now() - mtimeMs >= staleAfterMs, () => false);
|
|
342
|
+
if (aged) await (0, import_promises.rm)(path, { force: true }).catch(() => void 0);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
289
345
|
async function recoverStaleLock(profile, lockPath, staleAfterMs) {
|
|
290
346
|
const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
|
|
291
347
|
const owner = await readJson(ownerPath).catch(() => null);
|
|
348
|
+
if (owner === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
|
|
292
349
|
if (!isStaleOwner(owner, staleAfterMs)) return false;
|
|
293
350
|
const observedToken = typeof owner?.token === "string" && owner.token.length > 0 ? owner.token : "legacy";
|
|
294
|
-
await
|
|
351
|
+
await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
|
|
295
352
|
const claimPath = (0, import_node_path.join)(lockPath, `.stale-${(0, import_node_crypto.randomUUID)()}.json`);
|
|
296
353
|
try {
|
|
297
|
-
await (0, import_promises.
|
|
298
|
-
} catch
|
|
299
|
-
if (isErrno(error, "ENOENT")) return false;
|
|
354
|
+
await (0, import_promises.link)(ownerPath, claimPath);
|
|
355
|
+
} catch {
|
|
300
356
|
return false;
|
|
301
357
|
}
|
|
358
|
+
const claimedAt = performance.now();
|
|
359
|
+
await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
|
|
302
360
|
const claimed = await readJson(claimPath).catch(() => null);
|
|
303
361
|
const sameInstance = claimed !== null && (observedToken === "legacy" ? claimed.token === void 0 : claimed.token === observedToken);
|
|
304
|
-
|
|
305
|
-
|
|
362
|
+
await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
|
|
363
|
+
if (sameInstance && recoveryFenced(claimedAt, staleAfterMs)) {
|
|
364
|
+
await (0, import_promises.rm)(claimPath, { force: true });
|
|
365
|
+
await waitForTestBarrier(TEST_LOCK_FENCED_BARRIER_DIR, profile);
|
|
306
366
|
return false;
|
|
307
367
|
}
|
|
368
|
+
if (sameInstance) {
|
|
369
|
+
await (0, import_promises.rm)(ownerPath, { force: true });
|
|
370
|
+
await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
|
|
371
|
+
}
|
|
308
372
|
await (0, import_promises.rm)(claimPath, { force: true });
|
|
309
373
|
await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
|
|
374
|
+
if (!sameInstance) return false;
|
|
375
|
+
await removeAgedOwnerFiles(lockPath, staleAfterMs);
|
|
310
376
|
return true;
|
|
311
377
|
}
|
|
312
|
-
async function
|
|
378
|
+
async function waitForTestBarrier(environmentName, profile) {
|
|
313
379
|
if (process.env.NODE_ENV !== "test") return;
|
|
314
|
-
const barrierDirectory = process.env[
|
|
380
|
+
const barrierDirectory = process.env[environmentName];
|
|
315
381
|
if (!barrierDirectory) return;
|
|
316
382
|
await (0, import_promises.mkdir)(barrierDirectory, { recursive: true });
|
|
317
383
|
const readyPath = (0, import_node_path.join)(barrierDirectory, `ready-${process.pid}-${profile}`);
|
|
@@ -335,9 +401,6 @@ function isStaleOwner(owner, staleAfterMs) {
|
|
|
335
401
|
}
|
|
336
402
|
return !isProcessAlive(owner.pid);
|
|
337
403
|
}
|
|
338
|
-
function isLockAlreadyHeld(error) {
|
|
339
|
-
return isErrno(error, "EEXIST") || isErrno(error, "ENOTEMPTY");
|
|
340
|
-
}
|
|
341
404
|
function isProcessAlive(pid) {
|
|
342
405
|
try {
|
|
343
406
|
process.kill(pid, 0);
|