laneyard 0.7.0 → 0.8.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/README.md CHANGED
@@ -94,7 +94,7 @@ See [Credentials](docs/credentials.md).
94
94
 
95
95
  - **[Configuration](docs/configuration.md)** — `config.yml` and `laneyard.yml`.
96
96
  - **[Accounts](docs/accounts.md)** — the two roles, and which projects a builder reaches.
97
- - **[Credentials](docs/credentials.md)** — hardcoded credentials at setup, secrets, signing blocks.
97
+ - **[Credentials](docs/credentials.md)** — hardcoded credentials at setup, secrets, the environment file, signing blocks.
98
98
  - **[Readiness](docs/readiness.md)** — the per-project checklist, and what a tick means.
99
99
  - **[Managing a project](docs/managing.md)** — the Fastfile editor, removing, resetting, uninstalling.
100
100
  - **[Security](docs/security.md)** — what is encrypted, what is not, and what this does not cover.
@@ -45,8 +45,8 @@ async function readCounts(home, slug) {
45
45
  return {
46
46
  activeRun: runs.hasActiveRun(slug),
47
47
  runs: runs.listByProject(slug, -1).length,
48
- secrets: new SecretStore(db).listOwn(slug).length,
49
- signingBlocks: new CredentialStore(db).listOwn(slug).length,
48
+ secrets: new SecretStore(db).list(slug).length,
49
+ signingBlocks: new CredentialStore(db).list(slug).length,
50
50
  };
51
51
  }
