@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.
Files changed (44) hide show
  1. package/LICENSE +110 -0
  2. package/dist/src/auth-client.d.ts +199 -0
  3. package/dist/src/auth-client.js +638 -0
  4. package/dist/src/auth-middleware.d.ts +28 -0
  5. package/dist/src/auth-middleware.js +105 -0
  6. package/dist/src/credential-store.d.ts +77 -0
  7. package/dist/src/credential-store.js +443 -0
  8. package/dist/src/device-login.d.ts +114 -0
  9. package/dist/src/device-login.js +300 -0
  10. package/dist/src/errors.d.ts +141 -0
  11. package/dist/src/errors.js +84 -0
  12. package/dist/src/guard.d.ts +24 -0
  13. package/dist/src/guard.js +62 -0
  14. package/dist/src/index.d.ts +36 -0
  15. package/dist/src/index.js +31 -0
  16. package/dist/src/internal/environment.d.ts +23 -0
  17. package/dist/src/internal/environment.js +45 -0
  18. package/dist/src/live.d.ts +13 -0
  19. package/dist/src/live.js +13 -0
  20. package/dist/src/login-interaction.d.ts +41 -0
  21. package/dist/src/login-interaction.js +108 -0
  22. package/dist/src/login-output.d.ts +22 -0
  23. package/dist/src/login-output.js +32 -0
  24. package/dist/src/login-presenter.d.ts +95 -0
  25. package/dist/src/login-presenter.js +58 -0
  26. package/dist/src/login-strategy.d.ts +21 -0
  27. package/dist/src/login-strategy.js +25 -0
  28. package/dist/src/loopback-login.d.ts +19 -0
  29. package/dist/src/loopback-login.js +91 -0
  30. package/dist/src/loopback-server.d.ts +40 -0
  31. package/dist/src/loopback-server.js +168 -0
  32. package/dist/src/oauth-contract.d.ts +7 -0
  33. package/dist/src/oauth-contract.js +2 -0
  34. package/dist/src/pending-device-login-store.d.ts +37 -0
  35. package/dist/src/pending-device-login-store.js +122 -0
  36. package/dist/src/publish-authorization.d.ts +11 -0
  37. package/dist/src/publish-authorization.js +79 -0
  38. package/dist/src/schema.d.ts +78 -0
  39. package/dist/src/schema.js +55 -0
  40. package/dist/src/testing.d.ts +14 -0
  41. package/dist/src/testing.js +14 -0
  42. package/dist/src/token-resolution.d.ts +79 -0
  43. package/dist/src/token-resolution.js +190 -0
  44. 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