History

Store file secrets under EXECUTOR_DATA_DIR when set (#1431)

* Add regression test for secrets data directory * Store file secrets in executor data directory * Harden file secrets migration * Add changeset

Rhys Sullivan committed 3 months ago

Commit 39c2bfd1 parent a86cc4e4 files · +498 −57Browse files

Unified · Split

4 files
  1. .changeset/file-secrets-data-dir.md +5 −0
  2. packages/plugins/file-secrets/src/data-dir.test.ts +115 −0
  3. packages/plugins/file-secrets/src/index.test.ts +208 −0
  4. packages/plugins/file-secrets/src/index.ts +170 −57
.changeset/file-secrets-data-dir.md+5−0
@@ -0,0 +1,5 @@
1---
2"@executor-js/plugin-file-secrets": patch
3---
4
5`fileSecretsPlugin()` now stores `auth.json` under `EXECUTOR_DATA_DIR` when that variable is set (an explicit `directory` option still wins; the XDG location remains the fallback when it is unset). Existing secrets in the legacy XDG location are migrated automatically on first use. This keeps all daemon state under one directory, so persisting `EXECUTOR_DATA_DIR` alone preserves credentials across environment recreation.
packages/plugins/file-secrets/src/data-dir.test.ts+115−0
@@ -0,0 +1,115 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import { Effect } from "effect";
6
7import {
8 AuthTemplateSlug,
9 ConnectionName,
10 IntegrationSlug,
11 ProviderKey,
12 definePlugin,
13} from "@executor-js/sdk";
14import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing";
15
16import { fileSecretsPlugin } from "./index";
17
18const INTEGRATION = IntegrationSlug.make("durable-secrets");
19const CONNECTION = ConnectionName.make("main");
20const TEMPLATE = AuthTemplateSlug.make("apiKey");
21const CREDENTIAL = "secret-token";
22
23const connectionFixturePlugin = definePlugin(() => ({
24 id: "connectionFixture" as const,
25 storage: () => ({}),
26 resolveTools: () => Effect.succeed({ tools: [] }),
27 extension: (ctx) => ({
28 registerIntegration: () =>
29 ctx.core.integrations.register({
30 slug: INTEGRATION,
31 description: "Durable secrets test integration",
32 config: {},
33 }),
34 resolveCredential: () =>
35 ctx.connections.resolveValue({
36 owner: "org",
37 integration: INTEGRATION,
38 name: CONNECTION,
39 }),
40 }),
41}))();
42
43const plugins = () => [fileSecretsPlugin(), connectionFixturePlugin] as const;
44
45describe("file secrets data directory", () => {
46 let workDir: string;
47 let dataDir: string;
48 let firstSandboxDataHome: string;
49 let recreatedSandboxDataHome: string;
50
51 beforeEach(() => {
52 workDir = mkdtempSync(join(tmpdir(), "executor-file-secrets-data-dir-"));
53 dataDir = join(workDir, "executor-data");
54 firstSandboxDataHome = join(workDir, "sandbox-a-xdg");
55 recreatedSandboxDataHome = join(workDir, "sandbox-b-xdg");
56 mkdirSync(dataDir, { recursive: true });
57 mkdirSync(firstSandboxDataHome, { recursive: true });
58 mkdirSync(recreatedSandboxDataHome, { recursive: true });
59 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
60 });
61
62 afterEach(() => {
63 vi.unstubAllEnvs();
64 rmSync(workDir, { recursive: true, force: true });
65 });
66
67 it.effect("keeps credentials when only EXECUTOR_DATA_DIR survives sandbox recreation", () =>
68 Effect.gen(function* () {
69 vi.stubEnv("XDG_DATA_HOME", firstSandboxDataHome);
70 const firstAuthPath = yield* Effect.scoped(
71 Effect.gen(function* () {
72 const first = yield* makeTestWorkspaceHarness({ dataDir, plugins: plugins() });
73 yield* first.executor.connectionFixture.registerIntegration();
74 const connection = yield* first.executor.connections.create({
75 owner: "org",
76 name: CONNECTION,
77 integration: INTEGRATION,
78 template: TEMPLATE,
79 value: CREDENTIAL,
80 });
81
82 expect(connection.provider).toBe(ProviderKey.make("file"));
83 const authPath = first.executor.fileSecrets.filePath;
84 expect(authPath).toBe(join(dataDir, "auth.json"));
85 expect(existsSync(authPath)).toBe(true);
86 expect(readFileSync(authPath, "utf8")).toContain(
87 '"connection:org:durable-secrets:main:token": "secret-token"',
88 );
89 expect(existsSync(join(dataDir, "test.db"))).toBe(true);
90 return authPath;
91 }),
92 );
93
94 vi.stubEnv("XDG_DATA_HOME", recreatedSandboxDataHome);
95 yield* Effect.scoped(
96 Effect.gen(function* () {
97 const recreated = yield* makeTestWorkspaceHarness({ dataDir, plugins: plugins() });
98 const connections = yield* recreated.executor.connections.list({
99 integration: INTEGRATION,
100 });
101 expect(connections.map((connection) => String(connection.name))).toEqual(["main"]);
102
103 const resolved = yield* recreated.executor.connectionFixture.resolveCredential();
104
105 // Regression: auth.json follows XDG_DATA_HOME instead of EXECUTOR_DATA_DIR.
106 // After the fix it must live with test.db under dataDir and survive this home swap.
107 expect(
108 resolved,
109 `credential persisted in ${firstAuthPath} was not available after recreating the sandbox with ${dataDir}`,
110 ).toBe(CREDENTIAL);
111 }),
112 );
113 }),
114 );
115});
packages/plugins/file-secrets/src/index.test.ts+208−0
@@ -0,0 +1,208 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2import {
3 existsSync,
4 mkdirSync,
5 mkdtempSync,
6 readFileSync,
7 readdirSync,
8 rmSync,
9 statSync,
10 writeFileSync,
11} from "node:fs";
12import { tmpdir } from "node:os";
13import { dirname, join } from "node:path";
14import { Effect, Predicate, Result } from "effect";
15
16import { ProviderKey } from "@executor-js/sdk";
17import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing";
18
19import { fileSecretsPlugin } from "./index";
20
21const FILE_PROVIDER = ProviderKey.make("file");
22
23const inspectPlugin = (plugin: ReturnType<typeof fileSecretsPlugin>) =>
24 Effect.scoped(
25 Effect.gen(function* () {
26 const workspace = yield* makeTestWorkspaceHarness({ plugins: [plugin] as const });
27 const items = yield* workspace.executor.providers.items(FILE_PROVIDER);
28 return {
29 filePath: workspace.executor.fileSecrets.filePath,
30 itemIds: items.map((item) => String(item.id)),
31 };
32 }),
33 );
34
35const writeAuthFile = (filePath: string, contents: string, mode = 0o600): void => {
36 mkdirSync(dirname(filePath), { recursive: true });
37 writeFileSync(filePath, contents, { mode });
38};
39
40describe("file secrets auth location", () => {
41 let workDir: string;
42 let dataDir: string;
43 let otherDataDir: string;
44 let xdgDataHome: string;
45 let overrideDir: string;
46 let legacyFilePath: string;
47
48 beforeEach(() => {
49 workDir = mkdtempSync(join(tmpdir(), "executor-file-secrets-location-"));
50 dataDir = join(workDir, "data");
51 otherDataDir = join(workDir, "other-data");
52 xdgDataHome = join(workDir, "xdg");
53 overrideDir = join(workDir, "override");
54 legacyFilePath = join(xdgDataHome, "executor", "auth.json");
55 vi.stubEnv("XDG_DATA_HOME", xdgDataHome);
56 });
57
58 afterEach(() => {
59 vi.unstubAllEnvs();
60 rmSync(workDir, { recursive: true, force: true });
61 });
62
63 it.effect("uses auth.json directly under EXECUTOR_DATA_DIR resolved at construction", () =>
64 Effect.gen(function* () {
65 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
66 const plugin = fileSecretsPlugin();
67 vi.stubEnv("EXECUTOR_DATA_DIR", otherDataDir);
68
69 const inspected = yield* inspectPlugin(plugin);
70
71 expect(inspected.filePath).toBe(join(dataDir, "auth.json"));
72 }),
73 );
74
75 it.effect("keeps the XDG location when EXECUTOR_DATA_DIR is unset", () =>
76 Effect.gen(function* () {
77 vi.stubEnv("EXECUTOR_DATA_DIR", "");
78
79 const inspected = yield* inspectPlugin(fileSecretsPlugin());
80
81 expect(inspected.filePath).toBe(legacyFilePath);
82 }),
83 );
84
85 it.effect("gives an explicit directory precedence without migration", () =>
86 Effect.gen(function* () {
87 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
88 writeAuthFile(legacyFilePath, '{"legacy":"legacy-secret"}');
89
90 const inspected = yield* inspectPlugin(fileSecretsPlugin({ directory: overrideDir }));
91
92 expect(inspected.filePath).toBe(join(overrideDir, "auth.json"));
93 expect(inspected.itemIds).toEqual([]);
94 expect(existsSync(join(dataDir, "auth.json"))).toBe(false);
95 expect(readFileSync(legacyFilePath, "utf8")).toBe('{"legacy":"legacy-secret"}');
96 }),
97 );
98
99 it.effect("copies a valid legacy XDG file once and preserves 0600 permissions", () =>
100 Effect.gen(function* () {
101 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
102 const legacyContents = '{"legacy-token":"legacy-secret"}';
103 writeAuthFile(legacyFilePath, legacyContents, 0o644);
104
105 const inspected = yield* inspectPlugin(fileSecretsPlugin());
106 const migratedFilePath = join(dataDir, "auth.json");
107
108 expect(inspected.filePath).toBe(migratedFilePath);
109 expect(inspected.itemIds).toEqual(["legacy-token"]);
110 expect(readFileSync(migratedFilePath, "utf8")).toContain('"legacy-token": "legacy-secret"');
111 expect(statSync(migratedFilePath).mode & 0o777).toBe(0o600);
112 expect(readFileSync(legacyFilePath, "utf8")).toBe(legacyContents);
113 }),
114 );
115
116 it.effect("uses an existing data-dir file without merging the legacy file", () =>
117 Effect.gen(function* () {
118 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
119 const activeFilePath = join(dataDir, "auth.json");
120 const activeContents = '{"active-token":"active-secret"}';
121 const legacyContents = '{"legacy-token":"legacy-secret"}';
122 writeAuthFile(activeFilePath, activeContents);
123 writeAuthFile(legacyFilePath, legacyContents);
124
125 const inspected = yield* inspectPlugin(fileSecretsPlugin());
126
127 expect(inspected.itemIds).toEqual(["active-token"]);
128 expect(readFileSync(activeFilePath, "utf8")).toBe(activeContents);
129 expect(readFileSync(legacyFilePath, "utf8")).toBe(legacyContents);
130 }),
131 );
132
133 it.effect("leaves the new store empty when the legacy file is corrupt", () =>
134 Effect.scoped(
135 Effect.gen(function* () {
136 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
137 writeAuthFile(legacyFilePath, "not-json");
138 const workspace = yield* makeTestWorkspaceHarness({
139 plugins: [fileSecretsPlugin()] as const,
140 });
141
142 const initial = yield* workspace.executor.providers.items(FILE_PROVIDER);
143 expect(initial).toEqual([]);
144 expect(existsSync(join(dataDir, "auth.json"))).toBe(false);
145 expect(readFileSync(legacyFilePath, "utf8")).toBe("not-json");
146
147 writeAuthFile(legacyFilePath, '{"repaired-token":"repaired-secret"}');
148 const afterRepair = yield* workspace.executor.providers.items(FILE_PROVIDER);
149 expect(afterRepair).toEqual([]);
150 expect(existsSync(join(dataDir, "auth.json"))).toBe(false);
151 }),
152 ),
153 );
154
155 it.effect("retries migration after a legacy read I/O failure", () =>
156 Effect.scoped(
157 Effect.gen(function* () {
158 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
159 mkdirSync(legacyFilePath, { recursive: true });
160 const workspace = yield* makeTestWorkspaceHarness({
161 plugins: [fileSecretsPlugin()] as const,
162 });
163
164 const failed = yield* Effect.result(workspace.executor.providers.items(FILE_PROVIDER));
165 expect(Result.isFailure(failed)).toBe(true);
166 if (!Result.isFailure(failed)) return;
167 expect(Predicate.isTagged("StorageError")(failed.failure)).toBe(true);
168
169 rmSync(legacyFilePath, { recursive: true, force: true });
170 writeAuthFile(legacyFilePath, '{"recovered-token":"recovered-secret"}');
171
172 const recovered = yield* workspace.executor.providers.items(FILE_PROVIDER);
173 expect(recovered.map((item) => String(item.id))).toEqual(["recovered-token"]);
174 expect(readFileSync(join(dataDir, "auth.json"), "utf8")).toContain(
175 '"recovered-token": "recovered-secret"',
176 );
177 }),
178 ),
179 );
180
181 it.effect("shares one migration across concurrent first provider operations", () =>
182 Effect.scoped(
183 Effect.gen(function* () {
184 vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
185 const legacyContents = '{"legacy-token":"legacy-secret"}';
186 writeAuthFile(legacyFilePath, legacyContents);
187 const workspace = yield* makeTestWorkspaceHarness({
188 plugins: [fileSecretsPlugin()] as const,
189 });
190
191 const results = yield* Effect.all(
192 [
193 workspace.executor.providers.items(FILE_PROVIDER),
194 workspace.executor.providers.items(FILE_PROVIDER),
195 ],
196 { concurrency: "unbounded" },
197 );
198
199 expect(results.map((items) => items.map((item) => String(item.id)))).toEqual([
200 ["legacy-token"],
201 ["legacy-token"],
202 ]);
203 expect(readdirSync(dataDir)).toEqual(["auth.json"]);
204 expect(readFileSync(legacyFilePath, "utf8")).toBe(legacyContents);
205 }),
206 ),
207 );
208});
packages/plugins/file-secrets/src/index.ts+170−57
@@ -1,6 +1,7 @@
1import { Effect, Schema } from "effect";1import { randomUUID } from "node:crypto";
2import * as fs from "node:fs";2import * as fs from "node:fs";
3import * as path from "node:path";3import * as path from "node:path";
4import { Deferred, Effect, Exit, Schema } from "effect";
4 5
5import {6import {
6 definePlugin,7 definePlugin,
@@ -11,10 +12,11 @@ import { · 4 unmodified lines
11} from "@executor-js/sdk";12} from "@executor-js/sdk";
12 13
13// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
14// XDG data dir resolution15// Auth file location
15// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
16 17
17const APP_NAME = "executor";18const APP_NAME = "executor";
19const AUTH_FILE_NAME = "auth.json";
18 20
19export const xdgDataHome = (): string => {21export const xdgDataHome = (): string => {
20 if (process.env.XDG_DATA_HOME?.trim()) return process.env.XDG_DATA_HOME.trim();22 if (process.env.XDG_DATA_HOME?.trim()) return process.env.XDG_DATA_HOME.trim();
@@ -28,9 +30,34 @@ export const xdgDataHome = (): string => { · 7 unmodified lines
28 return path.join(process.env.HOME || "~", ".local", "share");30 return path.join(process.env.HOME || "~", ".local", "share");
29};31};
30 32
31const authDir = (overrideDir?: string): string => overrideDir ?? path.join(xdgDataHome(), APP_NAME);33interface AuthLocation {
34 readonly filePath: string;
35 readonly legacyFilePath: string | null;
36}
37
38const legacyAuthFilePath = (): string => path.join(xdgDataHome(), APP_NAME, AUTH_FILE_NAME);
39
40const resolveAuthLocation = (config: FileSecretsPluginConfig | undefined): AuthLocation => {
41 if (config?.directory !== undefined) {
42 return {
43 filePath: path.join(config.directory, AUTH_FILE_NAME),
44 legacyFilePath: null,
45 };
46 }
32 47
33const authFilePath = (overrideDir?: string): string => path.join(authDir(overrideDir), "auth.json");48 const dataDir = process.env.EXECUTOR_DATA_DIR?.trim();
49 if (dataDir) {
50 return {
51 filePath: path.join(dataDir, AUTH_FILE_NAME),
52 legacyFilePath: legacyAuthFilePath(),
53 };
54 }
55
56 return {
57 filePath: legacyAuthFilePath(),
58 legacyFilePath: null,
59 };
60};
34 61
35// ---------------------------------------------------------------------------62// ---------------------------------------------------------------------------
36// Schema for the auth file63// Schema for the auth file
@@ -45,13 +72,29 @@ const authFilePath = (overrideDir?: string): string => path.join(authDir(overrid · 8 unmodified lines
45const FlatAuthFile = Schema.Record(Schema.String, Schema.String);72const FlatAuthFile = Schema.Record(Schema.String, Schema.String);
46const decodeFlatAuthFile = Schema.decodeUnknownEffect(Schema.fromJsonString(FlatAuthFile));73const decodeFlatAuthFile = Schema.decodeUnknownEffect(Schema.fromJsonString(FlatAuthFile));
47 74
75class AuthFileDecodeError extends Schema.TaggedErrorClass<AuthFileDecodeError>()(
76 "AuthFileDecodeError",
77 {
78 filePath: Schema.String,
79 cause: Schema.Defect,
80 },
81 {
82 description:
83 "The auth file was readable but its contents were not a valid flat credential map. The caller must surface the failure or explicitly treat malformed legacy data as non-migratable.",
84 },
85) {
86 override get message(): string {
87 return `Failed to parse auth file: ${this.filePath}`;
88 }
89}
90
48// ---------------------------------------------------------------------------91// ---------------------------------------------------------------------------
49// File I/O with restricted permissions92// File I/O with restricted permissions
50//93//
51// These helpers keep real I/O and decode failures in the Effect error94// These helpers keep real I/O and decode failures distinct internally, then
52// channel as `StorageError`. Missing files are still treated as an empty95// project both to `StorageError` at the provider boundary. Missing files are
53// auth file, but malformed JSON, schema decode failures, and permission96// still treated as an empty auth file; malformed content and permission errors
54// errors no longer collapse into "empty file".97// do not collapse into "empty file" during ordinary provider reads.
55// ---------------------------------------------------------------------------98// ---------------------------------------------------------------------------
56 99
57const isFileNotFoundCause = (cause: unknown): cause is NodeJS.ErrnoException =>100const isFileNotFoundCause = (cause: unknown): cause is NodeJS.ErrnoException =>
@@ -62,7 +105,9 @@ const toStorageError = · 4 unmodified lines
62 (cause: unknown): StorageError =>105 (cause: unknown): StorageError =>
63 new StorageError({ message, cause });106 new StorageError({ message, cause });
64 107
65const readAll = (filePath: string): Effect.Effect<Record<string, string>, StorageError> => {108const readAuthFile = (
109 filePath: string,
110): Effect.Effect<Record<string, string>, StorageError | AuthFileDecodeError> => {
66 if (!fs.existsSync(filePath)) return Effect.succeed({});111 if (!fs.existsSync(filePath)) return Effect.succeed({});
67 return Effect.try({112 return Effect.try({
68 try: () => fs.readFileSync(filePath, "utf-8"),113 try: () => fs.readFileSync(filePath, "utf-8"),
@@ -76,18 +121,25 @@ const readAll = (filePath: string): Effect.Effect<Record<string, string>, Storag · 7 unmodified lines
76 raw === ""121 raw === ""
77 ? Effect.succeed<Record<string, string>>({})122 ? Effect.succeed<Record<string, string>>({})
78 : decodeFlatAuthFile(raw).pipe(123 : decodeFlatAuthFile(raw).pipe(
79 Effect.mapError(toStorageError("Failed to parse auth file")),124 Effect.mapError((cause) => new AuthFileDecodeError({ filePath, cause })),
80 ),125 ),
81 ),126 ),
82 );127 );
83};128};
84 129
130const readAll = (filePath: string): Effect.Effect<Record<string, string>, StorageError> =>
131 readAuthFile(filePath).pipe(
132 Effect.catchTag("AuthFileDecodeError", (cause) =>
133 Effect.fail(new StorageError({ message: cause.message, cause })),
134 ),
135 );
136
85const writeAll = (137const writeAll = (
86 filePath: string,138 filePath: string,
87 secrets: Record<string, string>,139 secrets: Record<string, string>,
88): Effect.Effect<void, StorageError> => {140): Effect.Effect<void, StorageError> => {
89 const dir = path.dirname(filePath);141 const dir = path.dirname(filePath);
90 const tmp = `${filePath}.tmp`;142 const tmp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
91 return Effect.gen(function* () {143 return Effect.gen(function* () {
92 if (!fs.existsSync(dir)) {144 if (!fs.existsSync(dir)) {
93 yield* Effect.try({145 yield* Effect.try({
@@ -96,7 +148,12 @@ const writeAll = ( · 2 unmodified lines
96 });148 });
97 }149 }
98 yield* Effect.try({150 yield* Effect.try({
99 try: () => fs.writeFileSync(tmp, JSON.stringify(secrets, null, 2), { mode: 0o600 }),151 try: () => {
152 fs.writeFileSync(tmp, JSON.stringify(secrets, null, 2), { mode: 0o600 });
153 // `mode` only applies when the file is created; chmod afterwards covers
154 // the case where a temporary file with broader permissions already exists.
155 fs.chmodSync(tmp, 0o600);
156 },
100 catch: toStorageError("Failed to write temporary auth file"),157 catch: toStorageError("Failed to write temporary auth file"),
101 });158 });
102 yield* Effect.try({159 yield* Effect.try({
@@ -106,12 +163,28 @@ const writeAll = ( · 3 unmodified lines
106 });163 });
107};164};
108 165
166const migrateLegacyAuthFile = ({
167 filePath,
168 legacyFilePath,
169}: AuthLocation): Effect.Effect<void, StorageError> => {
170 if (legacyFilePath === null || fs.existsSync(filePath) || !fs.existsSync(legacyFilePath)) {
171 return Effect.void;
172 }
173
174 return readAuthFile(legacyFilePath).pipe(
175 Effect.flatMap((secrets) => writeAll(filePath, secrets)),
176 // Malformed legacy contents are deliberately skipped. Genuine read I/O
177 // failures stay visible and leave migration eligible for a later retry.
178 Effect.catchTag("AuthFileDecodeError", () => Effect.void),
179 );
180};
181
109// ---------------------------------------------------------------------------182// ---------------------------------------------------------------------------
110// Plugin config183// Plugin config
111// ---------------------------------------------------------------------------184// ---------------------------------------------------------------------------
112 185
113export interface FileSecretsPluginConfig {186export interface FileSecretsPluginConfig {
114 /** Override the directory for auth.json (default: XDG data dir) */187 /** Override the directory for auth.json (default: EXECUTOR_DATA_DIR, then XDG data dir) */
115 readonly directory?: string;188 readonly directory?: string;
116}189}
117 190
@@ -119,8 +192,8 @@ export interface FileSecretsPluginConfig { · 1 unmodified line
119// Plugin extension — public API on executor.fileSecrets192// Plugin extension — public API on executor.fileSecrets
120// ---------------------------------------------------------------------------193// ---------------------------------------------------------------------------
121 194
122const makeFileSecretsExtension = (options: FileSecretsPluginConfig | undefined) => ({195const makeFileSecretsExtension = (filePath: string) => ({
123 filePath: resolveFilePath(options),196 filePath,
124});197});
125 198
126export type FileSecretsExtension = ReturnType<typeof makeFileSecretsExtension>;199export type FileSecretsExtension = ReturnType<typeof makeFileSecretsExtension>;
@@ -135,54 +208,94 @@ export type FileSecretsExtension = ReturnType<typeof makeFileSecretsExtension>; · 8 unmodified lines
135 208
136const FILE_PROVIDER_KEY = ProviderKey.make("file");209const FILE_PROVIDER_KEY = ProviderKey.make("file");
137 210
138const makeFileProvider = (filePath: string): CredentialProvider => ({211const makeFileProvider = (location: AuthLocation): CredentialProvider => {
139 key: FILE_PROVIDER_KEY,212 let migrationComplete = false;
140 writable: true,213 let migrationInFlight: Deferred.Deferred<void, StorageError> | null = null;
141 214 const ensureMigration = Effect.suspend(() => {
142 get: (id: ProviderItemId) => readAll(filePath).pipe(Effect.map((data) => data[id] ?? null)),215 if (migrationComplete) return Effect.void;
143 216 if (migrationInFlight !== null) return Deferred.await(migrationInFlight);
144 has: (id: ProviderItemId) => readAll(filePath).pipe(Effect.map((data) => id in data)),217
145 218 // Installing the latch inside Effect.suspend is synchronous, so a peer
146 set: (id: ProviderItemId, value: string) =>219 // fiber can only observe either no attempt or this fully initialized one.
147 Effect.gen(function* () {220 const latch = Deferred.makeUnsafe<void, StorageError>();
148 const data = yield* readAll(filePath);221 migrationInFlight = latch;
149 data[id] = value;222 return migrateLegacyAuthFile(location).pipe(
150 yield* writeAll(filePath, data);223 Effect.onExit((exit) =>
151 }),224 Effect.gen(function* () {
152 225 if (Exit.isSuccess(exit)) migrationComplete = true;
153 delete: (id: ProviderItemId) =>226 yield* Deferred.done(latch, exit);
154 Effect.gen(function* () {227 migrationInFlight = null;
155 const data = yield* readAll(filePath);228 }),
156 if (id in data) {229 ),
157 delete data[id];230 );
158 yield* writeAll(filePath, data);231 });
159 }232
160 }),233 return {
161 234 key: FILE_PROVIDER_KEY,
162 list: () =>235 writable: true,
163 readAll(filePath).pipe(236
164 Effect.map((data) => Object.keys(data).map((k) => ({ id: ProviderItemId.make(k), name: k }))),237 get: (id: ProviderItemId) =>
165 ),238 ensureMigration.pipe(
166});239 Effect.andThen(Effect.suspend(() => readAll(location.filePath))),
240 Effect.map((data) => data[id] ?? null),
241 ),
242
243 has: (id: ProviderItemId) =>
244 ensureMigration.pipe(
245 Effect.andThen(Effect.suspend(() => readAll(location.filePath))),
246 Effect.map((data) => id in data),
247 ),
248
249 set: (id: ProviderItemId, value: string) =>
250 ensureMigration.pipe(
251 Effect.andThen(
252 Effect.gen(function* () {
253 const data = yield* readAll(location.filePath);
254 data[id] = value;
255 yield* writeAll(location.filePath, data);
256 }),
257 ),
258 ),
259
260 delete: (id: ProviderItemId) =>
261 ensureMigration.pipe(
262 Effect.andThen(
263 Effect.gen(function* () {
264 const data = yield* readAll(location.filePath);
265 if (id in data) {
266 delete data[id];
267 yield* writeAll(location.filePath, data);
268 }
269 }),
270 ),
271 ),
272
273 list: () =>
274 ensureMigration.pipe(
275 Effect.andThen(Effect.suspend(() => readAll(location.filePath))),
276 Effect.map((data) =>
277 Object.keys(data).map((k) => ({ id: ProviderItemId.make(k), name: k })),
278 ),
279 ),
280 };
281};
167 282
168// ---------------------------------------------------------------------------283// ---------------------------------------------------------------------------
169// Plugin definition284// Plugin definition
170//285//
171// Compute the file path identically in `extension` (for `filePath`) and286// Resolve the path once when the configured plugin is constructed. The provider
172// `credentialProviders` (for the provider's read/write). Both are called once287// performs the one-time migration before its first read or write.
173// per createExecutor.
174// ---------------------------------------------------------------------------288// ---------------------------------------------------------------------------
175 289
176const resolveFilePath = (config: FileSecretsPluginConfig | undefined): string =>290export const fileSecretsPlugin = definePlugin((options?: FileSecretsPluginConfig) => {
177 authFilePath(config?.directory);291 const location = resolveAuthLocation(options);
178 292
179export const fileSecretsPlugin = definePlugin((options?: FileSecretsPluginConfig) => ({293 return {
180 id: "fileSecrets" as const,294 id: "fileSecrets" as const,
181 storage: () => ({}),295 storage: () => ({}),
182 296
183 extension: () => makeFileSecretsExtension(options),297 extension: () => makeFileSecretsExtension(location.filePath),
184 298
185 credentialProviders: (): readonly CredentialProvider[] => [299 credentialProviders: (): readonly CredentialProvider[] => [makeFileProvider(location)],
186 makeFileProvider(resolveFilePath(options)),300 };
187 ],301});
188}));