@magoz/provision 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +498 -2
- package/dist/cli.js +29 -0
- package/dist/contract.js +13 -0
- package/dist/db/branch-lifecycle.js +23 -0
- package/dist/db/commands.js +303 -0
- package/dist/db/config.js +75 -0
- package/dist/db/credentials.js +177 -0
- package/dist/db/domain.js +106 -0
- package/dist/db/environment.js +14 -0
- package/dist/db/lease.js +132 -0
- package/dist/db/neon.js +350 -0
- package/dist/db/operations.js +246 -0
- package/dist/db/policy.js +59 -0
- package/dist/env/commands.js +39 -0
- package/dist/env/domain.js +14 -0
- package/dist/env/install.js +25 -0
- package/dist/env/paths.js +105 -0
- package/dist/env/provision.js +320 -0
- package/dist/env/sanitize.js +88 -0
- package/dist/env/vercel.js +180 -0
- package/dist/factory/approval.js +33 -0
- package/dist/factory/commands.js +173 -0
- package/dist/factory/config-commands.js +215 -0
- package/dist/factory/context.js +129 -0
- package/dist/factory/http.js +37 -0
- package/dist/factory/onboarding.js +155 -0
- package/dist/factory/onepassword.js +140 -0
- package/dist/factory/op-credential.js +154 -0
- package/dist/factory/passphrase.js +145 -0
- package/dist/factory/providers/cloudflare.js +126 -0
- package/dist/factory/providers/neon.js +81 -0
- package/dist/factory/providers/report-receiver.js +37 -0
- package/dist/factory/providers/resend.js +32 -0
- package/dist/factory/providers/upstash.js +21 -0
- package/dist/factory/registry.js +50 -0
- package/dist/factory/scoped-key.js +30 -0
- package/dist/factory/sealed.js +64 -0
- package/dist/factory/secret-input.js +43 -0
- package/dist/factory/steps/domain.js +23 -0
- package/dist/factory/steps/neon.js +101 -0
- package/dist/factory/steps/r2.js +70 -0
- package/dist/factory/steps/reports.js +44 -0
- package/dist/factory/steps/resend.js +37 -0
- package/dist/factory/steps/secrets.js +104 -0
- package/dist/factory/steps/upstash.js +92 -0
- package/dist/factory/steps/vercel.js +68 -0
- package/dist/factory/vercel-api.js +165 -0
- package/dist/package-info.js +15 -0
- package/dist/shared/agent.js +28 -0
- package/dist/shared/env-file.js +54 -0
- package/dist/shared/git.js +48 -0
- package/dist/shared/output.js +10 -0
- package/dist/shared/private-file.js +63 -0
- package/dist/shared/process.js +55 -0
- package/dist/shared/repo-config.js +104 -0
- package/dist/shared/sandbox-profile.js +12 -0
- package/package.json +44 -4
- package/skills/provision/SKILL.md +172 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Console, Data, Effect, Option, Redacted, Schema } from 'effect';
|
|
2
|
+
import { Prompt } from 'effect/cli';
|
|
3
|
+
import { capture, inherit } from '../shared/process.js';
|
|
4
|
+
import { credentialSections, opErrorMessage } from './onepassword.js';
|
|
5
|
+
import { parseServiceAccountToken, storeServiceAccountToken } from './op-credential.js';
|
|
6
|
+
/**
|
|
7
|
+
* One-time factory setup with the operator's own 1Password sign-in: make sure the vault and its
|
|
8
|
+
* `provision` item exist, then create the factory's read-only service account and store its token.
|
|
9
|
+
* The operator session lives only in this process and is signed out at the end.
|
|
10
|
+
*/
|
|
11
|
+
export class OnboardingError extends Data.TaggedError('OnboardingError') {
|
|
12
|
+
}
|
|
13
|
+
const failure = (message) => new OnboardingError({ message });
|
|
14
|
+
/** A failure that includes `op`'s own explanation (never secret values). */
|
|
15
|
+
const opFailure = (message, result) => {
|
|
16
|
+
const reason = opErrorMessage(result.stderr);
|
|
17
|
+
return failure(reason === undefined ? message : `${message}: ${reason}`);
|
|
18
|
+
};
|
|
19
|
+
const Accounts = Schema.fromJsonString(Schema.Array(Schema.Struct({ url: Schema.String })));
|
|
20
|
+
const ensureAccount = Effect.gen(function* () {
|
|
21
|
+
const listed = yield* capture('op', ['account', 'list', '--format', 'json']);
|
|
22
|
+
const accounts = Schema.decodeUnknownOption(Accounts)(listed.stdout.trim() || '[]');
|
|
23
|
+
if (Option.isSome(accounts) && accounts.value.length > 0)
|
|
24
|
+
return;
|
|
25
|
+
yield* Console.log('No 1Password account is configured on this machine; adding yours.');
|
|
26
|
+
yield* inherit('op', ['account', 'add']);
|
|
27
|
+
});
|
|
28
|
+
/** `op signin` prints `export OP_SESSION_<id>="<token>"` for the shell to evaluate. */
|
|
29
|
+
export const parseSessionExport = (output) => {
|
|
30
|
+
const match = /export (OP_SESSION_[A-Za-z0-9_]+)="([^"]+)"/.exec(output);
|
|
31
|
+
return match?.[1] === undefined || match[2] === undefined
|
|
32
|
+
? undefined
|
|
33
|
+
: { name: match[1], token: Redacted.make(match[2]) };
|
|
34
|
+
};
|
|
35
|
+
const signIn = Effect.gen(function* () {
|
|
36
|
+
yield* ensureAccount;
|
|
37
|
+
yield* Console.log('Sign in to 1Password (used only for this setup, then signed out):');
|
|
38
|
+
const result = yield* capture('op', ['signin'], { interactive: true });
|
|
39
|
+
const session = result.exitCode === 0 ? parseSessionExport(result.stdout) : undefined;
|
|
40
|
+
if (session === undefined)
|
|
41
|
+
return yield* failure('1Password sign-in failed');
|
|
42
|
+
const env = { [session.name]: Redacted.value(session.token) };
|
|
43
|
+
return env;
|
|
44
|
+
});
|
|
45
|
+
const signOut = (env) => capture('op', ['signout'], { env }).pipe(Effect.ignore);
|
|
46
|
+
/** The operator's 1Password session for the duration of `use`; always signed out afterwards. */
|
|
47
|
+
export const withOperatorSession = (use) => Effect.acquireUseRelease(signIn, use, signOut);
|
|
48
|
+
const confirm = (options, message) => options.assumeYes
|
|
49
|
+
? Effect.succeed(true)
|
|
50
|
+
: Prompt.run(Prompt.Confirm({ message, initial: true })).pipe(Effect.mapError(() => failure('cancelled')));
|
|
51
|
+
const ensureVault = (env, vault, options) => Effect.gen(function* () {
|
|
52
|
+
const found = yield* capture('op', ['vault', 'get', vault, '--format', 'json'], { env });
|
|
53
|
+
if (found.exitCode === 0)
|
|
54
|
+
return;
|
|
55
|
+
if (!(yield* confirm(options, `Vault ${vault} does not exist. Create it?`))) {
|
|
56
|
+
return yield* failure(`vault ${vault} is required`);
|
|
57
|
+
}
|
|
58
|
+
const created = yield* capture('op', ['vault', 'create', vault], { env });
|
|
59
|
+
if (created.exitCode !== 0)
|
|
60
|
+
return yield* opFailure(`could not create vault ${vault}`, created);
|
|
61
|
+
yield* Console.log(`created vault ${vault}`);
|
|
62
|
+
});
|
|
63
|
+
const secretFields = new Set(['api_key', 'api_token', 'factory_key']);
|
|
64
|
+
/** An empty item with every factory field, ready to be filled in the 1Password app. */
|
|
65
|
+
export const templateFields = [
|
|
66
|
+
...credentialSections.map(spec => ({ section: spec.section, labels: spec.required })),
|
|
67
|
+
{ section: 'resend', labels: ['sending_domain'] }
|
|
68
|
+
].flatMap(({ section, labels }) => labels.map(label => ({ section, label, concealed: secretFields.has(label) })));
|
|
69
|
+
/** `op` assignment statements that create each field empty (no values, so nothing secret). */
|
|
70
|
+
const emptyFieldAssignments = (fields) => fields.map(field => `${field.section}.${field.label}[${field.concealed ? 'password' : 'text'}]=`);
|
|
71
|
+
const OpItemFields = Schema.fromJsonString(Schema.Struct({
|
|
72
|
+
fields: Schema.optional(Schema.Array(Schema.Struct({
|
|
73
|
+
label: Schema.optional(Schema.String),
|
|
74
|
+
section: Schema.optional(Schema.Struct({ label: Schema.optional(Schema.String) }))
|
|
75
|
+
})))
|
|
76
|
+
}));
|
|
77
|
+
/** Template fields an item lacks entirely (empty fields count as present). */
|
|
78
|
+
export const missingTemplateFields = (itemJson) => {
|
|
79
|
+
const decoded = Schema.decodeUnknownOption(OpItemFields)(itemJson);
|
|
80
|
+
const present = new Set(Option.match(decoded, { onNone: () => [], onSome: item => item.fields ?? [] }).flatMap(field => {
|
|
81
|
+
const label = field.label?.trim().toLowerCase();
|
|
82
|
+
const section = field.section?.label?.trim().toLowerCase();
|
|
83
|
+
if (label === undefined || label === '')
|
|
84
|
+
return [];
|
|
85
|
+
return [section === undefined || section === '' ? label : `${section}.${label}`];
|
|
86
|
+
}));
|
|
87
|
+
return templateFields.filter(field => !present.has(`${field.section}.${field.label}`));
|
|
88
|
+
};
|
|
89
|
+
/** Adds the missing fields with empty values; existing fields and values are never touched. */
|
|
90
|
+
const addMissingFields = (env, vault, item, missing) => Effect.gen(function* () {
|
|
91
|
+
if (missing.length === 0)
|
|
92
|
+
return;
|
|
93
|
+
const assignments = emptyFieldAssignments(missing);
|
|
94
|
+
const edited = yield* capture('op', ['item', 'edit', item, '--vault', vault, ...assignments], {
|
|
95
|
+
env
|
|
96
|
+
});
|
|
97
|
+
if (edited.exitCode !== 0)
|
|
98
|
+
return yield* opFailure(`could not add fields to item ${item}`, edited);
|
|
99
|
+
yield* Console.log(`added ${missing.length} empty field(s) to ${item}: ${missing.map(field => `${field.section}.${field.label}`).join(', ')}`);
|
|
100
|
+
});
|
|
101
|
+
/** Ensures the item exists; returns `true` when it was just created (and so is still empty). */
|
|
102
|
+
const ensureItem = (env, vault, item, options) => Effect.gen(function* () {
|
|
103
|
+
const found = yield* capture('op', ['item', 'get', item, '--vault', vault, '--format', 'json'], {
|
|
104
|
+
env
|
|
105
|
+
});
|
|
106
|
+
if (found.exitCode === 0) {
|
|
107
|
+
yield* addMissingFields(env, vault, item, missingTemplateFields(found.stdout));
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (!(yield* confirm(options, `Item ${item} does not exist in ${vault}. Create it with empty fields?`))) {
|
|
111
|
+
return yield* failure(`item ${item} is required`);
|
|
112
|
+
}
|
|
113
|
+
const created = yield* capture('op', [
|
|
114
|
+
'item',
|
|
115
|
+
'create',
|
|
116
|
+
'--vault',
|
|
117
|
+
vault,
|
|
118
|
+
'--category',
|
|
119
|
+
'Secure Note',
|
|
120
|
+
'--title',
|
|
121
|
+
item,
|
|
122
|
+
...emptyFieldAssignments(templateFields)
|
|
123
|
+
], { env });
|
|
124
|
+
if (created.exitCode !== 0) {
|
|
125
|
+
return yield* opFailure(`could not create item ${item} in ${vault}`, created);
|
|
126
|
+
}
|
|
127
|
+
yield* Console.log(`created item ${item} in ${vault}`);
|
|
128
|
+
return true;
|
|
129
|
+
});
|
|
130
|
+
const createServiceAccount = (env, factory, vault) => Effect.gen(function* () {
|
|
131
|
+
const name = `provision-${factory}`;
|
|
132
|
+
yield* Console.log(`creating read-only service account ${name} for ${vault}`);
|
|
133
|
+
const created = yield* capture('op', ['service-account', 'create', name, '--vault', `${vault}:read_items`, '--raw'], { env });
|
|
134
|
+
if (created.exitCode !== 0) {
|
|
135
|
+
return yield* opFailure(`could not create service account ${name} (if it already exists, revoke it under Developer > Service accounts and run this again)`, created);
|
|
136
|
+
}
|
|
137
|
+
return yield* parseServiceAccountToken(created.stdout);
|
|
138
|
+
});
|
|
139
|
+
/**
|
|
140
|
+
* Signs in as the operator and makes sure the item exists with every field: creates it when
|
|
141
|
+
* missing, adds missing fields (empty) otherwise. Existing fields and values are never touched.
|
|
142
|
+
*/
|
|
143
|
+
export const ensureFactoryItem = (vault, item, options) => withOperatorSession(env => ensureItem(env, vault, item, options));
|
|
144
|
+
/**
|
|
145
|
+
* Signs in as the operator, ensures the vault and item, then creates and stores the factory's
|
|
146
|
+
* read-only service account. Returns whether the item was just created (and still needs values).
|
|
147
|
+
*/
|
|
148
|
+
export const onboardFactory = (factory, vault, item, options) => withOperatorSession(env => Effect.gen(function* () {
|
|
149
|
+
yield* ensureVault(env, vault, options);
|
|
150
|
+
const createdItem = yield* ensureItem(env, vault, item, options);
|
|
151
|
+
const token = yield* createServiceAccount(env, factory, vault);
|
|
152
|
+
const file = yield* storeServiceAccountToken(factory, token);
|
|
153
|
+
yield* Console.log(`stored the ${factory} service-account token encrypted at ${file}`);
|
|
154
|
+
return createdItem;
|
|
155
|
+
}));
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Data, Effect, Option, Redacted, Schema } from 'effect';
|
|
2
|
+
import { capture } from '../shared/process.js';
|
|
3
|
+
import { opEnvironment } from './op-credential.js';
|
|
4
|
+
export class CredentialsError extends Data.TaggedError('CredentialsError') {
|
|
5
|
+
}
|
|
6
|
+
const OpField = Schema.Struct({
|
|
7
|
+
label: Schema.optional(Schema.String),
|
|
8
|
+
value: Schema.optional(Schema.String),
|
|
9
|
+
section: Schema.optional(Schema.Struct({ label: Schema.optional(Schema.String) }))
|
|
10
|
+
});
|
|
11
|
+
const OpItem = Schema.fromJsonString(Schema.Struct({ fields: Schema.optional(Schema.Array(OpField)) }));
|
|
12
|
+
/**
|
|
13
|
+
* The first `[ERROR]` line of `op`'s stderr, without its timestamp. `op` error messages name
|
|
14
|
+
* items, vaults, and permissions, never secret values.
|
|
15
|
+
*/
|
|
16
|
+
export const opErrorMessage = (stderr) => {
|
|
17
|
+
const line = stderr
|
|
18
|
+
// oxlint-disable-next-line no-control-regex
|
|
19
|
+
.replace(/\u001b\[[0-9;]*m/g, '')
|
|
20
|
+
.split('\n')
|
|
21
|
+
.map(candidate => candidate.trim())
|
|
22
|
+
.find(candidate => candidate.length > 0);
|
|
23
|
+
return line === undefined
|
|
24
|
+
? undefined
|
|
25
|
+
: line.replace(/^\[ERROR\]\s*\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}\s*/, '').slice(0, 300);
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Reads one factory item as `op` JSON (values revealed; never printed). A failing `op` call
|
|
29
|
+
* reports only its exit code.
|
|
30
|
+
*/
|
|
31
|
+
export const readItemJson = (entry, auth) => Effect.gen(function* () {
|
|
32
|
+
const args = ['item', 'get', entry.item, '--vault', entry.vault, '--format', 'json', '--reveal'];
|
|
33
|
+
if (entry.account !== undefined)
|
|
34
|
+
args.push('--account', entry.account);
|
|
35
|
+
const result = yield* capture('op', args, Option.match(opEnvironment(auth), { onNone: () => ({}), onSome: env => ({ env }) })).pipe(Effect.mapError(() => new CredentialsError({ message: '1Password CLI (op) could not be started' })));
|
|
36
|
+
if (result.exitCode !== 0) {
|
|
37
|
+
const reason = opErrorMessage(result.stderr);
|
|
38
|
+
if (reason?.includes("isn't an item") === true) {
|
|
39
|
+
return yield* new CredentialsError({
|
|
40
|
+
message: `item ${entry.item} does not exist in vault ${entry.vault}`,
|
|
41
|
+
notFound: true
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return yield* new CredentialsError({
|
|
45
|
+
message: `cannot read 1Password item ${entry.item} in vault ${entry.vault} (op exit ${result.exitCode}` +
|
|
46
|
+
`${reason === undefined ? '' : `: ${reason}`}); ` +
|
|
47
|
+
(auth.kind === 'session'
|
|
48
|
+
? 'sign in with `eval "$(op signin)"` or store a service-account token with `provision config op-token set`'
|
|
49
|
+
: 'check that the service account has read access to this vault')
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return result.stdout;
|
|
53
|
+
});
|
|
54
|
+
/** Reads one factory item's non-empty fields; values stay redacted. */
|
|
55
|
+
export const readItemFields = (entry, auth) => Effect.flatMap(readItemJson(entry, auth), parseItemFields);
|
|
56
|
+
/** Non-empty fields of an `op item get --format json` result. */
|
|
57
|
+
export const parseItemFields = (json) => Effect.gen(function* () {
|
|
58
|
+
const item = yield* Schema.decodeUnknownEffect(OpItem)(json).pipe(Effect.mapError(() => new CredentialsError({ message: '1Password returned an unexpected item shape' })));
|
|
59
|
+
const fields = new Map();
|
|
60
|
+
for (const field of item.fields ?? []) {
|
|
61
|
+
const label = field.label?.trim();
|
|
62
|
+
const value = field.value?.trim();
|
|
63
|
+
if (label === undefined || label === '' || value === undefined || value === '')
|
|
64
|
+
continue;
|
|
65
|
+
const section = field.section?.label?.trim();
|
|
66
|
+
const key = section === undefined || section === '' ? label : `${section}.${label}`;
|
|
67
|
+
fields.set(key.toLowerCase(), Redacted.make(value));
|
|
68
|
+
}
|
|
69
|
+
return fields;
|
|
70
|
+
});
|
|
71
|
+
export const credentialSections = [
|
|
72
|
+
{ section: 'github', required: ['owner'] },
|
|
73
|
+
{ section: 'vercel', required: ['team_id'] },
|
|
74
|
+
{ section: 'neon', required: ['api_key', 'org_id'] },
|
|
75
|
+
{ section: 'cloudflare', required: ['api_token', 'account_id'] },
|
|
76
|
+
{ section: 'upstash', required: ['email', 'api_key'] },
|
|
77
|
+
{ section: 'resend', required: ['api_key'] },
|
|
78
|
+
{ section: 'report_receiver', required: ['url', 'factory_key'] }
|
|
79
|
+
];
|
|
80
|
+
/** Which sections are complete, partial, or absent: reported by `provision config verify`. */
|
|
81
|
+
export const describeSections = (fields) => credentialSections.map(spec => {
|
|
82
|
+
const present = spec.required.filter(name => fields.has(`${spec.section}.${name}`));
|
|
83
|
+
return {
|
|
84
|
+
section: spec.section,
|
|
85
|
+
status: present.length === spec.required.length
|
|
86
|
+
? 'complete'
|
|
87
|
+
: present.length === 0
|
|
88
|
+
? 'absent'
|
|
89
|
+
: 'partial',
|
|
90
|
+
missing: spec.required.filter(name => !present.includes(name))
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
export const decodeCredentials = (fields) => Effect.gen(function* () {
|
|
94
|
+
const partial = describeSections(fields).filter(entry => entry.status === 'partial');
|
|
95
|
+
if (partial.length > 0) {
|
|
96
|
+
return yield* new CredentialsError({
|
|
97
|
+
message: `incomplete factory item sections: ${partial
|
|
98
|
+
.map(entry => `${entry.section} (missing ${entry.missing.join(', ')})`)
|
|
99
|
+
.join('; ')}`
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const field = (key) => fields.get(key);
|
|
103
|
+
const text = (key) => {
|
|
104
|
+
const value = field(key);
|
|
105
|
+
return value === undefined ? undefined : Redacted.value(value);
|
|
106
|
+
};
|
|
107
|
+
const githubOwner = text('github.owner');
|
|
108
|
+
const teamId = text('vercel.team_id');
|
|
109
|
+
if (githubOwner === undefined || teamId === undefined) {
|
|
110
|
+
return yield* new CredentialsError({
|
|
111
|
+
message: 'factory item needs github.owner and vercel.team_id'
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (!/^team_[A-Za-z0-9]+$/.test(teamId)) {
|
|
115
|
+
return yield* new CredentialsError({
|
|
116
|
+
message: 'vercel.team_id must be an immutable team_* identifier'
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const neonKey = field('neon.api_key');
|
|
120
|
+
const neonOrg = text('neon.org_id');
|
|
121
|
+
const cloudflareToken = field('cloudflare.api_token');
|
|
122
|
+
const cloudflareAccount = text('cloudflare.account_id');
|
|
123
|
+
const upstashEmail = text('upstash.email');
|
|
124
|
+
const upstashKey = field('upstash.api_key');
|
|
125
|
+
const resendKey = field('resend.api_key');
|
|
126
|
+
const sendingDomain = text('resend.sending_domain');
|
|
127
|
+
const receiverUrl = text('report_receiver.url');
|
|
128
|
+
const receiverKey = field('report_receiver.factory_key');
|
|
129
|
+
return {
|
|
130
|
+
githubOwner,
|
|
131
|
+
vercel: { teamId },
|
|
132
|
+
neon: neonKey && neonOrg ? { apiKey: neonKey, orgId: neonOrg } : undefined,
|
|
133
|
+
cloudflare: cloudflareToken && cloudflareAccount
|
|
134
|
+
? { apiToken: cloudflareToken, accountId: cloudflareAccount }
|
|
135
|
+
: undefined,
|
|
136
|
+
upstash: upstashEmail && upstashKey ? { email: upstashEmail, apiKey: upstashKey } : undefined,
|
|
137
|
+
resend: resendKey ? { apiKey: resendKey, sendingDomain } : undefined,
|
|
138
|
+
reportReceiver: receiverUrl && receiverKey ? { url: receiverUrl, factoryKey: receiverKey } : undefined
|
|
139
|
+
};
|
|
140
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Data, Effect, FileSystem, Option, Redacted } from 'effect';
|
|
2
|
+
import { capture } from '../shared/process.js';
|
|
3
|
+
import { Passphrase } from './passphrase.js';
|
|
4
|
+
import { RegistryLocation } from './registry.js';
|
|
5
|
+
import { seal, unseal } from './sealed.js';
|
|
6
|
+
/**
|
|
7
|
+
* How `op` authenticates when the CLI reads factory items, in order:
|
|
8
|
+
*
|
|
9
|
+
* 1. `OP_SERVICE_ACCOUNT_TOKEN` already in the environment (`op` reads it itself);
|
|
10
|
+
* 2. the factory's own read-only service-account token, sealed with the operator's provision
|
|
11
|
+
* passphrase and then encrypted with `systemd-creds --user` (TPM2-bound where available).
|
|
12
|
+
* Every run needs the passphrase, typed by the operator in a terminal: that is the approval.
|
|
13
|
+
* One token per factory keeps factories isolated;
|
|
14
|
+
* 3. whatever `op` is signed in with: a manual `op signin` session or the desktop app.
|
|
15
|
+
*/
|
|
16
|
+
export class OpCredentialError extends Data.TaggedError('OpCredentialError') {
|
|
17
|
+
}
|
|
18
|
+
const credentialName = (factory) => `provision-op-${factory}`;
|
|
19
|
+
const credentialPattern = /^op-service-account\.([a-z][a-z0-9-]{0,31})\.cred$/;
|
|
20
|
+
export const serviceAccountTokenVariable = 'OP_SERVICE_ACCOUNT_TOKEN';
|
|
21
|
+
const registryDirectory = Effect.gen(function* () {
|
|
22
|
+
const { file } = yield* RegistryLocation;
|
|
23
|
+
return file.slice(0, file.lastIndexOf('/'));
|
|
24
|
+
});
|
|
25
|
+
export const opCredentialFile = (factory) => Effect.map(registryDirectory, directory => `${directory}/op-service-account.${factory}.cred`);
|
|
26
|
+
/** Factories with a stored token. */
|
|
27
|
+
export const storedTokenFactories = Effect.gen(function* () {
|
|
28
|
+
const fs = yield* FileSystem.FileSystem;
|
|
29
|
+
const directory = yield* registryDirectory;
|
|
30
|
+
const entries = yield* fs
|
|
31
|
+
.readDirectory(directory)
|
|
32
|
+
.pipe(Effect.orElseSucceed(() => []));
|
|
33
|
+
return entries.flatMap(entry => {
|
|
34
|
+
const factory = credentialPattern.exec(entry)?.[1];
|
|
35
|
+
return factory === undefined ? [] : [factory];
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
/** Service-account tokens are `ops_` followed by an encoded payload. */
|
|
39
|
+
export const parseServiceAccountToken = (value) => {
|
|
40
|
+
const token = value.trim();
|
|
41
|
+
return /^ops_[A-Za-z0-9_-]{20,}={0,2}$/.test(token)
|
|
42
|
+
? Effect.succeed(Redacted.make(token))
|
|
43
|
+
: Effect.fail(new OpCredentialError({
|
|
44
|
+
message: 'not a 1Password service-account token (expected ops_…)'
|
|
45
|
+
}));
|
|
46
|
+
};
|
|
47
|
+
const unavailable = () => new OpCredentialError({
|
|
48
|
+
message: 'systemd-creds is unavailable; export OP_SERVICE_ACCOUNT_TOKEN instead, or sign in with op'
|
|
49
|
+
});
|
|
50
|
+
const decryptSealed = (factory, file) => Effect.gen(function* () {
|
|
51
|
+
const result = yield* capture('systemd-creds', [
|
|
52
|
+
'--user',
|
|
53
|
+
'decrypt',
|
|
54
|
+
`--name=${credentialName(factory)}`,
|
|
55
|
+
file,
|
|
56
|
+
'-'
|
|
57
|
+
]).pipe(Effect.mapError(unavailable));
|
|
58
|
+
if (result.exitCode !== 0) {
|
|
59
|
+
return yield* new OpCredentialError({
|
|
60
|
+
message: `cannot decrypt ${file} (exit ${result.exitCode}); store the token again with \`provision config op-token set --factory ${factory}\``
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return result.stdout;
|
|
64
|
+
});
|
|
65
|
+
/** Unseals one factory's stored token with the (prompted, cached) passphrase. */
|
|
66
|
+
const openStoredToken = (factory, file) => Effect.gen(function* () {
|
|
67
|
+
const sealed = yield* decryptSealed(factory, file);
|
|
68
|
+
const passphrase = yield* (yield* Passphrase).get;
|
|
69
|
+
const token = yield* unseal(passphrase, sealed, credentialName(factory)).pipe(Effect.mapError(error => error.reason === 'malformed'
|
|
70
|
+
? new OpCredentialError({
|
|
71
|
+
message: `${file} is not passphrase-protected; store the token again with \`provision config op-token set --factory ${factory}\``
|
|
72
|
+
})
|
|
73
|
+
: new OpCredentialError({ message: `${error.message} for the ${factory} token` })));
|
|
74
|
+
return yield* parseServiceAccountToken(Redacted.value(token));
|
|
75
|
+
});
|
|
76
|
+
/**
|
|
77
|
+
* Seals the token with the provision passphrase, then encrypts it with `systemd-creds --user`
|
|
78
|
+
* into a `0600` file. The first stored token chooses the passphrase; later ones must use the same
|
|
79
|
+
* passphrase, proven by opening an existing token.
|
|
80
|
+
*/
|
|
81
|
+
export const storeServiceAccountToken = (factory, token) => Effect.gen(function* () {
|
|
82
|
+
const fs = yield* FileSystem.FileSystem;
|
|
83
|
+
const passphrases = yield* Passphrase;
|
|
84
|
+
const file = yield* opCredentialFile(factory);
|
|
85
|
+
const others = (yield* storedTokenFactories).filter(other => other !== factory);
|
|
86
|
+
const existing = others[0];
|
|
87
|
+
const passphrase = existing === undefined
|
|
88
|
+
? yield* passphrases.choose
|
|
89
|
+
: yield* openStoredToken(existing, yield* opCredentialFile(existing)).pipe(Effect.andThen(passphrases.get), Effect.mapError(error => error instanceof OpCredentialError && error.message.startsWith('wrong passphrase')
|
|
90
|
+
? new OpCredentialError({
|
|
91
|
+
message: `that is not the passphrase protecting the ${existing} token`
|
|
92
|
+
})
|
|
93
|
+
: error));
|
|
94
|
+
const sealed = yield* seal(passphrase, token, credentialName(factory));
|
|
95
|
+
yield* fs
|
|
96
|
+
.makeDirectory(file.slice(0, file.lastIndexOf('/')), { recursive: true })
|
|
97
|
+
.pipe(Effect.mapError(() => new OpCredentialError({ message: `cannot create ${file}` })));
|
|
98
|
+
const result = yield* capture('systemd-creds', ['--user', 'encrypt', `--name=${credentialName(factory)}`, '-', file], { input: sealed }).pipe(Effect.mapError(unavailable));
|
|
99
|
+
if (result.exitCode !== 0) {
|
|
100
|
+
return yield* new OpCredentialError({
|
|
101
|
+
message: `systemd-creds could not encrypt the token (exit ${result.exitCode})`
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
yield* fs
|
|
105
|
+
.chmod(file, 0o600)
|
|
106
|
+
.pipe(Effect.mapError(() => new OpCredentialError({ message: `cannot protect ${file}` })));
|
|
107
|
+
return file;
|
|
108
|
+
});
|
|
109
|
+
export const removeServiceAccountToken = (factory) => Effect.gen(function* () {
|
|
110
|
+
const fs = yield* FileSystem.FileSystem;
|
|
111
|
+
const file = yield* opCredentialFile(factory);
|
|
112
|
+
const exists = yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false));
|
|
113
|
+
if (!exists)
|
|
114
|
+
return false;
|
|
115
|
+
yield* fs
|
|
116
|
+
.remove(file)
|
|
117
|
+
.pipe(Effect.mapError(() => new OpCredentialError({ message: `cannot remove ${file}` })));
|
|
118
|
+
return true;
|
|
119
|
+
});
|
|
120
|
+
/** Where `op` credentials would come from for a factory, without prompting or decrypting. */
|
|
121
|
+
export const opAuthKind = (factory) => Effect.gen(function* () {
|
|
122
|
+
const fs = yield* FileSystem.FileSystem;
|
|
123
|
+
if ((process.env[serviceAccountTokenVariable] ?? '').trim() !== '') {
|
|
124
|
+
return 'environment';
|
|
125
|
+
}
|
|
126
|
+
const file = yield* opCredentialFile(factory);
|
|
127
|
+
const exists = yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false));
|
|
128
|
+
return exists ? 'credential' : 'session';
|
|
129
|
+
});
|
|
130
|
+
export const describeOpAuth = (factory) => Effect.gen(function* () {
|
|
131
|
+
const kind = yield* opAuthKind(factory);
|
|
132
|
+
return kind === 'environment'
|
|
133
|
+
? 'service account (OP_SERVICE_ACCOUNT_TOKEN)'
|
|
134
|
+
: kind === 'credential'
|
|
135
|
+
? `passphrase-protected service account (${yield* opCredentialFile(factory)})`
|
|
136
|
+
: 'op session or desktop app';
|
|
137
|
+
});
|
|
138
|
+
/** Resolves how `op` authenticates for one factory; a stored token needs the passphrase. */
|
|
139
|
+
export const resolveOpAuth = (factory) => Effect.gen(function* () {
|
|
140
|
+
const fs = yield* FileSystem.FileSystem;
|
|
141
|
+
if ((process.env[serviceAccountTokenVariable] ?? '').trim() !== '') {
|
|
142
|
+
return { kind: 'environment' };
|
|
143
|
+
}
|
|
144
|
+
const file = yield* opCredentialFile(factory);
|
|
145
|
+
const exists = yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false));
|
|
146
|
+
if (!exists)
|
|
147
|
+
return { kind: 'session' };
|
|
148
|
+
const token = yield* openStoredToken(factory, file);
|
|
149
|
+
return { kind: 'credential', file, token };
|
|
150
|
+
});
|
|
151
|
+
/** Child-only environment for `op` under a stored service-account token. */
|
|
152
|
+
export const opEnvironment = (source) => source.kind === 'credential'
|
|
153
|
+
? Option.some({ [serviceAccountTokenVariable]: Redacted.value(source.token) })
|
|
154
|
+
: Option.none();
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { closeSync, openSync, writeSync } from 'node:fs';
|
|
2
|
+
import { ReadStream } from 'node:tty';
|
|
3
|
+
import { Context, Data, Effect, Layer, Redacted } from 'effect';
|
|
4
|
+
/**
|
|
5
|
+
* The provision passphrase: the human approval for every run that reads factory credentials.
|
|
6
|
+
* It is read from the controlling terminal only, never from arguments, environment, or stdin, and
|
|
7
|
+
* only when this process is attached to a terminal, so agent-launched commands cannot be
|
|
8
|
+
* answered by the agent. One prompt per process; the value stays in memory for that run only.
|
|
9
|
+
*/
|
|
10
|
+
export class PassphraseError extends Data.TaggedError('PassphraseError') {
|
|
11
|
+
}
|
|
12
|
+
export class Passphrase extends Context.Service()('provision/factory/Passphrase') {
|
|
13
|
+
}
|
|
14
|
+
export const minimumPassphraseLength = 12;
|
|
15
|
+
const notInteractive = () => new PassphraseError({
|
|
16
|
+
message: 'this needs your provision passphrase: run the command yourself in a terminal (agents cannot approve it)'
|
|
17
|
+
});
|
|
18
|
+
/**
|
|
19
|
+
* Reads hidden input from `/dev/tty`, line by line, until `next` says a line ends it. Lines may
|
|
20
|
+
* arrive in one pasted chunk; CR, LF, and CRLF all end a line. Ctrl-C, or Ctrl-D on an empty
|
|
21
|
+
* line, cancels. `next` can print a confirmation (never the value) after each line.
|
|
22
|
+
*/
|
|
23
|
+
const readHidden = (message, next) => Effect.callback(resume => {
|
|
24
|
+
if (process.stdin.isTTY !== true && process.stdout.isTTY !== true) {
|
|
25
|
+
resume(Effect.fail(notInteractive()));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
let readFd;
|
|
29
|
+
let writeFd;
|
|
30
|
+
try {
|
|
31
|
+
readFd = openSync('/dev/tty', 'r');
|
|
32
|
+
writeFd = openSync('/dev/tty', 'w');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
resume(Effect.fail(notInteractive()));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const input = new ReadStream(readFd);
|
|
39
|
+
const lines = [];
|
|
40
|
+
let characters = [];
|
|
41
|
+
let afterCarriageReturn = false;
|
|
42
|
+
let finished = false;
|
|
43
|
+
const write = (text) => {
|
|
44
|
+
try {
|
|
45
|
+
const buffer = Buffer.from(text);
|
|
46
|
+
let offset = 0;
|
|
47
|
+
while (offset < buffer.length) {
|
|
48
|
+
offset += writeSync(writeFd, buffer, offset);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// The prompt is cosmetic; reading still works.
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const finish = (outcome) => {
|
|
56
|
+
finished = true;
|
|
57
|
+
input.removeListener('data', onData);
|
|
58
|
+
input.setRawMode(false);
|
|
59
|
+
input.destroy();
|
|
60
|
+
write('\n');
|
|
61
|
+
closeSync(writeFd);
|
|
62
|
+
resume(outcome === 'done'
|
|
63
|
+
? Effect.succeed(lines.map(line => Redacted.make(line)))
|
|
64
|
+
: Effect.fail(new PassphraseError({ message: 'cancelled' })));
|
|
65
|
+
};
|
|
66
|
+
const endLine = () => {
|
|
67
|
+
lines.push(characters.join(''));
|
|
68
|
+
characters = [];
|
|
69
|
+
const { done, note } = next(lines);
|
|
70
|
+
if (note !== undefined)
|
|
71
|
+
write(`\n${note}`);
|
|
72
|
+
if (done)
|
|
73
|
+
finish('done');
|
|
74
|
+
};
|
|
75
|
+
const onData = (chunk) => {
|
|
76
|
+
for (const character of chunk.toString('utf8')) {
|
|
77
|
+
if (finished)
|
|
78
|
+
return;
|
|
79
|
+
const wasCarriageReturn = afterCarriageReturn;
|
|
80
|
+
afterCarriageReturn = character === '\r';
|
|
81
|
+
if (character === '\n' && wasCarriageReturn)
|
|
82
|
+
continue;
|
|
83
|
+
if (character === '\r' || character === '\n') {
|
|
84
|
+
endLine();
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (character === '\u0003' || (character === '\u0004' && characters.length === 0)) {
|
|
88
|
+
return finish('cancelled');
|
|
89
|
+
}
|
|
90
|
+
if (character === '\u007f' || character === '\b')
|
|
91
|
+
characters.pop();
|
|
92
|
+
else if (character >= ' ')
|
|
93
|
+
characters.push(character);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
write(message);
|
|
97
|
+
input.setRawMode(true);
|
|
98
|
+
input.on('data', onData);
|
|
99
|
+
return Effect.sync(() => {
|
|
100
|
+
if (finished)
|
|
101
|
+
return;
|
|
102
|
+
input.removeListener('data', onData);
|
|
103
|
+
input.setRawMode(false);
|
|
104
|
+
input.destroy();
|
|
105
|
+
closeSync(writeFd);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
/** Reads one hidden line from `/dev/tty`. Ctrl-C or Ctrl-D cancels. */
|
|
109
|
+
export const readHiddenLine = (message) => readHidden(message, () => ({ done: true })).pipe(Effect.map(lines => lines[0] ?? Redacted.make('')));
|
|
110
|
+
/**
|
|
111
|
+
* Reads hidden lines until an empty line or until `complete` accepts them, so a pasted block
|
|
112
|
+
* arrives in one go; `note` confirms each line without echoing it.
|
|
113
|
+
*/
|
|
114
|
+
export const readHiddenLines = (message, options) => readHidden(message, lines => {
|
|
115
|
+
const last = lines.at(-1) ?? '';
|
|
116
|
+
const note = options.note(last);
|
|
117
|
+
const done = last.trim() === '' || options.complete(lines);
|
|
118
|
+
return note === undefined ? { done } : { done, note };
|
|
119
|
+
}).pipe(Effect.map(lines => lines.filter(line => Redacted.value(line).trim() !== '')));
|
|
120
|
+
export const makePassphrase = (read) => {
|
|
121
|
+
let cached;
|
|
122
|
+
const remember = (value) => Effect.sync(() => {
|
|
123
|
+
cached = value;
|
|
124
|
+
return value;
|
|
125
|
+
});
|
|
126
|
+
return Passphrase.of({
|
|
127
|
+
get: Effect.suspend(() => cached === undefined
|
|
128
|
+
? read('provision passphrase: ').pipe(Effect.flatMap(remember))
|
|
129
|
+
: Effect.succeed(cached)),
|
|
130
|
+
choose: Effect.gen(function* () {
|
|
131
|
+
const first = yield* read('new provision passphrase: ');
|
|
132
|
+
if (Redacted.value(first).length < minimumPassphraseLength) {
|
|
133
|
+
return yield* new PassphraseError({
|
|
134
|
+
message: `the passphrase needs at least ${minimumPassphraseLength} characters`
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const second = yield* read('repeat the passphrase: ');
|
|
138
|
+
if (Redacted.value(first) !== Redacted.value(second)) {
|
|
139
|
+
return yield* new PassphraseError({ message: 'the passphrases do not match' });
|
|
140
|
+
}
|
|
141
|
+
return yield* remember(first);
|
|
142
|
+
})
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
export const PassphraseLive = Layer.sync(Passphrase, () => makePassphrase(readHiddenLine));
|