52
52
  finally {
@@ -31,8 +31,8 @@ function renderInventory(inv) {
31
31
  out += field("database", inv.db === null ? dim("not there") : dim("could not be read")) + "\n";
32
32
  }
33
33
  else {
34
- out += field("secrets", `${plural(v.projectSecrets, "project secret")}, ${plural(v.globalSecrets, "global secret")}`) + "\n";
35
- out += field("signing", `${plural(v.projectBlocks, "project signing block")}, ${plural(v.globalBlocks, "global signing block")}`) + "\n";
34
+ out += field("secrets", plural(v.secrets, "secret")) + "\n";
35
+ out += field("signing", plural(v.blocks, "signing block")) + "\n";
36
36
  out += field("history", plural(v.runs, "run")) + "\n";
37
37
  }
38
38
  if (inv.folders.length === 0) {
@@ -103,13 +103,8 @@ async function readVault(dbPath) {
103
103
  const count = (sql) => db.prepare(sql).get().n;
104
104
  return {
105
105
  vault: {
106
- // The empty slug is how a global row is stored — the same convention
107
- // `SecretStore` and `CredentialStore` use, and the reason the two are
108
- // counted apart: a global secret belongs to every project.
109
- projectSecrets: count("SELECT COUNT(*) AS n FROM secret WHERE project_slug != ''"),
110
- globalSecrets: count("SELECT COUNT(*) AS n FROM secret WHERE project_slug = ''"),
111
- projectBlocks: count("SELECT COUNT(*) AS n FROM credential WHERE project_slug != ''"),
112
- globalBlocks: count("SELECT COUNT(*) AS n FROM credential WHERE project_slug = ''"),
106
+ secrets: count("SELECT COUNT(*) AS n FROM secret"),
107
+ blocks: count("SELECT COUNT(*) AS n FROM credential"),
113
108
  runs: count("SELECT COUNT(*) AS n FROM run"),
114
109
  },
115
110
  unreadable: null,
@@ -221,19 +216,8 @@ export function renderInventory(inv) {
221
216
  }
222
217
  else {
223
218
  const v = inv.db.vault;
224
- out += field("secrets", `${plural(v.projectSecrets, "project secret")}`) + "\n";
225
- // Said on its own line and in words rather than folded into the number
226
- // above: a global secret is read by every project on this machine, and
227
- // "in scope" is the fact someone needs before they answer the question.
228
- out +=
229
- field("", v.globalSecrets === 0
230
- ? dim("no global secret")
231
- : `${plural(v.globalSecrets, "global secret")} — shared by every project, ${bold("removed too")}`) + "\n";
232
- out += field("signing", `${plural(v.projectBlocks, "project signing block")}`) + "\n";
233
- out +=
234
- field("", v.globalBlocks === 0
235
- ? dim("no global signing block")
236
- : `${plural(v.globalBlocks, "global signing block")} — shared by every project, ${bold("removed too")}`) + "\n";
219
+ out += field("secrets", plural(v.secrets, "secret")) + "\n";
220
+ out += field("signing", plural(v.blocks, "signing block")) + "\n";
237
221
  out += field("history", plural(v.runs, "run")) + "\n";
238
222
  }
239
223
  }
@@ -267,7 +251,7 @@ export function renderInventory(inv) {
267
251
  */
268
252
  export function renderIrreversible(inv) {
269
253
  const v = inv.db?.vault;
270
- const stored = v === undefined || v === null ? null : v.projectSecrets + v.globalSecrets + v.projectBlocks + v.globalBlocks;
254
+ const stored = v === undefined || v === null ? null : v.secrets + v.blocks;
271
255
  return (heading("what cannot be undone") +
272
256
  warn(`The vault key is the one thing here that has no other copy.\n`) +
273
257
  dim(" Every secret and every signing block is encrypted under " + (inv.key?.path ?? join(inv.home, "key")) + ".\n") +
@@ -0,0 +1,57 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { isAbsolute, normalize } from "node:path";
3
+ import { parseDocument, YAMLMap, YAMLSeq } from "yaml";
4
+ import { serializeYaml } from "./yaml.js";
5
+ /**
6
+ * Sets or clears a project's `env_file`, in `config.yml`, leaving the rest of
7
+ * the file alone.
8
+ *
9
+ * The setting is configuration, so it lives in a file — that is what makes
10
+ * backing Laneyard up a matter of copying one. But it is also the only way to
11
+ * turn the environment file on, and a feature reachable exclusively by editing
12
+ * YAML by hand is one nobody finds. So the screen writes the file, the way the
13
+ * accounts screen already writes accounts and the removal screen already
14
+ * removes a project's block.
15
+ *
16
+ * A `laneyard.yml` in the repository still wins, unchanged: that precedence is
17
+ * the same for every setting, and this function is not the place to invent an
18
+ * exception to it. The screen reports it rather than offering to fight it.
19
+ *
20
+ * Returns false when no project carries that slug, so the caller can answer 404
21
+ * rather than rewrite the file to say what it already said.
22
+ */
23
+ export async function setEnvFileSetting(path, slug, envFile) {
24
+ const doc = parseDocument(await readFile(path, "utf8"));
25
+ const projects = doc.get("projects");
26
+ if (!(projects instanceof YAMLSeq))
27
+ return false;
28
+ const entry = projects.items.find((item) => item instanceof YAMLMap && item.get("slug") === slug);
29
+ if (!entry)
30
+ return false;
31
+ if (envFile === null)
32
+ entry.delete("env_file");
33
+ else
34
+ entry.set("env_file", envFile);
35
+ await writeFile(path, serializeYaml(doc), "utf8");
36
+ return true;
37
+ }
38
+ /**
39
+ * Why this path cannot be used, or null.
40
+ *
41
+ * The same rule the schema enforces at load, applied here so a bad value is
42
+ * refused by the screen that typed it rather than accepted and then reported as
43
+ * a broken configuration on the next reload. The file holds the values the vault
44
+ * exists to protect, and a path climbing out of the app would let it be dropped
45
+ * anywhere on the server.
46
+ */
47
+ export function envFileProblem(envFile) {
48
+ if (envFile === "")
49
+ return "A path is required.";
50
+ if (isAbsolute(envFile))
51
+ return "A path inside the app, not an absolute one.";
52
+ const clean = normalize(envFile);
53
+ if (clean === ".." || clean.startsWith("../") || clean.startsWith("..\\")) {
54
+ return "A path inside the app, not one that climbs out of it.";
55
+ }
56
+ return null;
57
+ }
@@ -18,13 +18,13 @@ function underApp(appRoot, p) {
18
18
  * duplicated keeps its file unchanged. But everything downstream resolves paths
19
19
  * as repo-root-relative (`join(workspacePath, …)`, globs with `cwd:
20
20
  * workspacePath`). So the app-relativity is collapsed here, once, at the
21
- * boundary: the two path fields are prefixed with the app directory before the
21
+ * boundary: the path fields are prefixed with the app directory before the
22
22
  * merge ever sees them, and nothing past this point learns a new rule.
23
23
  *
24
- * Only the path fields move. `platforms`, `runtime`, `timeout_minutes`,
25
- * `interactive_default`, `required_secrets` and `retention` are not paths and
26
- * pass through untouched. A root-level file is never handed here: its paths are
27
- * already repo-root-relative.
24
+ * Only the path fields move `fastlane_dir`, `artifact_globs` and `env_file`.
25
+ * `platforms`, `runtime`, `timeout_minutes`, `interactive_default`,
26
+ * `required_secrets` and `retention` are not paths and pass through untouched. A
27
+ * root-level file is never handed here: its paths are already repo-root-relative.
28
28
  */
29
29
  export function normaliseAppConfig(repo, appRoot) {
30
30
  const out = { ...repo };
@@ -34,6 +34,9 @@ export function normaliseAppConfig(repo, appRoot) {
34
34
  if (repo.artifact_globs !== undefined) {
35
35
  out.artifact_globs = repo.artifact_globs.map((g) => underApp(appRoot, g));
36
36
  }
37
+ if (repo.env_file !== undefined) {
38
+ out.env_file = underApp(appRoot, repo.env_file);
39
+ }
37
40
  return out;
38
41
  }
39
42
  /**
@@ -1,4 +1,18 @@
1
+ import { isAbsolute, normalize } from "node:path";
1
2
  import { z } from "zod";
3
+ /**
4
+ * Whether a relative path leaves the directory it is relative to.
5
+ *
6
+ * `normalize` collapses `a/../../b` to `../b`, so one check after it catches
7
+ * both the obvious `../.env` and the roundabout spelling. An absolute path is
8
+ * refused outright: it does not mean "inside the app" under any reading.
9
+ */
10
+ function escapesApp(p) {
11
+ if (isAbsolute(p))
12
+ return true;
13
+ const clean = normalize(p);
14
+ return clean === ".." || clean.startsWith(`..${"/"}`) || clean.startsWith("..\\");
15
+ }
2
16
  /** A slug is used as a folder name and a URL segment. */
3
17
  const slugSchema = z
4
18
  .string()
@@ -17,6 +31,21 @@ export const projectSettingsSchema = z.object({
17
31
  interactive_default: z.boolean().default(false),
18
32
  artifact_globs: z.array(z.string()).default([]),
19
33
  required_secrets: z.array(z.string()).default([]),
34
+ // Where to write the file the build reads from disk — a gitignored `.env`, a
35
+ // `config.json` for `--dart-define-from-file`. No default: absent means the
36
+ // project wants no such file, which is almost every project, and an empty
37
+ // string would be a path to interpret rather than an absence to respect.
38
+ //
39
+ // Relative to the app directory, like `fastlane_dir`. A path that climbs out
40
+ // of it is refused here rather than at write time: the file holds the values
41
+ // the vault exists to protect, and a configuration must never be able to drop
42
+ // one anywhere on the server. Refusing at load also means the last valid
43
+ // configuration stays live, which is what every other bad value gets.
44
+ env_file: z
45
+ .string()
46
+ .min(1, "env_file: a path, or leave it out")
47
+ .refine((p) => !escapesApp(p), "env_file: a path inside the app, not one that climbs out of it")
48
+ .optional(),
20
49
  // What this project builds for. No default: absent means "nobody said", and
21
50
  // the readiness checklist falls back to looking at the repository. Setting it
22
51
  // is how a repository that happens to carry an Xcode project it never builds
@@ -1,11 +1,14 @@
1
- const GLOBAL = "";
2
1
  /**
3
2
  * Stores signing credential blocks: a file plus the fields that make it
4
3
  * usable. Knows nothing about encryption itself: it takes and returns
5
4
  * ciphertext, so a bug here cannot leak a plaintext value.
6
5
  *
7
- * A project credential shadows a global one of the same kind the same
8
- * precedence as `SecretStore`, and the least surprising rule.
6
+ * One project, one block per kind. A block used to be storable under no project
7
+ * and read by all of them an App Store Connect key is usually one per
8
+ * developer account, not one per app, so the sharing matched something real.
9
+ * It went anyway: it meant no screen could show what a project actually signs
10
+ * with. Five apps under one account now hold five copies of the key, and
11
+ * rotating it means replacing five.
9
12
  */
10
13
  export class CredentialStore {
11
14
  db;
@@ -22,68 +25,30 @@ export class CredentialStore {
22
25
  fields_enc = excluded.fields_enc,
23
26
  var_names = excluded.var_names,
24
27
  updated_at = excluded.updated_at`)
25
- .run(projectSlug ?? GLOBAL, kind, input.fileName, input.fileEnc, input.fieldsEnc, JSON.stringify(input.varNames), new Date().toISOString());
28
+ .run(projectSlug, kind, input.fileName, input.fileEnc, input.fieldsEnc, JSON.stringify(input.varNames), new Date().toISOString());
26
29
  }
27
- /** Rows that apply to a project, project scope winning over global. */
28
- applicable(projectSlug) {
30
+ /** This project's blocks, ciphertext included. */
31
+ rows(projectSlug) {
29
32
  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));
33
+ .prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
34
+ .all(projectSlug);
35
+ return rows.map((row) => this.toSummary(row));
41
36
  }
42
- /** One applicable block, ciphertext included, or undefined. */
37
+ /** One block, ciphertext included, or undefined. */
43
38
  find(projectSlug, kind) {
44
- return this.applicable(projectSlug).find((r) => r.kind === kind);
39
+ return this.rows(projectSlug).find((r) => r.kind === kind);
45
40
  }
46
41
  list(projectSlug) {
47
- return this.applicable(projectSlug).map(({ fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary }) => summary);
42
+ return this.rows(projectSlug).map(({ fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary }) => summary);
48
43
  }
49
- /**
50
- * The blocks stored under this slug, and no global one.
51
- *
52
- * Same distinction as `SecretStore.listOwn`, and it matters more here: a
53
- * global keystore shadowed by nothing is shared by every project on the
54
- * machine, and counting it as one project's would offer to delete the one
55
- * credential every other project signs with.
56
- */
57
- listOwn(projectSlug) {
58
- if (projectSlug === GLOBAL)
59
- return [];
60
- const rows = this.db
61
- .prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
62
- .all(projectSlug);
63
- return rows.map((row) => {
64
- const { fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary } = this.toSummary(row);
65
- return summary;
66
- });
67
- }
68
- /** Removes every block stored under this slug, and returns how many. */
69
- removeAllOwn(projectSlug) {
70
- if (projectSlug === GLOBAL)
71
- return 0;
44
+ /** Removes every block this project holds, and returns how many. */
45
+ removeAll(projectSlug) {
72
46
  return this.db.prepare("DELETE FROM credential WHERE project_slug = ?").run(projectSlug).changes;
73
47
  }
74
- listGlobal() {
75
- const rows = this.db
76
- .prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
77
- .all(GLOBAL);
78
- return rows.map((row) => {
79
- const { fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary } = this.toSummary(row);
80
- return summary;
81
- });
82
- }
83
48
  remove(projectSlug, kind) {
84
49
  const res = this.db
85
50
  .prepare("DELETE FROM credential WHERE project_slug = ? AND kind = ?")
86
- .run(projectSlug ?? GLOBAL, kind);
51
+ .run(projectSlug, kind);
87
52
  return res.changes > 0;
88
53
  }
89
54
  toSummary(row) {
@@ -92,7 +57,6 @@ export class CredentialStore {
92
57
  fileName: row.file_name,
93
58
  fileEnc: row.file_enc,
94
59
  fieldsEnc: row.fields_enc,
95
- scope: row.project_slug === GLOBAL ? "global" : "project",
96
60
  varNames: JSON.parse(row.var_names),
97
61
  updatedAt: row.updated_at,
98
62
  };
@@ -0,0 +1,75 @@
1
+ /** The two tables that carried a scope, and the column that identified a row in each. */
2
+ const TABLES = [
3
+ { table: "secret", column: "key", what: "secret" },
4
+ { table: "credential", column: "kind", what: "signing block" },
5
+ ];
6
+ /**
7
+ * Gives every row that belonged to everything to each project that read it.
8
+ *
9
+ * The vault used to have two scopes: a row stored under the empty slug applied
10
+ * to every project, and a row under a real slug shadowed it. That is gone — a
11
+ * secret and a signing block now belong to exactly one project — and this is
12
+ * what happens to the rows written before it went.
13
+ *
14
+ * **Copied, never dropped.** A global secret was read by every project, so
15
+ * writing it into every project preserves precisely the behaviour it had:
16
+ * nothing that built yesterday stops building today. The alternative — deleting
17
+ * rows the user never asked to delete, on an upgrade — would break working
18
+ * projects to tidy a table.
19
+ *
20
+ * **A project that overrode a global one keeps its own value.** The insert
21
+ * takes no action on conflict, which is the same precedence the merged read
22
+ * applied, made permanent rather than recomputed on every query.
23
+ *
24
+ * The cost is real and is the point of the report: someone who stored one App
25
+ * Store Connect key now has five, and rotating it means replacing five. They
26
+ * have to be told, or they will find out from a build that uploads with a key
27
+ * they thought they had replaced.
28
+ *
29
+ * With no projects configured there is nowhere to copy to, and the row is
30
+ * deleted rather than left: every query names a slug now, so the empty one is
31
+ * unreachable — not shared, just invisible and forever unread.
32
+ */
33
+ export function migrateGlobalScope(db, allSlugs) {
34
+ const report = { copied: [], dropped: [] };
35
+ // The empty slug is the sentinel this function is deleting. A configuration
36
+ // cannot produce one — `slugSchema` refuses it — but if one ever arrived, the
37
+ // copy would land on the row about to be deleted and the data would go with
38
+ // it, silently. That is the one input worth refusing by hand.
39
+ const slugs = allSlugs.filter((slug) => slug !== "");
40
+ const run = db.transaction(() => {
41
+ for (const { table, column, what } of TABLES) {
42
+ const names = db.prepare(`SELECT ${column} AS name FROM ${table} WHERE project_slug = '' ORDER BY ${column}`).all().map((row) => row.name);
43
+ if (names.length === 0)
44
+ continue;
45
+ // `SELECT ?, <rest>` rather than a read-then-write: it keeps every column
46
+ // the table has without naming them here, so a column added later travels
47
+ // with the copy instead of being silently dropped by a migration nobody
48
+ // thought to update.
49
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);
50
+ const rest = columns.filter((c) => c !== "project_slug");
51
+ const copy = db.prepare(`INSERT INTO ${table} (project_slug, ${rest.join(", ")})
52
+ SELECT ?, ${rest.join(", ")} FROM ${table} WHERE project_slug = '' AND ${column} = ?
53
+ ON CONFLICT DO NOTHING`);
54
+ for (const name of names) {
55
+ const wrote = [];
56
+ for (const slug of slugs)
57
+ if (copy.run(slug, name).changes > 0)
58
+ wrote.push(slug);
59
+ // Dropped means "deleted and copied nowhere", which covers two cases:
60
+ // there was no project to copy into, and every project already defined
61
+ // the name and kept its own. The second changes nothing anyone could
62
+ // observe — the row was shadowed everywhere, so nothing ever read it —
63
+ // but it is still a deletion, and a migration that deletes in silence is
64
+ // one nobody can audit afterwards.
65
+ if (wrote.length > 0)
66
+ report.copied.push({ what, name, slugs: wrote });
67
+ else
68
+ report.dropped.push({ what, name });
69
+ }
70
+ db.prepare(`DELETE FROM ${table} WHERE project_slug = ''`).run();
71
+ }
72
+ });
73
+ run();
74
+ return report;
75
+ }
@@ -9,6 +9,10 @@ export function openDatabase(path) {
9
9
  migrateIntrospectionCache(db);
10
10
  const here = dirname(fileURLToPath(import.meta.url));
11
11
  db.exec(readFileSync(join(here, "schema.sql"), "utf8"));
12
+ // After the schema, not before: on a fresh database the table has to exist
13
+ // for the column check to mean anything, and there `CREATE TABLE` has already
14
+ // put the column in.
15
+ migrateEnvFileColumn(db);
12
16
  return db;
13
17
  }
14
18
  /**
@@ -25,3 +29,20 @@ function migrateIntrospectionCache(db) {
25
29
  db.exec("DROP TABLE introspection_cache");
26
30
  }
27
31
  }
32
+ /**
33
+ * Adds `secret.in_env_file` to a database written before the environment file
34
+ * existed.
35
+ *
36
+ * `CREATE TABLE IF NOT EXISTS` leaves an existing table alone, so a column added
37
+ * to the schema never reaches a database that already has the table. The cache
38
+ * above answers that by dropping and rebuilding; here that would delete the
39
+ * vault. `ALTER TABLE … ADD COLUMN` with a default is the whole migration: every
40
+ * row that predates the column reads as "not in the file", which is exactly what
41
+ * it was.
42
+ */
43
+ function migrateEnvFileColumn(db) {
44
+ const columns = db.prepare("PRAGMA table_info(secret)").all();
45
+ if (!columns.some((c) => c.name === "in_env_file")) {
46
+ db.exec("ALTER TABLE secret ADD COLUMN in_env_file INTEGER NOT NULL DEFAULT 0");
47
+ }
48
+ }
@@ -50,14 +50,19 @@ CREATE TABLE IF NOT EXISTS introspection_cache (
50
50
  PRIMARY KEY (project_slug, kind)
51
51
  );
52
52
 
53
- -- A global secret is stored with an empty project_slug rather than NULL:
54
- -- SQLite considers two NULLs distinct in a UNIQUE index, so NULL would let the
55
- -- same global name be inserted twice.
53
+ -- Every secret belongs to one project. There was a second scope once — the
54
+ -- empty slug, read by every project and `migrate-global-scope.ts` is what
55
+ -- became of the rows written under it.
56
+ -- `in_env_file` says this variable is also written into the file the build reads
57
+ -- from disk — see `runner/env-file.ts`. It is membership, not a second value: a
58
+ -- flagged secret still reaches the run as an environment variable like any
59
+ -- other. `open.ts` adds the column to a database written before it existed.
56
60
  CREATE TABLE IF NOT EXISTS secret (
57
- project_slug TEXT NOT NULL DEFAULT '',
61
+ project_slug TEXT NOT NULL,
58
62
  key TEXT NOT NULL,
59
63
  value_enc TEXT NOT NULL,
60
64
  masked INTEGER NOT NULL DEFAULT 1,
65
+ in_env_file INTEGER NOT NULL DEFAULT 0,
61
66
  updated_at TEXT NOT NULL,
62
67
  PRIMARY KEY (project_slug, key)
63
68
  );
@@ -74,7 +79,7 @@ CREATE TABLE IF NOT EXISTS secret (
74
79
  -- `var_names` is NOT encrypted. It holds variable names, never values, and the
75
80
  -- interface has to display them.
76
81
  CREATE TABLE IF NOT EXISTS credential (
77
- project_slug TEXT NOT NULL DEFAULT '',
82
+ project_slug TEXT NOT NULL,
78
83
  kind TEXT NOT NULL,
79
84
  file_name TEXT NOT NULL,
80
85
  file_enc TEXT NOT NULL,
@@ -1,50 +1,41 @@
1
- const GLOBAL = "";
1
+ const summaryOf = (row) => ({
2
+ key: row.key,
3
+ masked: row.masked === 1,
4
+ inEnvFile: row.in_env_file === 1,
5
+ });
2
6
  /**
3
7
  * Stores encrypted secrets. Knows nothing about encryption itself: it takes and
4
8
  * returns ciphertext, so a bug here cannot leak a plaintext value.
5
9
  *
6
- * A project secret shadows a global one of the same name the same precedence
7
- * as the configuration, and the least surprising rule.
10
+ * Every row belongs to exactly one project. There used to be a second scope —
11
+ * a row under no project, read by all of them, shadowed by a project's own —
12
+ * and it is gone: see `migrate-global-scope.ts`. What it cost was the answer to
13
+ * "what does this project see", which was a merge of two sets that no screen
14
+ * ever showed whole. It is now one query.
8
15
  */
9
16
  export class SecretStore {
10
17
  db;
11
18
  constructor(db) {
12
19
  this.db = db;
13
20
  }
14
- set(projectSlug, key, valueEnc, masked) {
21
+ set(projectSlug, key, valueEnc, masked, inEnvFile = false) {
15
22
  this.db
16
- .prepare(`INSERT INTO secret (project_slug, key, value_enc, masked, updated_at)
17
- VALUES (?, ?, ?, ?, ?)
23
+ .prepare(`INSERT INTO secret (project_slug, key, value_enc, masked, in_env_file, updated_at)
24
+ VALUES (?, ?, ?, ?, ?, ?)
18
25
  ON CONFLICT (project_slug, key) DO UPDATE
19
26
  SET value_enc = excluded.value_enc,
20
27
  masked = excluded.masked,
28
+ in_env_file = excluded.in_env_file,
21
29
  updated_at = excluded.updated_at`)
22
- .run(projectSlug ?? GLOBAL, key, valueEnc, masked ? 1 : 0, new Date().toISOString());
30
+ .run(projectSlug, key, valueEnc, masked ? 1 : 0, inEnvFile ? 1 : 0, new Date().toISOString());
23
31
  }
24
- /** Rows that apply to a project, project scope winning over global. */
25
- applicable(projectSlug) {
26
- const rows = this.db
27
- .prepare("SELECT * FROM secret WHERE project_slug IN (?, ?) ORDER BY key")
28
- .all(projectSlug, GLOBAL);
29
- const byKey = new Map();
30
- for (const row of rows) {
31
- const existing = byKey.get(row.key);
32
- if (!existing || row.project_slug !== GLOBAL)
33
- byKey.set(row.key, row);
34
- }
35
- return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
32
+ rows(projectSlug) {
33
+ return this.db.prepare("SELECT * FROM secret WHERE project_slug = ? ORDER BY key").all(projectSlug);
36
34
  }
37
- /** One applicable row, ciphertext included, or undefined. */
35
+ /** One row, ciphertext included, or undefined. */
38
36
  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;
37
+ const row = this.rows(projectSlug).find((r) => r.key === key);
38
+ return row ? { ...summaryOf(row), valueEnc: row.value_enc } : undefined;
48
39
  }
49
40
  /**
50
41
  * Flips whether a value is kept out of the logs, leaving the value alone.
@@ -60,58 +51,46 @@ export class SecretStore {
60
51
  return (this.db
61
52
  .prepare(`UPDATE secret SET masked = ?, updated_at = ?
62
53
  WHERE project_slug = ? AND key = ?`)
63
- .run(masked ? 1 : 0, new Date().toISOString(), projectSlug ?? GLOBAL, key).changes > 0);
54
+ .run(masked ? 1 : 0, new Date().toISOString(), projectSlug, key).changes > 0);
64
55
  }
65
56
  /**
66
- * The rows stored under this slug, and no global one.
57
+ * Flips whether this variable is written into the environment file, leaving
58
+ * the value and the masking alone.
67
59
  *
68
- * `list` answers "what does this project see", which is the right question
69
- * everywhere else and the wrong one when a project is going away: a global
70
- * secret three other projects also read is not this one's to count, and
71
- * certainly not its to remove.
72
- *
73
- * The empty slug is the global scope's own key, so it is refused here rather
74
- * than quietly returning every global row as if one project owned them.
60
+ * Its own operation for the same reason as `setMasked`: re-`set` would mean
61
+ * handing back a value the caller may not be able to read.
75
62
  */
76
- listOwn(projectSlug) {
77
- if (projectSlug === GLOBAL)
78
- return [];
79
- const rows = this.db
80
- .prepare("SELECT * FROM secret WHERE project_slug = ? ORDER BY key")
81
- .all(projectSlug);
82
- return rows.map((row) => ({ key: row.key, masked: row.masked === 1, scope: "project" }));
63
+ setInEnvFile(projectSlug, key, inEnvFile) {
64
+ return (this.db
65
+ .prepare(`UPDATE secret SET in_env_file = ?, updated_at = ?
66
+ WHERE project_slug = ? AND key = ?`)
67
+ .run(inEnvFile ? 1 : 0, new Date().toISOString(), projectSlug, key).changes > 0);
83
68
  }
84
- /** Removes every row stored under this slug, and returns how many. */
85
- removeAllOwn(projectSlug) {
86
- if (projectSlug === GLOBAL)
87
- return 0;
69
+ /** Removes every row this project holds, and returns how many. */
70
+ removeAll(projectSlug) {
88
71
  return this.db.prepare("DELETE FROM secret WHERE project_slug = ?").run(projectSlug).changes;
89
72
  }
90
73
  list(projectSlug) {
91
- return this.applicable(projectSlug).map((row) => ({
92
- key: row.key,
93
- masked: row.masked === 1,
94
- scope: row.project_slug === GLOBAL ? "global" : "project",
95
- }));
74
+ return this.rows(projectSlug).map(summaryOf);
96
75
  }
97
- listGlobal() {
98
- const rows = this.db
99
- .prepare("SELECT * FROM secret WHERE project_slug = ? ORDER BY key")
100
- .all(GLOBAL);
101
- return rows.map((row) => ({ key: row.key, masked: row.masked === 1, scope: "global" }));
76
+ /** The names that go in the environment file, in the order they are written. */
77
+ envFileKeys(projectSlug) {
78
+ return this.rows(projectSlug)
79
+ .filter((r) => r.in_env_file === 1)
80
+ .map((r) => r.key);
102
81
  }
103
82
  /** Ciphertext by name, for the vault to decrypt. */
104
83
  encrypted(projectSlug) {
105
- return Object.fromEntries(this.applicable(projectSlug).map((row) => [row.key, row.value_enc]));
84
+ return Object.fromEntries(this.rows(projectSlug).map((row) => [row.key, row.value_enc]));
106
85
  }
107
- /** Which of the applicable secrets should be kept out of the logs. */
86
+ /** Which of this project's secrets should be kept out of the logs. */
108
87
  maskedKeys(projectSlug) {
109
- return new Set(this.applicable(projectSlug).filter((r) => r.masked === 1).map((r) => r.key));
88
+ return new Set(this.rows(projectSlug)
89
+ .filter((r) => r.masked === 1)
90
+ .map((r) => r.key));
110
91
  }
111
92
  remove(projectSlug, key) {
112
- const res = this.db
113
- .prepare("DELETE FROM secret WHERE project_slug = ? AND key = ?")
114
- .run(projectSlug ?? GLOBAL, key);
93
+ const res = this.db.prepare("DELETE FROM secret WHERE project_slug = ? AND key = ?").run(projectSlug, key);
115
94
  return res.changes > 0;
116
95
  }
117
96
  }