Open History to see the last commit
1import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe";
2import { defineSecretProviderDriver } from "@t3tools/secret-provider-core/server/driver";
3import { resolveSpawnCommand } from "@t3tools/shared/shell";
4import * as SecureRandom from "effect/crypto/SecureRandom";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import { ChildProcess } from "effect/process";
10
11import { definition, type OnePasswordAccount } from "../shared/definition.ts";
12
13const ONE_PASSWORD_BINARY = "op";
14// Bounds a read long enough to reach for a fingerprint reader, short enough not to wedge a rebuild.
15const READ_TIMEOUT = Duration.seconds(45);
16
17// `--cache=false` skips the `op daemon`, which hangs on prompts a background server cannot show.
18const runOnePassword = Effect.fn("OnePasswordDriver.runOnePassword")(function* (
19 args: ReadonlyArray<string>,
20) {
21 const spawnCommand = yield* resolveSpawnCommand(ONE_PASSWORD_BINARY, ["--cache=false", ...args]);
22 return yield* spawnAndCollect(
23 ONE_PASSWORD_BINARY,
24 ChildProcess.make(spawnCommand.command, spawnCommand.args, { shell: spawnCommand.shell }),
25 );
26});
27
28// A secret is passed on verbatim; surrounding whitespace can be part of it.
29const nonEmpty = (value: string) => (value.trim().length > 0 ? value : undefined);
30
31const readOne = Effect.fn("OnePasswordDriver.readOne")(function* (
32 account: OnePasswordAccount,
33 reference: string,
34) {
35 const result = yield* runOnePassword(["read", `--account=${account}`, "--no-newline", reference]);
36 if (result.code !== 0) {
37 yield* Effect.logWarning("Could not read secret from 1Password", {
38 stage: "read",
39 exitCode: result.code,
40 });
41 return undefined;
42 }
43 return nonEmpty(result.stdout);
44});
45
46// One `op inject` costs one unlock for every reference in the template. `op` ignores piped stdin
47// from Node, so the template goes through a temp file. A random separator after every reference
48// keeps one secret from shifting the others, and leaves any trailing newline outside the values.
49const readTogether = Effect.fn("OnePasswordDriver.readTogether")(function* (
50 account: OnePasswordAccount,
51 references: ReadonlyArray<string>,
52) {
53 const fileSystem = yield* FileSystem.FileSystem;
54 const separator = `__t3-secret-${yield* (yield* SecureRandom.SecureRandom).uuidV4()}__`;
55 const templatePath = yield* fileSystem.makeTempFileScoped({ prefix: "t3code-op-inject-" });
56 yield* fileSystem.writeFileString(
57 templatePath,
58 references.map((reference) => `{{ ${reference} }}${separator}`).join(""),
59 );
60 const result = yield* runOnePassword(["inject", `--account=${account}`, "-i", templatePath]);
61 const values = result.stdout.split(separator);
62 const trailing = values.pop();
63 if (
64 result.code !== 0 ||
65 values.length !== references.length ||
66 trailing === undefined ||
67 trailing.trim().length > 0
68 ) {
69 yield* Effect.logWarning("Could not batch-read secrets from 1Password", {
70 stage: "inject",
71 exitCode: result.code,
72 references: references.length,
73 });
74 return undefined;
75 }
76 return values.map(nonEmpty);
77}, Effect.scoped);
78
79const recover = <A, E extends { readonly _tag: string }, R>(
80 stage: "read" | "inject",
81 effect: Effect.Effect<A | undefined, E, R>,
82) =>
83 effect.pipe(
84 Effect.catch((error) =>
85 Effect.logWarning("Could not run 1Password", { stage, error: error._tag }).pipe(
86 Effect.as(undefined),
87 ),
88 ),
89 );
90
91export const driver = defineSecretProviderDriver({
92 kind: definition.kind,
93 definition,
94 read: Effect.fn("OnePasswordDriver.read")(function* ({ account }, references) {
95 // One deadline covers the batch and the fallback; a reference not read by then is unreadable.
96 const values: Array<string | undefined> = references.map(() => undefined);
97 const outcome = yield* Effect.gen(function* () {
98 if (references.length > 1) {
99 const together = yield* recover("inject", readTogether(account, references));
100 if (together !== undefined) return together;
101 }
102 yield* Effect.forEach(
103 references,
104 (reference, index) =>
105 recover("read", readOne(account, reference)).pipe(
106 Effect.map((value) => {
107 values[index] = value;
108 }),
109 ),
110 { discard: true },
111 );
112 return values;
113 }).pipe(Effect.timeoutOption(READ_TIMEOUT));
114 if (Option.isSome(outcome)) return outcome.value;
115 yield* Effect.logWarning("1Password did not answer in time", {
116 references: references.length,
117 });
118 return values;
119 }),
120});