laneyard 0.3.0 → 0.4.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/README.md +186 -18
  2. package/dist/src/cli/detect.js +12 -3
  3. package/dist/src/cli/prompt.js +28 -3
  4. package/dist/src/cli/secret-import.js +162 -0
  5. package/dist/src/cli/secret.js +162 -1
  6. package/dist/src/cli/setup.js +44 -7
  7. package/dist/src/credentials/kinds.js +120 -0
  8. package/dist/src/db/credentials.js +75 -0
  9. package/dist/src/db/schema.sql +39 -0
  10. package/dist/src/db/secrets.js +28 -0
  11. package/dist/src/db/sessions.js +61 -0
  12. package/dist/src/git/workspace.js +32 -6
  13. package/dist/src/heuristics/android-root.js +49 -0
  14. package/dist/src/heuristics/android-signing.js +98 -0
  15. package/dist/src/heuristics/appfile.js +60 -0
  16. package/dist/src/heuristics/blocking-actions.js +2 -0
  17. package/dist/src/heuristics/env-example.js +36 -0
  18. package/dist/src/heuristics/platforms.js +69 -10
  19. package/dist/src/heuristics/readiness.js +554 -25
  20. package/dist/src/main.js +10 -1
  21. package/dist/src/runner/gradle-properties.js +187 -0
  22. package/dist/src/runner/materialise.js +92 -0
  23. package/dist/src/runner/orchestrate.js +91 -2
  24. package/dist/src/secrets/vault.js +106 -5
  25. package/dist/src/server/app.js +30 -3
  26. package/dist/src/server/auth.js +50 -10
  27. package/dist/src/server/permissions.js +2 -0
  28. package/dist/src/server/required-secrets.js +30 -0
  29. package/dist/src/server/routes/account.js +57 -0
  30. package/dist/src/server/routes/credentials.js +108 -0
  31. package/dist/src/server/routes/readiness.js +162 -6
  32. package/dist/src/server/routes/secrets.js +101 -0
  33. package/dist/src/sidecar/bridge.js +21 -1
  34. package/dist/src/sidecar/fastlane-dir.js +23 -0
  35. package/dist/src/sidecar/lanes.js +6 -0
  36. package/dist/src/sidecar/uses.js +21 -5
  37. package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-DynuBC2l.js} +1 -1
  38. package/dist/web/assets/index-CpwrNE-K.css +1 -0
  39. package/dist/web/assets/index-lyZs-Y7J.js +62 -0
  40. package/dist/web/index.html +2 -2
  41. package/package.json +1 -1
  42. package/ruby/introspect.rb +122 -9
  43. package/dist/web/assets/index-De6sxx6G.css +0 -1
  44. package/dist/web/assets/index-TWRQ1cJg.js +0 -62
@@ -1,17 +1,29 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { join } from "node:path";
3
+ import { promisify } from "node:util";
4
+ import { readFile } from "node:fs/promises";
5
+ import { applyImport, envFilesIn, parseEnvFile, planImport } from "./secret-import.js";
2
6
  import { ConfigStore } from "../config/store.js";
3
7
  import { openDatabase } from "../db/open.js";
8
+ import { CredentialStore } from "../db/credentials.js";
4
9
  import { SecretStore } from "../db/secrets.js";
5
10
  import { MIN_LENGTH as MIN_REDACTABLE } from "../logs/redact.js";
6
11
  import { Vault } from "../secrets/vault.js";
7
12
  /** Same rule as the API: what cannot become an environment variable is refused here too. */
8
13
  const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
14
  export const SECRET_USAGE = `laneyard secret set <NAME> [--project <slug>] [--no-mask]
15
+ laneyard secret import --project <slug> [--yes]
10
16
 
11
17
  The value is read from standard input, never from an argument:
12
18
 
13
19
  laneyard secret set MATCH_PASSWORD --project app
14
20
  echo "$TOKEN" | laneyard secret set GITHUB_TOKEN
21
+
22
+ \`import\` reads this project's fastlane/.env and stores what it finds. A
23
+ variable naming a service account JSON has the *file* stored, under the name
24
+ fastlane looks for — a path does not travel to a build machine. A variable
25
+ naming a .p8 is left alone and reported instead: that credential belongs in a
26
+ signing block from the secrets tab, not in a loose variable.
15
27
  `;
