@agentxm/registry-auth 0.28.4-bootstrap.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +110 -0
- package/dist/src/auth-client.d.ts +199 -0
- package/dist/src/auth-client.js +638 -0
- package/dist/src/auth-middleware.d.ts +28 -0
- package/dist/src/auth-middleware.js +105 -0
- package/dist/src/credential-store.d.ts +77 -0
- package/dist/src/credential-store.js +443 -0
- package/dist/src/device-login.d.ts +114 -0
- package/dist/src/device-login.js +300 -0
- package/dist/src/errors.d.ts +141 -0
- package/dist/src/errors.js +84 -0
- package/dist/src/guard.d.ts +24 -0
- package/dist/src/guard.js +62 -0
- package/dist/src/index.d.ts +36 -0
- package/dist/src/index.js +31 -0
- package/dist/src/internal/environment.d.ts +23 -0
- package/dist/src/internal/environment.js +45 -0
- package/dist/src/live.d.ts +13 -0
- package/dist/src/live.js +13 -0
- package/dist/src/login-interaction.d.ts +41 -0
- package/dist/src/login-interaction.js +108 -0
- package/dist/src/login-output.d.ts +22 -0
- package/dist/src/login-output.js +32 -0
- package/dist/src/login-presenter.d.ts +95 -0
- package/dist/src/login-presenter.js +58 -0
- package/dist/src/login-strategy.d.ts +21 -0
- package/dist/src/login-strategy.js +25 -0
- package/dist/src/loopback-login.d.ts +19 -0
- package/dist/src/loopback-login.js +91 -0
- package/dist/src/loopback-server.d.ts +40 -0
- package/dist/src/loopback-server.js +168 -0
- package/dist/src/oauth-contract.d.ts +7 -0
- package/dist/src/oauth-contract.js +2 -0
- package/dist/src/pending-device-login-store.d.ts +37 -0
- package/dist/src/pending-device-login-store.js +122 -0
- package/dist/src/publish-authorization.d.ts +11 -0
- package/dist/src/publish-authorization.js +79 -0
- package/dist/src/schema.d.ts +78 -0
- package/dist/src/schema.js +55 -0
- package/dist/src/testing.d.ts +14 -0
- package/dist/src/testing.js +14 -0
- package/dist/src/token-resolution.d.ts +79 -0
- package/dist/src/token-resolution.js +190 -0
- package/package.json +62 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth middleware — HttpClient wrapping layer.
|
|
3
|
+
*
|
|
4
|
+
* Intercepts outgoing HTTP requests to inject Bearer tokens and handle
|
|
5
|
+
* automatic refresh on 401.
|
|
6
|
+
*
|
|
7
|
+
* Layer composition: wraps the base HttpClient so all downstream consumers
|
|
8
|
+
* get auth headers automatically for registry URLs.
|
|
9
|
+
*
|
|
10
|
+
* @experimental This API is unstable and may change without notice.
|
|
11
|
+
*/
|
|
12
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
13
|
+
import * as Effect from "effect/Effect";
|
|
14
|
+
import * as FileSystem from "effect/FileSystem";
|
|
15
|
+
import * as Layer from "effect/Layer";
|
|
16
|
+
import * as Option from "effect/Option";
|
|
17
|
+
import * as Ref from "effect/Ref";
|
|
18
|
+
import * as Semaphore from "effect/Semaphore";
|
|
19
|
+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
20
|
+
import { AuthClient } from "./auth-client.js";
|
|
21
|
+
import { CredentialStore } from "./credential-store.js";
|
|
22
|
+
import { RegistryUrl } from "@agentxm/registry-client";
|
|
23
|
+
import { refreshStoredToken, resolveRequestToken, resolveStoredToken } from "./token-resolution.js";
|
|
24
|
+
// -----------------------------------------------------------------------------
|
|
25
|
+
// AuthMiddleware layer
|
|
26
|
+
// -----------------------------------------------------------------------------
|
|
27
|
+
/**
|
|
28
|
+
* Creates an auth middleware layer that wraps HttpClient with token injection
|
|
29
|
+
* and automatic refresh on 401.
|
|
30
|
+
*
|
|
31
|
+
* The `flagToken` parameter allows per-command --token flag injection.
|
|
32
|
+
*/
|
|
33
|
+
export const makeAuthMiddlewareLive = (flagToken) => Layer.effect(HttpClient.HttpClient, Effect.gen(function* () {
|
|
34
|
+
const baseClient = yield* HttpClient.HttpClient;
|
|
35
|
+
const store = yield* CredentialStore;
|
|
36
|
+
const authClient = yield* AuthClient;
|
|
37
|
+
const fs = yield* Effect.serviceOption(FileSystem.FileSystem);
|
|
38
|
+
const defaultRegistryUrl = yield* RegistryUrl;
|
|
39
|
+
const authLayerBase = Layer.mergeAll(Layer.succeed(CredentialStore, store), Layer.succeed(AuthClient, authClient));
|
|
40
|
+
const authLayer = Option.match(fs, {
|
|
41
|
+
onNone: () => authLayerBase,
|
|
42
|
+
onSome: (fileSystem) => Layer.merge(authLayerBase, Layer.succeed(FileSystem.FileSystem, fileSystem)),
|
|
43
|
+
});
|
|
44
|
+
const refreshLocks = yield* Ref.make(new Map());
|
|
45
|
+
const refreshOutcomes = yield* Ref.make(new Map());
|
|
46
|
+
const getRefreshLock = (registryUrl) => Ref.modify(refreshLocks, (current) => {
|
|
47
|
+
const existing = current.get(registryUrl);
|
|
48
|
+
if (existing !== undefined)
|
|
49
|
+
return [existing, current];
|
|
50
|
+
const created = Semaphore.makeUnsafe(1);
|
|
51
|
+
const updated = new Map(current);
|
|
52
|
+
updated.set(registryUrl, created);
|
|
53
|
+
return [created, updated];
|
|
54
|
+
});
|
|
55
|
+
const refreshAfterUnauthorized = (tokenSource) => Effect.gen(function* () {
|
|
56
|
+
const lock = yield* getRefreshLock(tokenSource.registryUrl);
|
|
57
|
+
return yield* lock.withPermits(1)(Effect.gen(function* () {
|
|
58
|
+
const latest = yield* resolveStoredToken(tokenSource.registryUrl).pipe(Effect.provide(authLayer), Effect.catch(() => Effect.succeed(Option.none())));
|
|
59
|
+
if (Option.isNone(latest))
|
|
60
|
+
return latest;
|
|
61
|
+
if (latest.value.token !== tokenSource.token)
|
|
62
|
+
return latest;
|
|
63
|
+
const outcomes = yield* Ref.get(refreshOutcomes);
|
|
64
|
+
const previous = outcomes.get(tokenSource.registryUrl);
|
|
65
|
+
if (previous?.attemptedToken === tokenSource.token)
|
|
66
|
+
return previous.result;
|
|
67
|
+
const result = yield* refreshStoredToken(latest.value).pipe(Effect.provide(authLayer), Effect.option);
|
|
68
|
+
yield* Ref.update(refreshOutcomes, (current) => {
|
|
69
|
+
const updated = new Map(current);
|
|
70
|
+
updated.set(tokenSource.registryUrl, {
|
|
71
|
+
attemptedToken: tokenSource.token,
|
|
72
|
+
result,
|
|
73
|
+
});
|
|
74
|
+
return updated;
|
|
75
|
+
});
|
|
76
|
+
return result;
|
|
77
|
+
}));
|
|
78
|
+
});
|
|
79
|
+
return HttpClient.make((request) => Effect.gen(function* () {
|
|
80
|
+
const maybeToken = yield* resolveRequestToken(request.url, defaultRegistryUrl, flagToken).pipe(Effect.provide(authLayer), Effect.tapError((e) => Effect.logDebug("Token resolution failed", { error: e })), Effect.catch(() => Effect.succeed(Option.none())));
|
|
81
|
+
if (Option.isNone(maybeToken)) {
|
|
82
|
+
return yield* baseClient.execute(request);
|
|
83
|
+
}
|
|
84
|
+
const tokenSource = maybeToken.value;
|
|
85
|
+
const currentToken = tokenSource.token;
|
|
86
|
+
// Inject Bearer header
|
|
87
|
+
const authedRequest = HttpClientRequest.bearerToken(request, currentToken);
|
|
88
|
+
const response = yield* baseClient.execute(authedRequest);
|
|
89
|
+
// Automatic refresh on 401 (credential store tokens only)
|
|
90
|
+
if (response.status === 401 && tokenSource._tag === "CredentialStore") {
|
|
91
|
+
const refreshResult = yield* refreshAfterUnauthorized(tokenSource);
|
|
92
|
+
if (Option.isSome(refreshResult)) {
|
|
93
|
+
const retryRequest = HttpClientRequest.bearerToken(request, refreshResult.value.token);
|
|
94
|
+
return yield* baseClient.execute(retryRequest);
|
|
95
|
+
}
|
|
96
|
+
return response;
|
|
97
|
+
}
|
|
98
|
+
return response;
|
|
99
|
+
}));
|
|
100
|
+
}));
|
|
101
|
+
/**
|
|
102
|
+
* Default auth middleware layer (no --token flag).
|
|
103
|
+
*/
|
|
104
|
+
export const AuthMiddlewareLive = makeAuthMiddlewareLive();
|
|
105
|
+
//# sourceMappingURL=auth-middleware.js.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CredentialStore Effect service — credential storage and auth policy.
|
|
3
|
+
*
|
|
4
|
+
* Tier 1: OS keychain (@napi-rs/keyring)
|
|
5
|
+
* Tier 2: Restricted-permission file (~/.config/axm/credentials.json)
|
|
6
|
+
*
|
|
7
|
+
* CI environments are token-only by policy. Containers use the restricted
|
|
8
|
+
* file tier so agent sessions can complete resumable device authorization.
|
|
9
|
+
*
|
|
10
|
+
* @experimental This API is unstable and may change without notice.
|
|
11
|
+
*/
|
|
12
|
+
import * as FileSystem from "effect/FileSystem";
|
|
13
|
+
import * as Path from "effect/Path";
|
|
14
|
+
import * as ServiceMap from "effect/Context";
|
|
15
|
+
import type * as DateTime from "effect/DateTime";
|
|
16
|
+
import * as Effect from "effect/Effect";
|
|
17
|
+
import * as Layer from "effect/Layer";
|
|
18
|
+
import * as Option from "effect/Option";
|
|
19
|
+
import { type Handle } from "@agentxm/extension-model/unstable/extensions/handle";
|
|
20
|
+
import { AuthTokenPolicyRequired, RegistryAuthFailed } from "./errors.js";
|
|
21
|
+
import type { CredentialFile, StorageTier, StoredCredentials } from "./schema.js";
|
|
22
|
+
export interface CredentialStoreService {
|
|
23
|
+
readonly save: (registryUrl: string, handle: Handle, credentials: {
|
|
24
|
+
readonly access_token: string;
|
|
25
|
+
readonly refresh_token: string;
|
|
26
|
+
readonly expires_at: DateTime.Utc;
|
|
27
|
+
}) => Effect.Effect<void, RegistryAuthFailed | AuthTokenPolicyRequired>;
|
|
28
|
+
readonly load: (registryUrl: string) => Effect.Effect<Option.Option<StoredCredentials>, RegistryAuthFailed>;
|
|
29
|
+
readonly clear: (registryUrl: string) => Effect.Effect<void, RegistryAuthFailed>;
|
|
30
|
+
readonly tier: StorageTier;
|
|
31
|
+
readonly allowsPersistedCredentials: boolean;
|
|
32
|
+
}
|
|
33
|
+
declare const CredentialStore_base: ServiceMap.ServiceClass<CredentialStore, "@agentxm/registry-auth/credential-store/CredentialStore", CredentialStoreService>;
|
|
34
|
+
export declare class CredentialStore extends CredentialStore_base {
|
|
35
|
+
}
|
|
36
|
+
export declare const resolveCredentialHomeDir: (config: {
|
|
37
|
+
readonly axmUserHome: Option.Option<string>;
|
|
38
|
+
readonly home: Option.Option<string>;
|
|
39
|
+
readonly userProfile: Option.Option<string>;
|
|
40
|
+
readonly homePath: Option.Option<string>;
|
|
41
|
+
}) => string;
|
|
42
|
+
export interface EnvironmentInfo {
|
|
43
|
+
readonly isSSH: boolean;
|
|
44
|
+
readonly isContainer: boolean;
|
|
45
|
+
readonly isWSL: boolean;
|
|
46
|
+
readonly isCI: boolean;
|
|
47
|
+
readonly isRoot: boolean;
|
|
48
|
+
readonly isGenericBunExecutable: boolean;
|
|
49
|
+
}
|
|
50
|
+
export declare const detectEnvironment: Effect.Effect<{
|
|
51
|
+
isSSH: boolean;
|
|
52
|
+
isContainer: boolean;
|
|
53
|
+
isWSL: boolean;
|
|
54
|
+
isCI: boolean;
|
|
55
|
+
isRoot: boolean;
|
|
56
|
+
isGenericBunExecutable: boolean;
|
|
57
|
+
}, never, FileSystem.FileSystem>;
|
|
58
|
+
/**
|
|
59
|
+
* Select storage tier based on detected environment.
|
|
60
|
+
*
|
|
61
|
+
* Use OS keychain by default, falling back to the restricted file backend when
|
|
62
|
+
* keychain access is unavailable. Whether persistence is allowed is a separate
|
|
63
|
+
* policy decision.
|
|
64
|
+
*/
|
|
65
|
+
export declare const selectTier: (env: EnvironmentInfo) => StorageTier;
|
|
66
|
+
export declare const canUsePersistedCredentials: (env: EnvironmentInfo) => boolean;
|
|
67
|
+
export declare const makePersistedCredentialsUnsupportedError: () => AuthTokenPolicyRequired;
|
|
68
|
+
export declare const CredentialStoreLive: Layer.Layer<CredentialStore, never, FileSystem.FileSystem | Path.Path>;
|
|
69
|
+
/**
|
|
70
|
+
* Decorates a credential store with a per-layer, per-origin read memo.
|
|
71
|
+
* Successful empty reads are memoized; failures remain retryable. Every
|
|
72
|
+
* successful write invalidates only the affected origin.
|
|
73
|
+
*/
|
|
74
|
+
export declare const CredentialStoreSessionLive: Layer.Layer<CredentialStore, never, CredentialStore>;
|
|
75
|
+
export declare const CredentialStoreTest: (tier?: StorageTier, initialData?: CredentialFile, allowsPersistedCredentials?: boolean) => Layer.Layer<CredentialStore, never, never>;
|
|
76
|
+
export {};
|
|
77
|
+
//# sourceMappingURL=credential-store.d.ts.map
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CredentialStore Effect service — credential storage and auth policy.
|
|
3
|
+
*
|
|
4
|
+
* Tier 1: OS keychain (@napi-rs/keyring)
|
|
5
|
+
* Tier 2: Restricted-permission file (~/.config/axm/credentials.json)
|
|
6
|
+
*
|
|
7
|
+
* CI environments are token-only by policy. Containers use the restricted
|
|
8
|
+
* file tier so agent sessions can complete resumable device authorization.
|
|
9
|
+
*
|
|
10
|
+
* @experimental This API is unstable and may change without notice.
|
|
11
|
+
*/
|
|
12
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
13
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
14
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
15
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return path;
|
|
19
|
+
};
|
|
20
|
+
import * as FileSystem from "effect/FileSystem";
|
|
21
|
+
import * as Path from "effect/Path";
|
|
22
|
+
import * as ServiceMap from "effect/Context";
|
|
23
|
+
import * as Effect from "effect/Effect";
|
|
24
|
+
import * as Layer from "effect/Layer";
|
|
25
|
+
import * as Option from "effect/Option";
|
|
26
|
+
import * as Ref from "effect/Ref";
|
|
27
|
+
import * as Schema from "effect/Schema";
|
|
28
|
+
import * as Semaphore from "effect/Semaphore";
|
|
29
|
+
import * as lockfile from "proper-lockfile";
|
|
30
|
+
import { decodeHandleSync } from "@agentxm/extension-model/unstable/extensions/handle";
|
|
31
|
+
import { AuthTokenPolicyRequired, RegistryAuthFailed } from "./errors.js";
|
|
32
|
+
import { envOption, isCI, isContainer, isRoot, isSSH, isWSL } from "./internal/environment.js";
|
|
33
|
+
import { CredentialFileSchema } from "./schema.js";
|
|
34
|
+
const decodeCredentialFileFromJsonString = Schema.decodeUnknownEffect(Schema.fromJsonString(CredentialFileSchema));
|
|
35
|
+
export class CredentialStore extends ServiceMap.Service()("@agentxm/registry-auth/credential-store/CredentialStore") {
|
|
36
|
+
}
|
|
37
|
+
// -----------------------------------------------------------------------------
|
|
38
|
+
// Constants
|
|
39
|
+
// -----------------------------------------------------------------------------
|
|
40
|
+
const CREDENTIALS_FILENAME = "credentials.json";
|
|
41
|
+
const CONFIG_DIR_NAME = "axm";
|
|
42
|
+
const DIR_PERMISSIONS = 0o700;
|
|
43
|
+
const FILE_PERMISSIONS = 0o600;
|
|
44
|
+
const KEYCHAIN_SERVICE = "axm";
|
|
45
|
+
const keyringModuleSpecifier = ["@napi-rs", "keyring"].join("/");
|
|
46
|
+
const loadKeyringEntry = Effect.tryPromise({
|
|
47
|
+
try: async () => {
|
|
48
|
+
const keyring = await import(__rewriteRelativeImportExtension(keyringModuleSpecifier));
|
|
49
|
+
return keyring.Entry;
|
|
50
|
+
},
|
|
51
|
+
catch: (error) => new RegistryAuthFailed({
|
|
52
|
+
category: "auth",
|
|
53
|
+
detail: "OS keychain module could not be loaded",
|
|
54
|
+
cause: error,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
// -----------------------------------------------------------------------------
|
|
58
|
+
// Internal helpers (take fs/path as args to avoid context leakage)
|
|
59
|
+
// -----------------------------------------------------------------------------
|
|
60
|
+
export const resolveCredentialHomeDir = (config) => Option.getOrElse(Option.orElse(config.axmUserHome, () => Option.orElse(Option.orElse(config.home, () => config.userProfile), () => config.homePath)), () => "/tmp");
|
|
61
|
+
const getCredentialsDir = (path, homeDir) => {
|
|
62
|
+
return path.join(homeDir, ".config", CONFIG_DIR_NAME);
|
|
63
|
+
};
|
|
64
|
+
const getCredentialsPath = (path, homeDir) => path.join(getCredentialsDir(path, homeDir), CREDENTIALS_FILENAME);
|
|
65
|
+
const ensureCredentialsDir = (fs, path, homeDir) => Effect.gen(function* () {
|
|
66
|
+
const dir = getCredentialsDir(path, homeDir);
|
|
67
|
+
const exists = yield* fs.exists(dir).pipe(Effect.catch(() => Effect.succeed(false)));
|
|
68
|
+
if (!exists) {
|
|
69
|
+
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.mapError((error) => new RegistryAuthFailed({
|
|
70
|
+
category: "auth",
|
|
71
|
+
detail: `Failed to create credentials directory: ${dir}`,
|
|
72
|
+
suggestions: [{ description: `Ensure you have write access to ~/.config/` }],
|
|
73
|
+
cause: error,
|
|
74
|
+
})));
|
|
75
|
+
yield* fs.chmod(dir, DIR_PERMISSIONS).pipe(Effect.catch(() => Effect.void));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
const checkFilePermissions = (fs, filePath) => fs.stat(filePath).pipe(Effect.map((stat) => (stat.mode & 0o777) > FILE_PERMISSIONS), Effect.catch(() => Effect.succeed(false)));
|
|
79
|
+
const setFilePermissions = (fs, filePath) => fs.chmod(filePath, FILE_PERMISSIONS).pipe(Effect.catch(() => Effect.void));
|
|
80
|
+
const withCredentialFileLock = (fs, path, homeDir, effect) => Effect.gen(function* () {
|
|
81
|
+
yield* ensureCredentialsDir(fs, path, homeDir);
|
|
82
|
+
const dir = getCredentialsDir(path, homeDir);
|
|
83
|
+
const release = yield* Effect.tryPromise({
|
|
84
|
+
try: () => lockfile.lock(dir, { retries: { retries: 5, minTimeout: 25, maxTimeout: 100 } }),
|
|
85
|
+
catch: (error) => new RegistryAuthFailed({
|
|
86
|
+
category: "auth",
|
|
87
|
+
detail: "Could not lock credential storage",
|
|
88
|
+
cause: error,
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
return yield* effect.pipe(Effect.ensuring(Effect.tryPromise({
|
|
92
|
+
try: () => release(),
|
|
93
|
+
catch: () => undefined,
|
|
94
|
+
}).pipe(Effect.catch(() => Effect.void))));
|
|
95
|
+
});
|
|
96
|
+
const readCredentialFile = (fs, path, homeDir) => Effect.gen(function* () {
|
|
97
|
+
const filePath = getCredentialsPath(path, homeDir);
|
|
98
|
+
const exists = yield* fs.exists(filePath).pipe(Effect.catch(() => Effect.succeed(false)));
|
|
99
|
+
if (!exists)
|
|
100
|
+
return Option.none();
|
|
101
|
+
const overly = yield* checkFilePermissions(fs, filePath);
|
|
102
|
+
if (overly) {
|
|
103
|
+
yield* Effect.logWarning("Credential file has overly permissive permissions.");
|
|
104
|
+
}
|
|
105
|
+
const content = yield* fs.readFileString(filePath).pipe(Effect.mapError((error) => new RegistryAuthFailed({
|
|
106
|
+
category: "auth",
|
|
107
|
+
detail: "Credential file could not be read",
|
|
108
|
+
cause: error,
|
|
109
|
+
})));
|
|
110
|
+
return yield* decodeCredentialFileFromJsonString(content).pipe(Effect.map((file) => Option.some(file)), Effect.mapError((error) => new RegistryAuthFailed({
|
|
111
|
+
category: "auth",
|
|
112
|
+
detail: "Failed to parse credential file",
|
|
113
|
+
suggestions: [
|
|
114
|
+
{
|
|
115
|
+
description: "The credential file may be corrupt. Delete it and sign in again.",
|
|
116
|
+
cmd: "axm login",
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
cause: error,
|
|
120
|
+
})), Effect.catch(() => Effect.logWarning("Credential file failed schema validation, treating as empty.").pipe(Effect.map(() => Option.none()))));
|
|
121
|
+
});
|
|
122
|
+
const deleteCredentialFile = (fs, path, homeDir) => Effect.gen(function* () {
|
|
123
|
+
const filePath = getCredentialsPath(path, homeDir);
|
|
124
|
+
const exists = yield* fs.exists(filePath).pipe(Effect.catch(() => Effect.succeed(false)));
|
|
125
|
+
if (exists) {
|
|
126
|
+
yield* fs.remove(filePath).pipe(Effect.catch(() => Effect.void));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
const writeCredentialFile = (fs, path, homeDir, data) => Effect.gen(function* () {
|
|
130
|
+
yield* ensureCredentialsDir(fs, path, homeDir);
|
|
131
|
+
const filePath = getCredentialsPath(path, homeDir);
|
|
132
|
+
const encoded = yield* Schema.encodeEffect(CredentialFileSchema)(data).pipe(Effect.mapError((error) => new RegistryAuthFailed({
|
|
133
|
+
category: "auth",
|
|
134
|
+
detail: "Failed to encode credential file",
|
|
135
|
+
cause: error,
|
|
136
|
+
})));
|
|
137
|
+
const content = JSON.stringify(encoded, null, 2);
|
|
138
|
+
yield* fs.writeFileString(filePath, content).pipe(Effect.mapError((error) => new RegistryAuthFailed({
|
|
139
|
+
category: "auth",
|
|
140
|
+
detail: "Failed to write credential file",
|
|
141
|
+
cause: error,
|
|
142
|
+
})));
|
|
143
|
+
yield* setFilePermissions(fs, filePath);
|
|
144
|
+
});
|
|
145
|
+
const emptyCredentialFile = {
|
|
146
|
+
version: 1,
|
|
147
|
+
registries: {},
|
|
148
|
+
};
|
|
149
|
+
const keychainAccount = (registryUrl) => `registry:${registryUrl}`;
|
|
150
|
+
const readKeychainCredentialFile = (registryUrl) => Effect.gen(function* () {
|
|
151
|
+
const Entry = yield* loadKeyringEntry;
|
|
152
|
+
const content = yield* Effect.try({
|
|
153
|
+
try: () => {
|
|
154
|
+
const entry = new Entry(KEYCHAIN_SERVICE, keychainAccount(registryUrl));
|
|
155
|
+
return entry.getPassword();
|
|
156
|
+
},
|
|
157
|
+
catch: (error) => new RegistryAuthFailed({
|
|
158
|
+
category: "auth",
|
|
159
|
+
detail: "OS keychain could not be read",
|
|
160
|
+
cause: error,
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
if (content === null)
|
|
164
|
+
return Option.none();
|
|
165
|
+
return yield* decodeCredentialFileFromJsonString(content).pipe(Effect.map((file) => Option.some(file)), Effect.mapError((error) => new RegistryAuthFailed({
|
|
166
|
+
category: "auth",
|
|
167
|
+
detail: "Failed to parse OS keychain credentials",
|
|
168
|
+
cause: error,
|
|
169
|
+
})));
|
|
170
|
+
});
|
|
171
|
+
const writeKeychainCredentialFile = (registryUrl, data) => Effect.gen(function* () {
|
|
172
|
+
const encoded = yield* Schema.encodeEffect(CredentialFileSchema)(data).pipe(Effect.mapError((error) => new RegistryAuthFailed({
|
|
173
|
+
category: "auth",
|
|
174
|
+
detail: "Failed to encode credential file",
|
|
175
|
+
cause: error,
|
|
176
|
+
})));
|
|
177
|
+
const content = JSON.stringify(encoded);
|
|
178
|
+
const Entry = yield* loadKeyringEntry;
|
|
179
|
+
yield* Effect.try({
|
|
180
|
+
try: () => {
|
|
181
|
+
const entry = new Entry(KEYCHAIN_SERVICE, keychainAccount(registryUrl));
|
|
182
|
+
entry.setPassword(content);
|
|
183
|
+
},
|
|
184
|
+
catch: (error) => new RegistryAuthFailed({
|
|
185
|
+
category: "auth",
|
|
186
|
+
detail: "OS keychain could not be written",
|
|
187
|
+
cause: error,
|
|
188
|
+
}),
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
const deleteKeychainCredentialFile = (registryUrl) => Effect.gen(function* () {
|
|
192
|
+
const Entry = yield* loadKeyringEntry;
|
|
193
|
+
yield* Effect.try({
|
|
194
|
+
try: () => {
|
|
195
|
+
const entry = new Entry(KEYCHAIN_SERVICE, keychainAccount(registryUrl));
|
|
196
|
+
entry.deletePassword();
|
|
197
|
+
},
|
|
198
|
+
catch: (error) => new RegistryAuthFailed({
|
|
199
|
+
category: "auth",
|
|
200
|
+
detail: "OS keychain credential could not be deleted",
|
|
201
|
+
cause: error,
|
|
202
|
+
}),
|
|
203
|
+
}).pipe(Effect.catch(() => Effect.void));
|
|
204
|
+
});
|
|
205
|
+
const isGenericBunExecutable = () => {
|
|
206
|
+
const executable = process.execPath.replaceAll("\\", "/").toLowerCase();
|
|
207
|
+
return executable.endsWith("/bun") || executable.endsWith("/bun.exe");
|
|
208
|
+
};
|
|
209
|
+
export const detectEnvironment = Effect.gen(function* () {
|
|
210
|
+
return {
|
|
211
|
+
isSSH: yield* isSSH,
|
|
212
|
+
isContainer: yield* isContainer,
|
|
213
|
+
isWSL: yield* isWSL,
|
|
214
|
+
isCI: yield* isCI,
|
|
215
|
+
isRoot: isRoot(),
|
|
216
|
+
isGenericBunExecutable: isGenericBunExecutable(),
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
/**
|
|
220
|
+
* Select storage tier based on detected environment.
|
|
221
|
+
*
|
|
222
|
+
* Use OS keychain by default, falling back to the restricted file backend when
|
|
223
|
+
* keychain access is unavailable. Whether persistence is allowed is a separate
|
|
224
|
+
* policy decision.
|
|
225
|
+
*/
|
|
226
|
+
export const selectTier = (env) => env.isContainer || env.isCI || env.isSSH || env.isGenericBunExecutable
|
|
227
|
+
? "restricted-file"
|
|
228
|
+
: "keychain";
|
|
229
|
+
export const canUsePersistedCredentials = (env) => !env.isCI;
|
|
230
|
+
export const makePersistedCredentialsUnsupportedError = () => new AuthTokenPolicyRequired({});
|
|
231
|
+
// -----------------------------------------------------------------------------
|
|
232
|
+
// Live layer
|
|
233
|
+
// -----------------------------------------------------------------------------
|
|
234
|
+
export const CredentialStoreLive = Layer.effect(CredentialStore, Effect.gen(function* () {
|
|
235
|
+
const fs = yield* FileSystem.FileSystem;
|
|
236
|
+
const path = yield* Path.Path;
|
|
237
|
+
const axmUserHome = yield* envOption("AXM_USER_HOME");
|
|
238
|
+
const home = yield* envOption("HOME");
|
|
239
|
+
const userProfile = yield* envOption("USERPROFILE");
|
|
240
|
+
const homePath = yield* envOption("HOMEPATH");
|
|
241
|
+
const homeDir = resolveCredentialHomeDir({ axmUserHome, home, userProfile, homePath });
|
|
242
|
+
const env = yield* detectEnvironment;
|
|
243
|
+
const storageTier = selectTier(env);
|
|
244
|
+
const persistedCredentialsAllowed = canUsePersistedCredentials(env);
|
|
245
|
+
const readStoredFile = () => withCredentialFileLock(fs, path, homeDir, readCredentialFile(fs, path, homeDir));
|
|
246
|
+
const writeStoredFile = (data) => withCredentialFileLock(fs, path, homeDir, writeCredentialFile(fs, path, homeDir, data));
|
|
247
|
+
const loadCredentialFile = (registryUrl) => storageTier === "keychain"
|
|
248
|
+
? readKeychainCredentialFile(registryUrl).pipe(Effect.catch(() => Effect.logWarning("OS keychain unavailable; using restricted credential file.").pipe(Effect.flatMap(() => readStoredFile()))))
|
|
249
|
+
: readStoredFile();
|
|
250
|
+
// Returns the tier actually used, so the caller only deletes the plaintext
|
|
251
|
+
// fallback file when the keychain write genuinely succeeded — never when we
|
|
252
|
+
// fell back to writing that file because the keychain was unavailable.
|
|
253
|
+
const saveCredentialFile = (registryUrl, data) => storageTier === "keychain"
|
|
254
|
+
? writeKeychainCredentialFile(registryUrl, data).pipe(Effect.as("keychain"), Effect.catch(() => Effect.logWarning("OS keychain unavailable; using restricted credential file.").pipe(Effect.flatMap(() => writeStoredFile(data)), Effect.as("file"))))
|
|
255
|
+
: writeStoredFile(data).pipe(Effect.as("file"));
|
|
256
|
+
const save = Effect.fn("CredentialStore.save")(function* (registryUrl, handle, credentials) {
|
|
257
|
+
if (!persistedCredentialsAllowed) {
|
|
258
|
+
return yield* makePersistedCredentialsUnsupportedError();
|
|
259
|
+
}
|
|
260
|
+
if (env.isRoot) {
|
|
261
|
+
yield* Effect.logWarning("Running as root. Credentials will be owned by root.");
|
|
262
|
+
}
|
|
263
|
+
const existing = yield* loadCredentialFile(registryUrl);
|
|
264
|
+
const file = Option.getOrElse(existing, () => emptyCredentialFile);
|
|
265
|
+
const registryEntry = file.registries[registryUrl] ?? { accounts: {} };
|
|
266
|
+
const updatedAccounts = {};
|
|
267
|
+
for (const [h, entry] of Object.entries(registryEntry.accounts)) {
|
|
268
|
+
if (entry !== undefined) {
|
|
269
|
+
updatedAccounts[h] = { ...entry, active: false };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
updatedAccounts[handle] = {
|
|
273
|
+
access_token: credentials.access_token,
|
|
274
|
+
refresh_token: credentials.refresh_token,
|
|
275
|
+
expires_at: credentials.expires_at,
|
|
276
|
+
active: true,
|
|
277
|
+
};
|
|
278
|
+
const updated = {
|
|
279
|
+
...file,
|
|
280
|
+
registries: {
|
|
281
|
+
...file.registries,
|
|
282
|
+
[registryUrl]: { accounts: updatedAccounts },
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
const usedTier = yield* saveCredentialFile(registryUrl, updated);
|
|
286
|
+
// Only clear the plaintext file when credentials actually landed in the
|
|
287
|
+
// keychain; if we fell back to the file, deleting it would lose them.
|
|
288
|
+
if (usedTier === "keychain") {
|
|
289
|
+
yield* deleteCredentialFile(fs, path, homeDir);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
const load = Effect.fn("CredentialStore.load")(function* (registryUrl) {
|
|
293
|
+
const existing = yield* loadCredentialFile(registryUrl);
|
|
294
|
+
if (Option.isNone(existing))
|
|
295
|
+
return Option.none();
|
|
296
|
+
const registry = existing.value.registries[registryUrl];
|
|
297
|
+
if (!registry)
|
|
298
|
+
return Option.none();
|
|
299
|
+
for (const [handle, entry] of Object.entries(registry.accounts)) {
|
|
300
|
+
if (entry?.active) {
|
|
301
|
+
return Option.some({
|
|
302
|
+
handle: decodeHandleSync(handle),
|
|
303
|
+
access_token: entry.access_token,
|
|
304
|
+
refresh_token: entry.refresh_token,
|
|
305
|
+
expires_at: entry.expires_at,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return Option.none();
|
|
310
|
+
});
|
|
311
|
+
const clear = Effect.fn("CredentialStore.clear")(function* (registryUrl) {
|
|
312
|
+
if (storageTier === "keychain") {
|
|
313
|
+
yield* deleteKeychainCredentialFile(registryUrl);
|
|
314
|
+
}
|
|
315
|
+
const existing = yield* readStoredFile();
|
|
316
|
+
if (Option.isNone(existing))
|
|
317
|
+
return;
|
|
318
|
+
const { [registryUrl]: _, ...remainingRegistries } = existing.value.registries;
|
|
319
|
+
const updated = {
|
|
320
|
+
...existing.value,
|
|
321
|
+
registries: remainingRegistries,
|
|
322
|
+
};
|
|
323
|
+
yield* writeStoredFile(updated);
|
|
324
|
+
});
|
|
325
|
+
return {
|
|
326
|
+
tier: storageTier,
|
|
327
|
+
allowsPersistedCredentials: persistedCredentialsAllowed,
|
|
328
|
+
save,
|
|
329
|
+
load,
|
|
330
|
+
clear,
|
|
331
|
+
};
|
|
332
|
+
}));
|
|
333
|
+
/**
|
|
334
|
+
* Decorates a credential store with a per-layer, per-origin read memo.
|
|
335
|
+
* Successful empty reads are memoized; failures remain retryable. Every
|
|
336
|
+
* successful write invalidates only the affected origin.
|
|
337
|
+
*/
|
|
338
|
+
export const CredentialStoreSessionLive = Layer.effect(CredentialStore, Effect.gen(function* () {
|
|
339
|
+
const store = yield* CredentialStore;
|
|
340
|
+
const cache = yield* Ref.make(new Map());
|
|
341
|
+
const locks = yield* Ref.make(new Map());
|
|
342
|
+
const getLock = (registryUrl) => Ref.modify(locks, (current) => {
|
|
343
|
+
const existing = current.get(registryUrl);
|
|
344
|
+
if (existing !== undefined)
|
|
345
|
+
return [existing, current];
|
|
346
|
+
const created = Semaphore.makeUnsafe(1);
|
|
347
|
+
const updated = new Map(current);
|
|
348
|
+
updated.set(registryUrl, created);
|
|
349
|
+
return [created, updated];
|
|
350
|
+
});
|
|
351
|
+
const getCached = (registryUrl) => Effect.map(Ref.get(cache), (current) => {
|
|
352
|
+
const cached = current.get(registryUrl);
|
|
353
|
+
return cached === undefined
|
|
354
|
+
? Option.none()
|
|
355
|
+
: Option.some(cached);
|
|
356
|
+
});
|
|
357
|
+
const invalidate = (registryUrl) => Ref.update(cache, (current) => {
|
|
358
|
+
const updated = new Map(current);
|
|
359
|
+
updated.delete(registryUrl);
|
|
360
|
+
return updated;
|
|
361
|
+
});
|
|
362
|
+
const load = (registryUrl) => Effect.gen(function* () {
|
|
363
|
+
const cached = yield* getCached(registryUrl);
|
|
364
|
+
if (Option.isSome(cached))
|
|
365
|
+
return cached.value;
|
|
366
|
+
const lock = yield* getLock(registryUrl);
|
|
367
|
+
return yield* lock.withPermits(1)(Effect.gen(function* () {
|
|
368
|
+
const afterWait = yield* getCached(registryUrl);
|
|
369
|
+
if (Option.isSome(afterWait))
|
|
370
|
+
return afterWait.value;
|
|
371
|
+
const loaded = yield* store.load(registryUrl);
|
|
372
|
+
yield* Ref.update(cache, (current) => {
|
|
373
|
+
const updated = new Map(current);
|
|
374
|
+
updated.set(registryUrl, loaded);
|
|
375
|
+
return updated;
|
|
376
|
+
});
|
|
377
|
+
return loaded;
|
|
378
|
+
}));
|
|
379
|
+
});
|
|
380
|
+
return {
|
|
381
|
+
tier: store.tier,
|
|
382
|
+
allowsPersistedCredentials: store.allowsPersistedCredentials,
|
|
383
|
+
load,
|
|
384
|
+
save: (registryUrl, handle, credentials) => store.save(registryUrl, handle, credentials).pipe(Effect.andThen(invalidate(registryUrl))),
|
|
385
|
+
clear: (registryUrl) => store.clear(registryUrl).pipe(Effect.andThen(invalidate(registryUrl))),
|
|
386
|
+
};
|
|
387
|
+
}));
|
|
388
|
+
// -----------------------------------------------------------------------------
|
|
389
|
+
// Test layer factory
|
|
390
|
+
// -----------------------------------------------------------------------------
|
|
391
|
+
export const CredentialStoreTest = (tier = "restricted-file", initialData, allowsPersistedCredentials) => {
|
|
392
|
+
let data = initialData ?? emptyCredentialFile;
|
|
393
|
+
const persistedCredentialsAllowed = allowsPersistedCredentials ?? true;
|
|
394
|
+
return Layer.succeed(CredentialStore, {
|
|
395
|
+
tier,
|
|
396
|
+
allowsPersistedCredentials: persistedCredentialsAllowed,
|
|
397
|
+
save: (registryUrl, handle, credentials) => persistedCredentialsAllowed
|
|
398
|
+
? Effect.sync(() => {
|
|
399
|
+
const registryEntry = data.registries[registryUrl] ?? { accounts: {} };
|
|
400
|
+
const updatedAccounts = {};
|
|
401
|
+
for (const [h, entry] of Object.entries(registryEntry.accounts)) {
|
|
402
|
+
if (entry !== undefined) {
|
|
403
|
+
updatedAccounts[h] = { ...entry, active: false };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
updatedAccounts[handle] = {
|
|
407
|
+
access_token: credentials.access_token,
|
|
408
|
+
refresh_token: credentials.refresh_token,
|
|
409
|
+
expires_at: credentials.expires_at,
|
|
410
|
+
active: true,
|
|
411
|
+
};
|
|
412
|
+
data = {
|
|
413
|
+
...data,
|
|
414
|
+
registries: {
|
|
415
|
+
...data.registries,
|
|
416
|
+
[registryUrl]: { accounts: updatedAccounts },
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
})
|
|
420
|
+
: Effect.fail(makePersistedCredentialsUnsupportedError()),
|
|
421
|
+
load: (registryUrl) => Effect.sync(() => {
|
|
422
|
+
const registry = data.registries[registryUrl];
|
|
423
|
+
if (!registry)
|
|
424
|
+
return Option.none();
|
|
425
|
+
for (const [handle, entry] of Object.entries(registry.accounts)) {
|
|
426
|
+
if (entry?.active) {
|
|
427
|
+
return Option.some({
|
|
428
|
+
handle: decodeHandleSync(handle),
|
|
429
|
+
access_token: entry.access_token,
|
|
430
|
+
refresh_token: entry.refresh_token,
|
|
431
|
+
expires_at: entry.expires_at,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return Option.none();
|
|
436
|
+
}),
|
|
437
|
+
clear: (registryUrl) => Effect.sync(() => {
|
|
438
|
+
const { [registryUrl]: _, ...rest } = data.registries;
|
|
439
|
+
data = { ...data, registries: rest };
|
|
440
|
+
}),
|
|
441
|
+
});
|
|
442
|
+
};
|
|
443
|
+
//# sourceMappingURL=credential-store.js.map
|