@monster-orange/den 0.4.19 → 0.4.21
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/CHANGELOG.md +122 -0
- package/README.md +7 -7
- package/docs/distributions.md +121 -13
- package/package.json +1 -1
- package/src/shared/attest-code.ts +125 -0
- package/src/shared/claim-code.ts +6 -2
- package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
- package/src/shared/github-app.ts +4 -0
- package/src/shared/github-device.ts +38 -1
- package/src/shared/owner-github.ts +100 -0
- package/src/shared/product.ts +45 -5
- package/src/shared/profile.ts +26 -2
- package/src/shared/secrets/index.ts +71 -3
- package/src/shared/secrets/onepassword.ts +87 -14
- package/src/shared/secrets/op-token.ts +270 -0
- package/src/shared/secrets/secret-env.ts +19 -6
- package/src/shared/secrets/strategies.ts +6 -0
- package/src/shared/secrets/types.ts +8 -0
- package/src/shared/web-session.ts +112 -6
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +108 -17
- package/src/ui/cli/doctor-owner.ts +122 -0
- package/src/ui/cli/service-command.ts +16 -0
- package/src/ui/cli/web-command.ts +74 -12
- package/src/ui/server/device-flows.ts +45 -10
- package/src/ui/server/github-routes.ts +169 -16
- package/src/ui/server/index.ts +148 -13
- package/src/ui/server/public/error-page.js +115 -33
- package/src/ui/server/public/error.html +16 -0
- package/src/ui/server/public/header.js +5 -7
- package/src/ui/server/public/i18n/es.js +11 -1
- package/src/ui/server/public/i18n/ja.js +11 -1
- package/src/ui/server/public/settings-registry.js +23 -1
- package/src/ui/server/public/shell.js +11 -1
- package/src/ui/server/route-classes.ts +7 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `web.owner_github` (D17): the GitHub login of the person who runs this
|
|
3
|
+
* instance, recorded in config/local/user.yml at `web up` or `service
|
|
4
|
+
* install` from `gh` when it is signed in here and no owner is linked yet.
|
|
5
|
+
* With it, that person connects their GitHub account from any browser, on
|
|
6
|
+
* any machine (a devbox they reach from a laptop), and nobody else can
|
|
7
|
+
* claim the instance from another machine (github-routes.ts decideFlow).
|
|
8
|
+
*
|
|
9
|
+
* Only the login is kept: never a token. `gh api user` runs with a short
|
|
10
|
+
* timeout; gh missing or signed out records nothing.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from "child_process";
|
|
13
|
+
import { readUserConfig, writeUserConfig } from "./den-config.js";
|
|
14
|
+
import { parseGithubName } from "./web-session.js";
|
|
15
|
+
import { readWebOwner } from "./web-owner.js";
|
|
16
|
+
|
|
17
|
+
export interface OwnerGithubDeps {
|
|
18
|
+
/** The login `gh` is signed in as here, or null. */
|
|
19
|
+
ghLogin?: () => string | null;
|
|
20
|
+
/** Whether an owner is linked already. */
|
|
21
|
+
ownerLinked?: () => boolean;
|
|
22
|
+
read?: typeof readUserConfig;
|
|
23
|
+
write?: typeof writeUserConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `gh api user`'s login, or null (gh missing, signed out, slow, odd output). */
|
|
27
|
+
export function ghLogin(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
28
|
+
try {
|
|
29
|
+
const out = execFileSync("gh", ["api", "user", "--jq", ".login"], {
|
|
30
|
+
env,
|
|
31
|
+
timeout: 8_000,
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
33
|
+
encoding: "utf-8",
|
|
34
|
+
}).trim();
|
|
35
|
+
return parseGithubName(out, "gh api user", "gh") ?? null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type OwnerGithubResult =
|
|
42
|
+
| { state: "linked" }
|
|
43
|
+
| { state: "set"; login: string }
|
|
44
|
+
| { state: "recorded"; login: string }
|
|
45
|
+
| { state: "none" };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Record `web.owner_github` from gh when no owner is linked and it isn't
|
|
49
|
+
* set: `recorded`. Already set: `set`. An owner linked: `linked` (nothing
|
|
50
|
+
* to do). gh can't say: `none`. Never throws.
|
|
51
|
+
*/
|
|
52
|
+
export function recordOwnerGithub(
|
|
53
|
+
deps: OwnerGithubDeps = {},
|
|
54
|
+
): OwnerGithubResult {
|
|
55
|
+
try {
|
|
56
|
+
if ((deps.ownerLinked ?? (() => readWebOwner() !== null))())
|
|
57
|
+
return { state: "linked" };
|
|
58
|
+
const read = deps.read ?? readUserConfig;
|
|
59
|
+
const cfg = read();
|
|
60
|
+
const web = { ...((cfg as { web?: Record<string, unknown> }).web ?? {}) };
|
|
61
|
+
let set: string | undefined;
|
|
62
|
+
try {
|
|
63
|
+
set = parseGithubName(
|
|
64
|
+
web.owner_github,
|
|
65
|
+
"web.owner_github",
|
|
66
|
+
"config/local/user.yml",
|
|
67
|
+
);
|
|
68
|
+
} catch {
|
|
69
|
+
return { state: "none" }; // a value someone typed: leave it alone
|
|
70
|
+
}
|
|
71
|
+
if (set) return { state: "set", login: set };
|
|
72
|
+
const login = (deps.ghLogin ?? ghLogin)();
|
|
73
|
+
if (!login) return { state: "none" };
|
|
74
|
+
web.owner_github = login;
|
|
75
|
+
(deps.write ?? writeUserConfig)({ ...cfg, web } as typeof cfg);
|
|
76
|
+
return { state: "recorded", login };
|
|
77
|
+
} catch {
|
|
78
|
+
return { state: "none" };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The line `web up` and `service install` print, or null. */
|
|
83
|
+
export function ownerGithubLine(
|
|
84
|
+
r: OwnerGithubResult,
|
|
85
|
+
cli = "den",
|
|
86
|
+
): string | null {
|
|
87
|
+
if (r.state === "recorded")
|
|
88
|
+
return `GitHub owner: @${r.login} (from gh; web.owner_github in config/local/user.yml). Connect your GitHub account from any browser to sign in.`;
|
|
89
|
+
if (r.state === "none") return ghNotSignedInHint(cli);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** No owner, no web.owner_github, and gh can't say who you are: the fix. */
|
|
94
|
+
export function ghNotSignedInHint(cli = "den"): string {
|
|
95
|
+
return (
|
|
96
|
+
"GitHub owner: not known, because gh isn't signed in here. " +
|
|
97
|
+
`Run \`gh auth login\`, then \`${cli} web restart\`; or set web.owner_github: <your GitHub login> in config/local/user.yml. ` +
|
|
98
|
+
"Then you can connect your GitHub account from any browser."
|
|
99
|
+
);
|
|
100
|
+
}
|
package/src/shared/product.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* distribution root can be found (e.g. help text printed outside a checkout).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { parse as parseYaml } from "yaml";
|
|
11
14
|
import { getDistRoot } from "./paths.js";
|
|
12
15
|
import {
|
|
13
16
|
loadProfile,
|
|
@@ -17,7 +20,11 @@ import {
|
|
|
17
20
|
} from "./profile.js";
|
|
18
21
|
|
|
19
22
|
export interface Product extends ProductProfile {
|
|
20
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Display name (D11): the org config's brand.name ("Acme Den") when it is
|
|
25
|
+
* safe to show anywhere (TITLE_SAFE), else the name with its first letter
|
|
26
|
+
* upper-cased ("Acme-den").
|
|
27
|
+
*/
|
|
21
28
|
title: string;
|
|
22
29
|
/** The YAML manifest file name (".den.yml"). */
|
|
23
30
|
manifestYml: string;
|
|
@@ -25,11 +32,43 @@ export interface Product extends ProductProfile {
|
|
|
25
32
|
manifestJson: string;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
|
|
35
|
+
/**
|
|
36
|
+
* What a brand.name may hold to be the display title: letters, digits,
|
|
37
|
+
* spaces, ".", "_" and "-", up to 40. The title is filled into page scripts
|
|
38
|
+
* (inside "…" and '…' strings) and HTML attributes, so no quote, backslash,
|
|
39
|
+
* "<" or "&" may reach it (the 0.4.20 hyphen bug's kin).
|
|
40
|
+
*/
|
|
41
|
+
export const TITLE_SAFE = /^[\p{L}\p{N}][\p{L}\p{N} ._-]{0,39}$/u;
|
|
42
|
+
|
|
43
|
+
/** The name, first letter upper-cased: "acme-den" → "Acme-den". */
|
|
44
|
+
export function nameTitle(name: string): string {
|
|
45
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The org config's brand.name, trimmed, or undefined. Never throws. */
|
|
49
|
+
export function brandNameAt(root: string | null, configFile: string) {
|
|
50
|
+
if (!root) return undefined;
|
|
51
|
+
try {
|
|
52
|
+
const raw = parseYaml(
|
|
53
|
+
fs.readFileSync(path.join(root, configFile), "utf-8"),
|
|
54
|
+
) as { brand?: { name?: unknown } } | null;
|
|
55
|
+
const n = raw?.brand?.name;
|
|
56
|
+
return typeof n === "string" && n.trim() ? n.trim() : undefined;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The display title: a safe brand.name, else nameTitle. */
|
|
63
|
+
export function titleOf(name: string, brandName?: string): string {
|
|
64
|
+
return brandName && TITLE_SAFE.test(brandName) ? brandName : nameTitle(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function withDerived(p: ProductProfile, root: string | null = null): Product {
|
|
29
68
|
const yml = p.manifestFiles[0];
|
|
30
69
|
return {
|
|
31
70
|
...p,
|
|
32
|
-
title: p.name
|
|
71
|
+
title: titleOf(p.name, brandNameAt(root, p.configFile)),
|
|
33
72
|
manifestYml: yml,
|
|
34
73
|
manifestJson: p.manifestFiles[1] ?? yml.replace(/\.ya?ml$/, "") + ".json",
|
|
35
74
|
};
|
|
@@ -40,10 +79,11 @@ let derived: { profile: ProductProfile; product: Product } | null = null;
|
|
|
40
79
|
/** The running product. Never throws. */
|
|
41
80
|
export function product(): Product {
|
|
42
81
|
try {
|
|
43
|
-
const
|
|
82
|
+
const root = getDistRoot();
|
|
83
|
+
const profile = loadProfile(root);
|
|
44
84
|
// Same profile object (loadProfile caches it): same derived names.
|
|
45
85
|
if (derived?.profile !== profile)
|
|
46
|
-
derived = { profile, product: withDerived(profile) };
|
|
86
|
+
derived = { profile, product: withDerived(profile, root) };
|
|
47
87
|
return derived.product;
|
|
48
88
|
} catch {
|
|
49
89
|
const pinned = process.env.DEN_PROFILE?.trim();
|
package/src/shared/profile.ts
CHANGED
|
@@ -58,6 +58,13 @@ interface RawProduct {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
const SLUG = /^[a-z][a-z0-9-]*$/;
|
|
61
|
+
/**
|
|
62
|
+
* A path the pages show (config, manifest, services and workspaces dirs):
|
|
63
|
+
* letters, digits, ".", "_", "-" and "/". The web pages carry these
|
|
64
|
+
* names as placeholders filled when served (product.ts productTokens), so
|
|
65
|
+
* a quote, backslash, "$" or "<" in one would break a page's script.
|
|
66
|
+
*/
|
|
67
|
+
const SAFE_PATH = /^[A-Za-z0-9._/-]+$/;
|
|
61
68
|
const PREFIX = /^[A-Z][A-Z0-9_]*$/;
|
|
62
69
|
|
|
63
70
|
const str = (v: unknown): string | undefined =>
|
|
@@ -83,7 +90,7 @@ export function profileFrom(
|
|
|
83
90
|
const name = str(raw.name) ?? "den";
|
|
84
91
|
if (!SLUG.test(name))
|
|
85
92
|
throw new Error(
|
|
86
|
-
`den.yml: product.name "${name}" must be lower-case letters, digits and "-"`,
|
|
93
|
+
`den.yml: product.name "${name}" must be lower-case letters, digits and "-", starting with a letter`,
|
|
87
94
|
);
|
|
88
95
|
const envPrefix =
|
|
89
96
|
str(raw.env_prefix) ?? name.toUpperCase().replace(/-/g, "_");
|
|
@@ -91,11 +98,28 @@ export function profileFrom(
|
|
|
91
98
|
throw new Error(
|
|
92
99
|
`den.yml: product.env_prefix "${envPrefix}" must be upper-case letters, digits and "_"`,
|
|
93
100
|
);
|
|
101
|
+
const cli = str(raw.cli) ?? name;
|
|
102
|
+
if (!SLUG.test(cli))
|
|
103
|
+
throw new Error(
|
|
104
|
+
`den.yml: product.cli "${cli}" must be lower-case letters, digits and "-", starting with a letter`,
|
|
105
|
+
);
|
|
106
|
+
for (const key of [
|
|
107
|
+
"config",
|
|
108
|
+
"manifest",
|
|
109
|
+
"services_dir",
|
|
110
|
+
"workspaces_dir",
|
|
111
|
+
] as const) {
|
|
112
|
+
const v = str(raw[key]);
|
|
113
|
+
if (v !== undefined && !SAFE_PATH.test(v))
|
|
114
|
+
throw new Error(
|
|
115
|
+
`den.yml: product.${key} "${v}" may use only letters, digits, ".", "_", "-" and "/"`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
94
118
|
const manifest = str(raw.manifest) ?? `.${name}.yml`;
|
|
95
119
|
const json = manifest.replace(/\.ya?ml$/, ".json");
|
|
96
120
|
return {
|
|
97
121
|
name,
|
|
98
|
-
cli
|
|
122
|
+
cli,
|
|
99
123
|
envPrefix,
|
|
100
124
|
configFile: str(raw.config) ?? `config/${name}.yml`,
|
|
101
125
|
manifestFiles: json === manifest ? [manifest] : [manifest, json],
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { readDenConfig, readUserConfig } from "../den-config.js";
|
|
19
19
|
import type { DetectResult } from "../strategies/registry.js";
|
|
20
|
+
import { ConfigError } from "../config-error.js";
|
|
21
|
+
import {
|
|
22
|
+
describeOpTokenSource,
|
|
23
|
+
opTokenSourceError,
|
|
24
|
+
parseOpTokenSource,
|
|
25
|
+
} from "./op-token.js";
|
|
20
26
|
import { notConfiguredMessage, SecretsNotConfiguredError } from "./none.js";
|
|
21
27
|
import { secretsStrategy, type SecretsStrategy } from "./strategies.js";
|
|
22
28
|
import type {
|
|
@@ -37,6 +43,7 @@ export { notConfiguredMessage, SecretsNotConfiguredError } from "./none.js";
|
|
|
37
43
|
export { secretsStrategy, secretsStrategyIds } from "./strategies.js";
|
|
38
44
|
export type { SecretsStrategy, SecretsSetupContext } from "./strategies.js";
|
|
39
45
|
export * from "./secret-env.js";
|
|
46
|
+
export * from "./op-token.js";
|
|
40
47
|
|
|
41
48
|
export interface SecretsConfig {
|
|
42
49
|
provider?: SecretsProviderName;
|
|
@@ -48,7 +55,16 @@ export interface SecretsConfig {
|
|
|
48
55
|
*/
|
|
49
56
|
required?: boolean;
|
|
50
57
|
doppler?: { project?: string; config?: string };
|
|
51
|
-
onepassword?: {
|
|
58
|
+
onepassword?: {
|
|
59
|
+
account?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Where the service-account token comes from: `keychain:<service>`,
|
|
62
|
+
* `file:<path>` (mode 600) or `env:<VAR>` (the default,
|
|
63
|
+
* `env:OP_SERVICE_ACCOUNT_TOKEN`). Per machine: read from
|
|
64
|
+
* config/local/user.yml only, never from the distribution's config.
|
|
65
|
+
*/
|
|
66
|
+
token?: string;
|
|
67
|
+
};
|
|
52
68
|
/** `"vault/item/field": FLAT_KEY` overrides for doppler/env. */
|
|
53
69
|
keys?: Record<string, string>;
|
|
54
70
|
/**
|
|
@@ -126,11 +142,32 @@ export function readSecretsConfig(): SecretsConfig {
|
|
|
126
142
|
} catch {
|
|
127
143
|
/* no user config */
|
|
128
144
|
}
|
|
145
|
+
// The token source is this machine's: the distribution's config never
|
|
146
|
+
// names one, and a bad one is refused here, naming the key.
|
|
147
|
+
const tokenErr =
|
|
148
|
+
user.onepassword?.token === undefined || user.onepassword.token === null
|
|
149
|
+
? null
|
|
150
|
+
: opTokenSourceError(user.onepassword.token);
|
|
151
|
+
if (tokenErr) {
|
|
152
|
+
const key = "secrets.onepassword.token";
|
|
153
|
+
throw new ConfigError([
|
|
154
|
+
{
|
|
155
|
+
file: "config/local/user.yml",
|
|
156
|
+
key,
|
|
157
|
+
message: tokenErr,
|
|
158
|
+
text: `${key} ${tokenErr}`,
|
|
159
|
+
},
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
129
162
|
return {
|
|
130
163
|
...team,
|
|
131
164
|
...user,
|
|
132
165
|
doppler: { ...team.doppler, ...user.doppler },
|
|
133
|
-
onepassword: {
|
|
166
|
+
onepassword: {
|
|
167
|
+
...team.onepassword,
|
|
168
|
+
...user.onepassword,
|
|
169
|
+
token: user.onepassword?.token ?? undefined,
|
|
170
|
+
},
|
|
134
171
|
keys: { ...team.keys, ...user.keys },
|
|
135
172
|
refs: { ...team.refs, ...user.refs },
|
|
136
173
|
};
|
|
@@ -251,6 +288,14 @@ class DetectedProvider implements SecretsProvider {
|
|
|
251
288
|
async tryGet(ref: SecretRef): Promise<string | null> {
|
|
252
289
|
return this.unavailable() ? null : this.inner.tryGet(ref);
|
|
253
290
|
}
|
|
291
|
+
async tryGetWithReason(
|
|
292
|
+
ref: SecretRef,
|
|
293
|
+
): Promise<{ value: string | null; reason?: string }> {
|
|
294
|
+
const why = this.unavailable();
|
|
295
|
+
if (why) return { value: null, reason: why };
|
|
296
|
+
if (this.inner.tryGetWithReason) return this.inner.tryGetWithReason(ref);
|
|
297
|
+
return { value: await this.inner.tryGet(ref) };
|
|
298
|
+
}
|
|
254
299
|
async set(ref: SecretRef, value: string): Promise<void> {
|
|
255
300
|
const why = this.unavailable();
|
|
256
301
|
if (why) throw new Error(`${this.name} secrets backend ${why}`);
|
|
@@ -364,6 +409,24 @@ export function secretsProcessEnv(
|
|
|
364
409
|
return strategyFor(name).capabilities.env?.(cfg, env) ?? {};
|
|
365
410
|
}
|
|
366
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Settings › Secrets: where the 1Password token comes from, as text
|
|
414
|
+
* ("keychain: <service>", "file: <path>", "env: <VAR>") or "not set". The
|
|
415
|
+
* source only, never the token; reads nothing from the keychain or the file.
|
|
416
|
+
*/
|
|
417
|
+
export function opTokenSourceSetting(
|
|
418
|
+
cfg: SecretsConfig | (() => SecretsConfig) = readSecretsConfig,
|
|
419
|
+
): string {
|
|
420
|
+
try {
|
|
421
|
+
const raw = (typeof cfg === "function" ? cfg() : cfg).onepassword?.token;
|
|
422
|
+
return raw === undefined || raw === null
|
|
423
|
+
? "not set"
|
|
424
|
+
: describeOpTokenSource(parseOpTokenSource(raw));
|
|
425
|
+
} catch {
|
|
426
|
+
return "invalid: see secrets.onepassword.token";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
367
430
|
/** The engine's own named refs, for the legacy warning. */
|
|
368
431
|
export const ENGINE_SECRET_REFS = ["sudo", "journeys_agent_key"] as const;
|
|
369
432
|
|
|
@@ -379,9 +442,14 @@ export const IMPLICIT_PROVIDER_WARNING =
|
|
|
379
442
|
* distribution that needs no secrets never hears about it.
|
|
380
443
|
*/
|
|
381
444
|
export function secretsConfigWarnings(
|
|
382
|
-
cfg
|
|
445
|
+
cfg?: SecretsConfig,
|
|
383
446
|
env: NodeJS.ProcessEnv = process.env,
|
|
384
447
|
): string[] {
|
|
448
|
+
try {
|
|
449
|
+
cfg ??= readSecretsConfig();
|
|
450
|
+
} catch (err) {
|
|
451
|
+
return [(err as Error).message];
|
|
452
|
+
}
|
|
385
453
|
let r: ResolvedProvider;
|
|
386
454
|
try {
|
|
387
455
|
r = resolveProvider(cfg, env);
|
|
@@ -5,27 +5,95 @@
|
|
|
5
5
|
import { execFile } from "child_process";
|
|
6
6
|
import { promisify } from "util";
|
|
7
7
|
import type { PreflightResult, SecretRef, SecretsProvider } from "./types.js";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_OP_TOKEN_SOURCE,
|
|
10
|
+
OP_TOKEN_ENV,
|
|
11
|
+
OpFailure,
|
|
12
|
+
mapOpFailure,
|
|
13
|
+
readOpToken,
|
|
14
|
+
type OpTokenDeps,
|
|
15
|
+
type OpTokenSource,
|
|
16
|
+
} from "./op-token.js";
|
|
8
17
|
|
|
9
18
|
import { product } from "../product.js";
|
|
10
19
|
const execFileAsync = promisify(execFile);
|
|
11
20
|
|
|
12
21
|
export interface OnePasswordOptions {
|
|
13
22
|
account?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Where the service-account token comes from (`secrets.onepassword.token`).
|
|
25
|
+
* Unset: the default `env:OP_SERVICE_ACCOUNT_TOKEN`, and `op` is run with
|
|
26
|
+
* this process's own env even when that has none (a signed-in desktop app
|
|
27
|
+
* still works).
|
|
28
|
+
*/
|
|
29
|
+
tokenSource?: OpTokenSource;
|
|
30
|
+
/** Test seam: the env, platform and home the token source is read with. */
|
|
31
|
+
tokenDeps?: OpTokenDeps;
|
|
14
32
|
timeoutMs?: number;
|
|
15
33
|
}
|
|
16
34
|
|
|
17
35
|
export class OnePasswordProvider implements SecretsProvider {
|
|
18
36
|
readonly name = "1password" as const;
|
|
19
37
|
private readonly timeout: number;
|
|
38
|
+
private readonly tokenSource: OpTokenSource | undefined;
|
|
39
|
+
private readonly tokenDeps: OpTokenDeps | undefined;
|
|
20
40
|
|
|
21
41
|
constructor(opts: OnePasswordOptions = {}) {
|
|
22
42
|
this.timeout = opts.timeoutMs ?? 60_000;
|
|
43
|
+
this.tokenSource = opts.tokenSource;
|
|
44
|
+
this.tokenDeps = opts.tokenDeps;
|
|
23
45
|
if (opts.account) process.env.OP_ACCOUNT ||= opts.account;
|
|
24
46
|
}
|
|
25
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The options for one `op` call. The token is read now (never cached) and
|
|
50
|
+
* goes only into the child's env. A configured source that yields none
|
|
51
|
+
* fails with its reason; the default source with no token runs `op` as
|
|
52
|
+
* before.
|
|
53
|
+
*/
|
|
54
|
+
private async opOptions(): Promise<{
|
|
55
|
+
timeout: number;
|
|
56
|
+
env?: NodeJS.ProcessEnv;
|
|
57
|
+
token?: string;
|
|
58
|
+
}> {
|
|
59
|
+
const explicit = this.tokenSource !== undefined;
|
|
60
|
+
const t = await readOpToken(
|
|
61
|
+
this.tokenSource ?? DEFAULT_OP_TOKEN_SOURCE,
|
|
62
|
+
this.tokenDeps,
|
|
63
|
+
);
|
|
64
|
+
if (!t.ok) {
|
|
65
|
+
if (explicit) throw new OpFailure("token_source", t.reason);
|
|
66
|
+
return { timeout: this.timeout, env: process.env };
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
timeout: this.timeout,
|
|
70
|
+
env: { ...process.env, [OP_TOKEN_ENV]: t.token },
|
|
71
|
+
token: t.token,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Run `op`; a failure carries op's reason in plain words, never the token. */
|
|
76
|
+
private async run(args: string[], rawErrors = false): Promise<string> {
|
|
77
|
+
const { token, ...opts } = await this.opOptions();
|
|
78
|
+
try {
|
|
79
|
+
const { stdout } = await execFileAsync("op", args, opts);
|
|
80
|
+
return String(stdout);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// `set` keeps op's own error: its caller strips the value it wrote.
|
|
83
|
+
if (rawErrors) throw err;
|
|
84
|
+
const e = err as { stderr?: unknown; killed?: boolean; code?: unknown };
|
|
85
|
+
if (e.killed)
|
|
86
|
+
throw new OpFailure("unknown", "1Password failed: op timed out");
|
|
87
|
+
if (e.code === "ENOENT")
|
|
88
|
+
throw new OpFailure("unknown", "1Password failed: op is not installed");
|
|
89
|
+
const m = mapOpFailure(String(e.stderr ?? ""), token ? [token] : []);
|
|
90
|
+
throw new OpFailure(m.code, m.text);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
26
94
|
async isReady(): Promise<boolean> {
|
|
27
95
|
try {
|
|
28
|
-
await
|
|
96
|
+
await this.run(["whoami"]);
|
|
29
97
|
return true;
|
|
30
98
|
} catch {
|
|
31
99
|
return false;
|
|
@@ -37,18 +105,26 @@ export class OnePasswordProvider implements SecretsProvider {
|
|
|
37
105
|
const args = ["item", "get", ref.item];
|
|
38
106
|
if (ref.vault) args.push("--vault", ref.vault);
|
|
39
107
|
args.push("--fields", `label=${ref.field}`, "--reveal");
|
|
40
|
-
|
|
41
|
-
timeout: this.timeout,
|
|
42
|
-
});
|
|
43
|
-
return String(stdout).trim();
|
|
108
|
+
return (await this.run(args)).trim();
|
|
44
109
|
}
|
|
45
110
|
|
|
46
111
|
async tryGet(ref: SecretRef): Promise<string | null> {
|
|
112
|
+
return (await this.tryGetWithReason(ref)).value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async tryGetWithReason(
|
|
116
|
+
ref: SecretRef,
|
|
117
|
+
): Promise<{ value: string | null; reason?: string }> {
|
|
47
118
|
try {
|
|
48
119
|
const v = await this.get(ref);
|
|
49
|
-
return v.length > 0
|
|
50
|
-
|
|
51
|
-
|
|
120
|
+
return v.length > 0
|
|
121
|
+
? { value: v }
|
|
122
|
+
: { value: null, reason: "the field is empty" };
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return {
|
|
125
|
+
value: null,
|
|
126
|
+
reason: err instanceof OpFailure ? err.message : undefined,
|
|
127
|
+
};
|
|
52
128
|
}
|
|
53
129
|
}
|
|
54
130
|
|
|
@@ -57,13 +133,10 @@ export class OnePasswordProvider implements SecretsProvider {
|
|
|
57
133
|
const vault = ref.vault ? ["--vault", ref.vault] : [];
|
|
58
134
|
const field = `${ref.field}[password]=${value}`;
|
|
59
135
|
try {
|
|
60
|
-
await
|
|
61
|
-
timeout: this.timeout,
|
|
62
|
-
});
|
|
136
|
+
await this.run(["item", "edit", ref.item, ...vault, field], true);
|
|
63
137
|
} catch (err) {
|
|
64
138
|
try {
|
|
65
|
-
await
|
|
66
|
-
"op",
|
|
139
|
+
await this.run(
|
|
67
140
|
[
|
|
68
141
|
"item",
|
|
69
142
|
"create",
|
|
@@ -74,7 +147,7 @@ export class OnePasswordProvider implements SecretsProvider {
|
|
|
74
147
|
...vault,
|
|
75
148
|
field,
|
|
76
149
|
],
|
|
77
|
-
|
|
150
|
+
true,
|
|
78
151
|
);
|
|
79
152
|
} catch {
|
|
80
153
|
throw err;
|