16
28
  /**
17
29
  * Reads standard input whole.
@@ -69,6 +81,9 @@ function parse(args) {
69
81
  */
70
82
  export async function runSecretCommand(home, args, io) {
71
83
  const [subcommand, ...rest] = args;
84
+ if (subcommand === "import") {
85
+ return runSecretImport(home, rest, io);
86
+ }
72
87
  if (subcommand !== "set") {
73
88
  io.err(subcommand === undefined ? `${SECRET_USAGE}` : `Unknown subcommand: ${subcommand}\n\n${SECRET_USAGE}`);
74
89
  return 1;
@@ -121,7 +136,7 @@ export async function runSecretCommand(home, args, io) {
121
136
  }
122
137
  const db = openDatabase(join(home, "laneyard.db"));
123
138
  try {
124
- const vault = await Vault.open(home, new SecretStore(db));
139
+ const vault = await Vault.open(home, new SecretStore(db), new CredentialStore(db));
125
140
  await vault.set(parsed.project, parsed.key, value, parsed.masked);
126
141
  }
127
142
  finally {
@@ -131,3 +146,149 @@ export async function runSecretCommand(home, args, io) {
131
146
  ` ${parsed.masked ? "kept out of the logs" : "shown in the logs"}\n`);
132
147
  return 0;
133
148
  }
149
+ /**
150
+ * `laneyard secret import` — the existing `.env` into the vault.
151
+ *
152
+ * Shows what it would do before doing it, in names only. An import that reads
153
+ * eight files and writes eight secrets before saying a word is one nobody can
154
+ * check, and this is the one command that handles every credential a project
155
+ * has at once.
156
+ */
157
+ async function runSecretImport(home, args, io) {
158
+ let project = null;
159
+ let yes = false;
160
+ for (let i = 0; i < args.length; i += 1) {
161
+ const arg = args[i];
162
+ if (arg === "--project" || arg === "-p") {
163
+ const next = args[i + 1];
164
+ if (next === undefined || next.startsWith("-")) {
165
+ io.err("--project needs a slug.\n");
166
+ return 1;
167
+ }
168
+ project = next;
169
+ i += 1;
170
+ }
171
+ else if (arg === "--yes" || arg === "-y") {
172
+ yes = true;
173
+ }
174
+ else {
175
+ io.err(`Unknown option: ${arg}\n\n${SECRET_USAGE}`);
176
+ return 1;
177
+ }
178
+ }
179
+ if (project === null) {
180
+ io.err("Which project? Give it with --project <slug>.\n");
181
+ return 1;
182
+ }
183
+ const config = new ConfigStore(join(home, "config.yml"));
184
+ const loaded = await config.load();
185
+ if (!loaded.ok) {
186
+ io.err(`Unreadable configuration in ${join(home, "config.yml")}: ${loaded.error}\n`);
187
+ return 1;
188
+ }
189
+ const entry = config.project(project);
190
+ if (!entry) {
191
+ const known = config.projects().map((p) => p.slug);
192
+ io.err(`Unknown project: "${project}".` +
193
+ (known.length > 0 ? ` Known projects: ${known.join(", ")}.` : " No project is declared yet.") +
194
+ "\n");
195
+ return 1;
196
+ }
197
+ // Read from the working copy this command was run in — the only place a
198
+ // gitignored `.env` exists; the server's clone never has one.
199
+ //
200
+ // Resolved against the repository root, not the current directory:
201
+ // `fastlane_dir` is recorded relative to the root, so running this from
202
+ // `app/` would otherwise look for `app/app/fastlane/.env`. The same
203
+ // confusion, one command along, as the one that produced an ENOENT in the
204
+ // lane list.
205
+ const fastlaneDir = entry.fastlane_dir ?? "fastlane";
206
+ const root = await repositoryRoot(process.cwd());
207
+ const env = new Map();
208
+ const readFrom = [];
209
+ for (const file of envFilesIn(fastlaneDir)) {
210
+ const text = await readFile(join(root, file), "utf8").catch(() => null);
211
+ if (text === null)
212
+ continue;
213
+ readFrom.push(file);
214
+ for (const [k, v] of parseEnvFile(text))
215
+ env.set(k, v);
216
+ }
217
+ if (readFrom.length === 0) {
218
+ io.err(`No ${fastlaneDir}/.env under ${root}. Run this from the working copy that has one — ` +
219
+ "it is the file that never reaches a clone, which is the whole reason for this command.\n");
220
+ return 1;
221
+ }
222
+ const db = openDatabase(join(home, "laneyard.db"));
223
+ try {
224
+ const vault = await Vault.open(home, new SecretStore(db), new CredentialStore(db));
225
+ // Paths inside a `.env` are written relative to where fastlane runs, which
226
+ // is the fastlane directory's parent — the app, not the repository root.
227
+ const plan = await planImport(env, join(root, fastlaneDir, ".."), vault.list(project).map((s) => s.key));
228
+ if (plan.planned.length === 0) {
229
+ io.out(`Nothing to import from ${readFrom.join(" and ")}.\n`);
230
+ return 0;
231
+ }
232
+ io.out(`\nFrom ${readFrom.join(" and ")}, into "${project}":\n\n`);
233
+ for (const item of plan.planned) {
234
+ if (item.kind === "unresolved-path") {
235
+ io.out(` ✗ ${item.key} — names ${item.path}, which is not there. Skipped.\n`);
236
+ }
237
+ else if (item.kind === "suggest-block") {
238
+ io.out(` ▸ ${item.key} names ${item.path} — no action in fastlane reads that variable. ` +
239
+ "Upload the `.p8` as an App Store Connect key block from the secrets tab instead, with " +
240
+ "its key id and issuer id. Not imported here.\n");
241
+ }
242
+ else if (item.kind === "file-contents") {
243
+ io.out(` ● ${item.key}${item.from ? ` (the contents of ${item.from})` : ""}\n`);
244
+ }
245
+ else {
246
+ io.out(` ● ${item.key}\n`);
247
+ }
248
+ }
249
+ if (plan.replacing.length > 0) {
250
+ io.out(`\n ${plan.replacing.join(", ")} already in the vault — they will be replaced.\n`);
251
+ }
252
+ const skipped = plan.planned.filter((p) => p.kind === "unresolved-path");
253
+ if (skipped.length > 0) {
254
+ io.out("\n A skipped file is the credential this project most needs. Check the path.\n");
255
+ }
256
+ if (!yes) {
257
+ io.out("\nNothing is written yet. Run it again with --yes to store these.\n");
258
+ return 0;
259
+ }
260
+ const stored = await applyImport(vault, project, plan);
261
+ io.out(`\n✓ Stored ${stored} ${stored === 1 ? "secret" : "secrets"}, all kept out of the logs.\n`);
262
+ // Said here because it is easy to assume an import leaves lanes with
263
+ // homework. It does not: `key_filepath:` and `json_key:` are the supported
264
+ // forms, not a stopgap. A signing credential block writes its file to disk
265
+ // for the length of a run, so a lane that names a path keeps reading a
266
+ // real one, unedited.
267
+ io.out("\nNothing in your lanes needs to change. `key_filepath:` and `json_key:` keep working as " +
268
+ "written. From here, a `.p8` or a service account JSON belongs in a signing credential block " +
269
+ "from the secrets tab, not as a loose variable in this file.\n");
270
+ return 0;
271
+ }
272
+ finally {
273
+ db.close();
274
+ }
275
+ }
276
+ /**
277
+ * The repository root, or the directory given if this is not a repository.
278
+ *
279
+ * `fastlane_dir` is recorded relative to the root — that is what the clone is
280
+ * measured from — so anything resolving it has to agree about where the root is,
281
+ * whichever sub-directory the command happens to be run from.
282
+ */
283
+ async function repositoryRoot(from) {
284
+ try {
285
+ const { stdout } = await promisify(execFile)("git", ["rev-parse", "--show-toplevel"], {
286
+ cwd: from,
287
+ timeout: 5_000,
288
+ });
289
+ return stdout.trim() || from;
290
+ }
291
+ catch {
292
+ return from;
293
+ }
294
+ }
@@ -33,13 +33,25 @@ export async function addProjectToConfig(path, entry) {
33
33
  const seq = projects instanceof YAMLSeq ? projects : new YAMLSeq();
34
34
  if (!(projects instanceof YAMLSeq))
35
35
  doc.setIn(["projects"], seq);
36
- for (const item of seq.items) {
37
- const slug = item.get?.("slug");
38
- if (slug === entry.slug) {
39
- throw new Error(`A project already uses the slug "${entry.slug}" in ${path}`);
40
- }
36
+ // An entry of the same name is updated, not refused.
37
+ //
38
+ // Setup prints "Continuing replaces its entry" before asking anything, and
39
+ // then this threw so the one way to correct a stale entry, running setup
40
+ // again, was the one thing that could not be done. A project written by an
41
+ // older version and missing a field it now needs was unfixable except by hand.
42
+ //
43
+ // Field by field rather than wholesale, though the warning says "replaces":
44
+ // an entry may carry things setup knows nothing about — a `git_auth` pointing
45
+ // at an SSH key, a raised `timeout_minutes` — and losing those silently, on a
46
+ // command someone ran to fix something else, would be its own bug.
47
+ const existing = seq.items.find((item) => item.get?.("slug") === entry.slug);
48
+ if (existing?.set) {
49
+ for (const [key, value] of Object.entries(entry))
50
+ existing.set(key, value);
51
+ }
52
+ else {
53
+ seq.add(doc.createNode(entry));
41
54
  }
42
- seq.add(doc.createNode(entry));
43
55
  await writeFile(path, serialize(doc), "utf8");
44
56
  }
45
57
  /**
@@ -105,7 +117,8 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
105
117
  if (existing) {
106
118
  process.stdout.write("\n" +
107
119
  warn(`This machine already knows a project called ${bold(existing)}.\n`) +
108
- dim(" Continuing replaces its entry. Give it another name to keep both.\n"));
120
+ dim(" Continuing updates its entry, keeping anything you added by hand.\n") +
121
+ dim(" Give it another name to keep both.\n"));
109
122
  }
110
123
  const repoConfigPath = join(repoRoot(cwd, d.subPath), LANEYARD_YML);
111
124
  if (await fileExists(repoConfigPath)) {
@@ -163,6 +176,30 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
163
176
  name: slug,
164
177
  git_url: gitUrl,
165
178
  default_branch: branch,
179
+ // Written on this machine too, and only when it is not the default,
180
+ // because of the gap between the two files. Laneyard builds from a clone
181
+ // of the remote, so nothing written into the working copy reaches it
182
+ // until `laneyard.yml` is committed and pushed — and until then
183
+ // `fastlane_dir` falls back to `fastlane`, which in a monorepo is not
184
+ // where anything is. The project was unreadable from the moment setup
185
+ // finished until a git push, with an ENOENT for an explanation.
186
+ //
187
+ // This is not a second source of truth: the repository file wins the
188
+ // moment it lands, which is the precedence `config.yml` already
189
+ // documents and the schema already allows. It is the value setup just
190
+ // proposed and the user just accepted, kept where it is useful until the
191
+ // authoritative copy arrives.
192
+ //
193
+ // Omitted when it *is* the default, so an ordinary project's block stays
194
+ // about how the project is reached and nothing else.
195
+ // Both of the fields the sidecar needs before it can read anything, and
196
+ // only when they are not already the default. `runtime` belongs here for
197
+ // exactly the same reason as `fastlane_dir`: without it the sidecar is
198
+ // launched under `bundle exec` and a project that uses a system fastlane
199
+ // fails with "Could not locate Gemfile" — the same bootstrap gap, one
200
+ // field along.
201
+ ...(fastlaneDir === "fastlane" ? {} : { fastlane_dir: fastlaneDir }),
202
+ ...(runtime === "bundle" ? {} : { runtime }),
166
203
  });
167
204
  // The repository half: how it builds. This is the part that was going into
168
205
  // the machine's file and therefore never being committed — which is exactly
@@ -0,0 +1,120 @@
1
+ /**
2
+ * What each kind of credential block is made of — the one table the server,
3
+ * runner, readiness checks, and web UI all read, so an agreement kept in one
4
+ * place instead of copied four times.
5
+ *
6
+ * Apple and Play defaults are the names fastlane itself reads — verified
7
+ * against fastlane 2.237: `app_store_connect_api_key` declares
8
+ * `APP_STORE_CONNECT_API_KEY_KEY_FILEPATH` / `_KEY_ID` / `..._ISSUER_ID`,
9
+ * and `supply` declares `SUPPLY_JSON_KEY`.
10
+ *
11
+ * Android defaults are Laneyard's own — nothing in fastlane reads a keystore
12
+ * by convention. `ANDROID_KEYSTORE_PASSWORD` was not chosen freely: it
13
+ * matches the `/(^|_)(KEYSTORE|STORE)_PASSWORD$/` pattern that
14
+ * `src/heuristics/readiness.ts` already recognises, so the check and the
15
+ * block agree by construction rather than by coincidence.
16
+ */
17
+ export const CREDENTIAL_KINDS = [
18
+ {
19
+ kind: "apple_asc",
20
+ platform: "ios",
21
+ what: "app store connect key",
22
+ accept: ".p8",
23
+ fields: [
24
+ { name: "key_id", secret: false, label: "Key ID" },
25
+ { name: "issuer_id", secret: false, label: "Issuer ID" },
26
+ ],
27
+ defaults: {
28
+ path: "APP_STORE_CONNECT_API_KEY_KEY_FILEPATH",
29
+ key_id: "APP_STORE_CONNECT_API_KEY_KEY_ID",
30
+ issuer_id: "APP_STORE_CONNECT_API_KEY_ISSUER_ID",
31
+ },
32
+ },
33
+ {
34
+ kind: "android_keystore",
35
+ platform: "android",
36
+ what: "android upload keystore",
37
+ accept: ".jks,.keystore",
38
+ fields: [
39
+ { name: "key_alias", secret: false, label: "Key alias" },
40
+ { name: "store_password", secret: true, label: "Store password" },
41
+ { name: "key_password", secret: true, label: "Key password" },
42
+ // The two things about a gradle properties file that cannot be deduced.
43
+ // `runner/gradle-properties.ts` writes that file where a build script
44
+ // falls back to the debug key; the script names the file, and says
45
+ // nothing about where the name is resolved when the receiver is a
46
+ // variable, nor about the keys read out of it afterwards. Both are asked
47
+ // here, pre-filled from what detection could tell, and left empty rather
48
+ // than guessed when it could tell nothing.
49
+ {
50
+ name: "properties_path",
51
+ secret: false,
52
+ optional: true,
53
+ label: "Properties file, relative to the app",
54
+ },
55
+ {
56
+ name: "property_names",
57
+ secret: false,
58
+ optional: true,
59
+ suggested: "storeFile,storePassword,keyPassword,keyAlias",
60
+ label: "Names your build reads inside it",
61
+ },
62
+ ],
63
+ defaults: {
64
+ path: "ANDROID_KEYSTORE_PATH",
65
+ store_password: "ANDROID_KEYSTORE_PASSWORD",
66
+ key_alias: "ANDROID_KEY_ALIAS",
67
+ key_password: "ANDROID_KEY_PASSWORD",
68
+ },
69
+ },
70
+ {
71
+ kind: "play_service_account",
72
+ platform: "android",
73
+ what: "play store service account",
74
+ accept: ".json,application/json",
75
+ fields: [],
76
+ defaults: {
77
+ path: "SUPPLY_JSON_KEY",
78
+ },
79
+ },
80
+ ];
81
+ function specOf(kind) {
82
+ const spec = CREDENTIAL_KINDS.find((k) => k.kind === kind);
83
+ if (!spec)
84
+ throw new Error(`unknown credential kind: ${kind}`);
85
+ return spec;
86
+ }
87
+ export function defaultVarNames(kind) {
88
+ return specOf(kind).defaults;
89
+ }
90
+ export function fieldsOf(kind) {
91
+ return specOf(kind).fields;
92
+ }
93
+ /**
94
+ * The variable names a set of stored blocks will export into a run.
95
+ *
96
+ * Read off the summaries — the kind and the names stored beside it — so nothing
97
+ * here decrypts anything. That is what lets the readiness check and the secrets
98
+ * screen both ask the question: a name a block supplies is a name the project
99
+ * already has, and asking someone to type it by hand is asking them for what
100
+ * Laneyard is about to hand the run itself.
101
+ *
102
+ * Every name in the block is counted, and no value is looked at. `path` is
103
+ * always exported, and every other slot belongs to a field the block could not
104
+ * be stored without — `credentials.ts` refuses a block with a required field
105
+ * empty, and the optional two have no slot at all. So a name here is a name
106
+ * `runner/materialise.ts` will set.
107
+ *
108
+ * The blocks passed in are the ones that apply, project shadowing global, which
109
+ * is `Vault.listCredentials` and the precedence a run uses.
110
+ */
111
+ export function exportedVarNames(blocks) {
112
+ const names = new Set();
113
+ for (const block of blocks) {
114
+ for (const name of Object.values({ ...defaultVarNames(block.kind), ...block.varNames })) {
115
+ if (name)
116
+ names.add(name);
117
+ }
118
+ }
119
+ return [...names].sort();
120
+ }
@@ -0,0 +1,75 @@
1
+ const GLOBAL = "";
2
+ /**
3
+ * Stores signing credential blocks: a file plus the fields that make it
4
+ * usable. Knows nothing about encryption itself: it takes and returns
5
+ * ciphertext, so a bug here cannot leak a plaintext value.
6
+ *
7
+ * A project credential shadows a global one of the same kind — the same
8
+ * precedence as `SecretStore`, and the least surprising rule.
9
+ */
10
+ export class CredentialStore {
11
+ db;
12
+ constructor(db) {
13
+ this.db = db;
14
+ }
15
+ set(projectSlug, kind, input) {
16
+ this.db
17
+ .prepare(`INSERT INTO credential (project_slug, kind, file_name, file_enc, fields_enc, var_names, updated_at)
18
+ VALUES (?, ?, ?, ?, ?, ?, ?)
19
+ ON CONFLICT (project_slug, kind) DO UPDATE
20
+ SET file_name = excluded.file_name,
21
+ file_enc = excluded.file_enc,
22
+ fields_enc = excluded.fields_enc,
23
+ var_names = excluded.var_names,
24
+ updated_at = excluded.updated_at`)
25
+ .run(projectSlug ?? GLOBAL, kind, input.fileName, input.fileEnc, input.fieldsEnc, JSON.stringify(input.varNames), new Date().toISOString());
26
+ }
27
+ /** Rows that apply to a project, project scope winning over global. */
28
+ applicable(projectSlug) {
29
+ const rows = this.db
30
+ .prepare("SELECT * FROM credential WHERE project_slug IN (?, ?) ORDER BY kind")
31
+ .all(projectSlug, GLOBAL);
32
+ const byKind = new Map();
33
+ for (const row of rows) {
34
+ const existing = byKind.get(row.kind);
35
+ if (!existing || row.project_slug !== GLOBAL)
36
+ byKind.set(row.kind, row);
37
+ }
38
+ return [...byKind.values()]
39
+ .sort((a, b) => a.kind.localeCompare(b.kind))
40
+ .map((row) => this.toSummary(row));
41
+ }
42
+ /** One applicable block, ciphertext included, or undefined. */
43
+ find(projectSlug, kind) {
44
+ return this.applicable(projectSlug).find((r) => r.kind === kind);
45
+ }
46
+ list(projectSlug) {
47
+ return this.applicable(projectSlug).map(({ fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary }) => summary);
48
+ }
49
+ listGlobal() {
50
+ const rows = this.db
51
+ .prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
52
+ .all(GLOBAL);
53
+ return rows.map((row) => {
54
+ const { fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary } = this.toSummary(row);
55
+ return summary;
56
+ });
57
+ }
58
+ remove(projectSlug, kind) {
59
+ const res = this.db
60
+ .prepare("DELETE FROM credential WHERE project_slug = ? AND kind = ?")
61
+ .run(projectSlug ?? GLOBAL, kind);
62
+ return res.changes > 0;
63
+ }
64
+ toSummary(row) {
65
+ return {
66
+ kind: row.kind,
67
+ fileName: row.file_name,
68
+ fileEnc: row.file_enc,
69
+ fieldsEnc: row.fields_enc,
70
+ scope: row.project_slug === GLOBAL ? "global" : "project",
71
+ varNames: JSON.parse(row.var_names),
72
+ updatedAt: row.updated_at,
73
+ };
74
+ }
75
+ }
@@ -61,3 +61,42 @@ CREATE TABLE IF NOT EXISTS secret (
61
61
  updated_at TEXT NOT NULL,
62
62
  PRIMARY KEY (project_slug, key)
63
63
  );
64
+
65
+ -- A signing credential: a file plus the fields that make it usable. Separate
66
+ -- from `secret` because these are not key/value pairs — stored as loose rows,
67
+ -- nothing knew the parts belonged together, and deleting one left a half-dead
68
+ -- group no check could detect.
69
+ --
70
+ -- `fields_enc` is one encrypted JSON object rather than a column per field: the
71
+ -- three kinds do not share a shape, and a column per field would mean a
72
+ -- migration every time a kind gains one.
73
+ --
74
+ -- `var_names` is NOT encrypted. It holds variable names, never values, and the
75
+ -- interface has to display them.
76
+ CREATE TABLE IF NOT EXISTS credential (
77
+ project_slug TEXT NOT NULL DEFAULT '',
78
+ kind TEXT NOT NULL,
79
+ file_name TEXT NOT NULL,
80
+ file_enc TEXT NOT NULL,
81
+ fields_enc TEXT NOT NULL,
82
+ var_names TEXT NOT NULL,
83
+ updated_at TEXT NOT NULL,
84
+ PRIMARY KEY (project_slug, kind)
85
+ );
86
+
87
+ -- Sessions outlive a restart, which is the whole reason they are here rather
88
+ -- than in a Map. What is stored is a SHA-256 of the token, never the token: the
89
+ -- cookie is a bearer credential, and a stolen `laneyard.db` must not hand
90
+ -- anybody a live session the way a table of raw tokens would.
91
+ --
92
+ -- `expires_at` is an ISO timestamp compared as text, which sorts correctly
93
+ -- because ISO-8601 does. A session with no end is not a convenience, it is a
94
+ -- credential nobody can lose track of.
95
+ CREATE TABLE IF NOT EXISTS session (
96
+ token_hash TEXT PRIMARY KEY,
97
+ name TEXT NOT NULL,
98
+ role TEXT NOT NULL,
99
+ created_at TEXT NOT NULL,
100
+ expires_at TEXT NOT NULL
101
+ );
102
+ CREATE INDEX IF NOT EXISTS session_by_name ON session (name);
@@ -34,6 +34,34 @@ export class SecretStore {
34
34
  }
35
35
  return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
36
36
  }
37
+ /** One applicable row, ciphertext included, or undefined. */
38
+ find(projectSlug, key) {
39
+ const row = this.applicable(projectSlug).find((r) => r.key === key);
40
+ return row
41
+ ? {
42
+ key: row.key,
43
+ masked: row.masked === 1,
44
+ scope: row.project_slug === GLOBAL ? "global" : "project",
45
+ valueEnc: row.value_enc,
46
+ }
47
+ : undefined;
48
+ }
49
+ /**
50
+ * Flips whether a value is kept out of the logs, leaving the value alone.
51
+ *
52
+ * Its own operation rather than a re-`set`, because of a circle: to reveal a
53
+ * value you must first declare it not secret, and declaring that by storing it
54
+ * again would mean typing the value you were trying to read.
55
+ *
56
+ * Returns false when no row matches, so a caller can answer 404 rather than
57
+ * report a change that did not happen.
58
+ */
59
+ setMasked(projectSlug, key, masked) {
60
+ return (this.db
61
+ .prepare(`UPDATE secret SET masked = ?, updated_at = ?
62
+ WHERE project_slug = ? AND key = ?`)
63
+ .run(masked ? 1 : 0, new Date().toISOString(), projectSlug ?? GLOBAL, key).changes > 0);
64
+ }
37
65
  list(projectSlug) {
38
66
  return this.applicable(projectSlug).map((row) => ({
39
67
  key: row.key,
@@ -0,0 +1,61 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * A token is never stored, only its digest.
4
+ *
5
+ * SHA-256 without a salt on purpose, unlike a password: the token is 32 random
6
+ * bytes from `randomBytes`, so there is no dictionary to build and no cheaper
7
+ * attack than guessing the token itself. What this buys is that a copy of
8
+ * `laneyard.db` is a list of digests rather than a ring of working keys.
9
+ */
10
+ const digest = (token) => createHash("sha256").update(token).digest("hex");
11
+ /**
12
+ * Where sessions live between restarts.
13
+ *
14
+ * They used to live in a Map, with a comment saying they did not survive a
15
+ * restart "and that's just fine". It was not fine: a server restarted to pick
16
+ * up a configuration change signed everybody out, and on a machine you are
17
+ * still setting up that is several times an hour.
18
+ */
19
+ export class SessionRecords {
20
+ db;
21
+ constructor(db) {
22
+ this.db = db;
23
+ }
24
+ insert(token, owner, expiresAt) {
25
+ this.db
26
+ .prepare(`INSERT INTO session (token_hash, name, role, created_at, expires_at)
27
+ VALUES (?, ?, ?, ?, ?)`)
28
+ .run(digest(token), owner.name, owner.role, new Date().toISOString(), expiresAt.toISOString());
29
+ }
30
+ /** The owner, or undefined when the token is unknown or its time is up. */
31
+ find(token, now = new Date()) {
32
+ const row = this.db
33
+ .prepare(`SELECT name, role FROM session WHERE token_hash = ? AND expires_at > ?`)
34
+ .get(digest(token), now.toISOString());
35
+ return row ? { name: row.name, role: row.role } : undefined;
36
+ }
37
+ remove(token) {
38
+ this.db.prepare(`DELETE FROM session WHERE token_hash = ?`).run(digest(token));
39
+ }
40
+ removeAllFor(name) {
41
+ this.db.prepare(`DELETE FROM session WHERE name = ?`).run(name);
42
+ }
43
+ /**
44
+ * Drops what has already expired.
45
+ *
46
+ * Called at startup rather than on a timer: the rows are harmless — `find`
47
+ * already refuses them — so this is housekeeping, not correctness, and a
48
+ * timer would be a moving part earning nothing.
49
+ */
50
+ prune(now = new Date()) {
51
+ return this.db.prepare(`DELETE FROM session WHERE expires_at <= ?`).run(now.toISOString())
52
+ .changes;
53
+ }
54
+ /** Number of live sessions. Exposed so expiry can be tested without sleeping. */
55
+ count(now = new Date()) {
56
+ const row = this.db
57
+ .prepare(`SELECT COUNT(*) AS n FROM session WHERE expires_at > ?`)
58
+ .get(now.toISOString());
59
+ return row.n;
60
+ }
61
+ }
@@ -7,6 +7,27 @@ const exec = promisify(execFile);
7
7
  * A clone managed by Laneyard, kept between runs.
8
8
  * All git commands go through here to share the authentication environment.
9
9
  */
10
+ /**
11
+ * The environment git needs to work without a person at the keyboard.
12
+ *
13
+ * Lifted out of `Workspace` because it is not only Workspace's problem. A lane
14
+ * may run git itself — pushing a bumped build number is a common and reasonable
15
+ * thing for a Fastfile to do — and that `sh("git push")` inherited none of
16
+ * this. The result was the worst possible failure: a push that needed a
17
+ * credential did not fail, it *waited*, and the run sat there until it hit its
18
+ * timeout with nothing in the log to say what it was waiting for.
19
+ *
20
+ * `GIT_TERMINAL_PROMPT=0` is what turns that wait into an error. The SSH
21
+ * command is what makes the push succeed instead — the key configured for
22
+ * cloning is by definition the right one for pushing to the same remote.
23
+ */
24
+ export function gitEnvFor(auth) {
25
+ const env = { GIT_TERMINAL_PROMPT: "0" };
26
+ if (auth.kind === "ssh_key" && auth.ref) {
27
+ env["GIT_SSH_COMMAND"] = `ssh -i ${auth.ref} -o IdentitiesOnly=yes -o BatchMode=yes`;
28
+ }
29
+ return env;
30
+ }
10
31
  export class Workspace {
11
32
  path;
12
33
  gitUrl;
@@ -17,12 +38,17 @@ export class Workspace {
17
38
  this.auth = auth;
18
39
  }
19
40
  env() {
20
- // Without this, git can block on a credentials prompt and freeze the run.
21
- const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
22
- if (this.auth.kind === "ssh_key" && this.auth.ref) {
23
- env["GIT_SSH_COMMAND"] = `ssh -i ${this.auth.ref} -o IdentitiesOnly=yes -o BatchMode=yes`;
24
- }
25
- return env;
41
+ return { ...process.env, ...gitEnvFor(this.auth) };
42
+ }
43
+ /**
44
+ * The git identity this workspace would commit under, or null.
45
+ *
46
+ * Exposed because a lane that runs `git commit` itself needs the same answer,
47
+ * and a clone Laneyard made has no identity of its own — so the question is
48
+ * whether the *server* has one.
49
+ */
50
+ async identity() {
51
+ return this.gitIdentity();
26
52
  }
27
53
  /**
28
54
  * Replaces the repository URL with a neutral token in a piece of text.