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 +1 -1
- package/dist/src/cli/remove.js +2 -2
- package/dist/src/cli/reset.js +2 -2
- package/dist/src/cli/uninstall.js +5 -21
- package/dist/src/config/env-file-setting.js +57 -0
- package/dist/src/config/resolve.js +8 -5
- package/dist/src/config/schema.js +29 -0
- package/dist/src/db/credentials.js +18 -54
- package/dist/src/db/migrate-global-scope.js +75 -0
- package/dist/src/db/open.js +21 -0
- package/dist/src/db/schema.sql +10 -5
- package/dist/src/db/secrets.js +44 -65
- package/dist/src/main.js +31 -2
- package/dist/src/runner/env-file.js +144 -0
- package/dist/src/runner/orchestrate.js +14 -1
- package/dist/src/secrets/vault.js +27 -29
- package/dist/src/server/app.js +4 -0
- package/dist/src/server/routes/credentials.js +0 -13
- package/dist/src/server/routes/projects.js +0 -16
- package/dist/src/server/routes/readiness.js +2 -3
- package/dist/src/server/routes/secrets.js +96 -27
- package/dist/web/assets/{Editor-wZdJUk7D.js → Editor-BK-ZRKlI.js} +1 -1
- package/dist/web/assets/index-DnUdqjT4.js +62 -0
- package/dist/web/assets/{index-CPKV0yx1.css → index-r3IyRuO4.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Cq1Ze8dP.js +0 -62
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.
|
package/dist/src/cli/remove.js
CHANGED
|
@@ -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).
|
|
49
|
-
signingBlocks: new CredentialStore(db).
|
|
48
|
+
secrets: new SecretStore(db).list(slug).length,
|
|
49
|
+
signingBlocks: new CredentialStore(db).list(slug).length,
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
finally {
|
package/dist/src/cli/reset.js
CHANGED
|
@@ -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",
|
|
35
|
-
out += field("signing",
|
|
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
|
-
|
|
107
|
-
|
|
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",
|
|
225
|
-
|
|
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.
|
|
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
|
|
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
|
|
25
|
-
* `
|
|
26
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
|
28
|
+
.run(projectSlug, kind, input.fileName, input.fileEnc, input.fieldsEnc, JSON.stringify(input.varNames), new Date().toISOString());
|
|
26
29
|
}
|
|
27
|
-
/**
|
|
28
|
-
|
|
30
|
+
/** This project's blocks, ciphertext included. */
|
|
31
|
+
rows(projectSlug) {
|
|
29
32
|
const rows = this.db
|
|
30
|
-
.prepare("SELECT * FROM credential WHERE project_slug
|
|
31
|
-
.all(projectSlug
|
|
32
|
-
|
|
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
|
|
37
|
+
/** One block, ciphertext included, or undefined. */
|
|
43
38
|
find(projectSlug, kind) {
|
|
44
|
-
return this.
|
|
39
|
+
return this.rows(projectSlug).find((r) => r.kind === kind);
|
|
45
40
|
}
|
|
46
41
|
list(projectSlug) {
|
|
47
|
-
return this.
|
|
42
|
+
return this.rows(projectSlug).map(({ fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary }) => summary);
|
|
48
43
|
}
|
|
49
|
-
/**
|
|
50
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/src/db/open.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/db/schema.sql
CHANGED
|
@@ -50,14 +50,19 @@ CREATE TABLE IF NOT EXISTS introspection_cache (
|
|
|
50
50
|
PRIMARY KEY (project_slug, kind)
|
|
51
51
|
);
|
|
52
52
|
|
|
53
|
-
--
|
|
54
|
-
--
|
|
55
|
-
--
|
|
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
|
|
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
|
|
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,
|
package/dist/src/db/secrets.js
CHANGED
|
@@ -1,50 +1,41 @@
|
|
|
1
|
-
const
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
30
|
+
.run(projectSlug, key, valueEnc, masked ? 1 : 0, inEnvFile ? 1 : 0, new Date().toISOString());
|
|
23
31
|
}
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
35
|
+
/** One row, ciphertext included, or undefined. */
|
|
38
36
|
find(projectSlug, key) {
|
|
39
|
-
const row = this.
|
|
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
|
|
54
|
+
.run(masked ? 1 : 0, new Date().toISOString(), projectSlug, key).changes > 0);
|
|
64
55
|
}
|
|
65
56
|
/**
|
|
66
|
-
*
|
|
57
|
+
* Flips whether this variable is written into the environment file, leaving
|
|
58
|
+
* the value and the masking alone.
|
|
67
59
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
.
|
|
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
|
|
85
|
-
|
|
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.
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
.
|
|
101
|
-
|
|
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.
|
|
84
|
+
return Object.fromEntries(this.rows(projectSlug).map((row) => [row.key, row.value_enc]));
|
|
106
85
|
}
|
|
107
|
-
/** Which of
|
|
86
|
+
/** Which of this project's secrets should be kept out of the logs. */
|
|
108
87
|
maskedKeys(projectSlug) {
|
|
109
|
-
return new Set(this.
|
|
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
|
}
|