laneyard 0.3.0 → 0.4.1

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 (46) hide show
  1. package/README.md +227 -20
  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/cli/uninstall.js +390 -0
  8. package/dist/src/credentials/kinds.js +120 -0
  9. package/dist/src/db/credentials.js +100 -0
  10. package/dist/src/db/schema.sql +39 -0
  11. package/dist/src/db/secrets.js +53 -0
  12. package/dist/src/db/sessions.js +61 -0
  13. package/dist/src/git/workspace.js +32 -6
  14. package/dist/src/heuristics/android-root.js +49 -0
  15. package/dist/src/heuristics/android-signing.js +98 -0
  16. package/dist/src/heuristics/appfile.js +60 -0
  17. package/dist/src/heuristics/blocking-actions.js +2 -0
  18. package/dist/src/heuristics/env-example.js +36 -0
  19. package/dist/src/heuristics/platforms.js +69 -10
  20. package/dist/src/heuristics/readiness.js +554 -25
  21. package/dist/src/main.js +23 -2
  22. package/dist/src/runner/gradle-properties.js +187 -0
  23. package/dist/src/runner/materialise.js +92 -0
  24. package/dist/src/runner/orchestrate.js +91 -2
  25. package/dist/src/secrets/vault.js +137 -5
  26. package/dist/src/server/app.js +30 -3
  27. package/dist/src/server/auth.js +50 -10
  28. package/dist/src/server/permissions.js +2 -0
  29. package/dist/src/server/required-secrets.js +30 -0
  30. package/dist/src/server/routes/account.js +57 -0
  31. package/dist/src/server/routes/credentials.js +108 -0
  32. package/dist/src/server/routes/projects.js +60 -2
  33. package/dist/src/server/routes/readiness.js +162 -6
  34. package/dist/src/server/routes/secrets.js +101 -0
  35. package/dist/src/sidecar/bridge.js +21 -1
  36. package/dist/src/sidecar/fastlane-dir.js +23 -0
  37. package/dist/src/sidecar/lanes.js +6 -0
  38. package/dist/src/sidecar/uses.js +21 -5
  39. package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-9nLYwtg7.js} +1 -1
  40. package/dist/web/assets/index-DsjxZtsO.css +1 -0
  41. package/dist/web/assets/index-pzPa9EZr.js +62 -0
  42. package/dist/web/index.html +2 -2
  43. package/package.json +1 -1
  44. package/ruby/introspect.rb +122 -9
  45. package/dist/web/assets/index-De6sxx6G.css +0 -1
  46. package/dist/web/assets/index-TWRQ1cJg.js +0 -62
@@ -6,14 +6,18 @@ import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { LEGACY_ADMIN_NAME } from "../config/load.js";
8
8
  import { RunStore } from "../db/runs.js";
9
+ import { SessionRecords } from "../db/sessions.js";
9
10
  import { Workspace } from "../git/workspace.js";
10
11
  import { LogStore } from "../logs/store.js";
12
+ import { materialiseCredentials } from "../runner/materialise.js";
11
13
  import { executeRun } from "../runner/orchestrate.js";
12
14
  import { RunQueue } from "../runner/queue.js";
13
- import { authenticate, LoginThrottle, SESSION_COOKIE, SessionStore } from "./auth.js";
15
+ import { authenticate, LoginThrottle, COOKIE_OPTIONS, SESSION_COOKIE, SessionStore } from "./auth.js";
14
16
  import { requiresAdmin } from "./permissions.js";
17
+ import { registerCredentialRoutes } from "./routes/credentials.js";
15
18
  import { registerFastfileRoutes } from "./routes/fastfile.js";
16
19
  import { registerProjectRoutes } from "./routes/projects.js";
20
+ import { registerAccountRoutes } from "./routes/account.js";
17
21
  import { registerReadinessRoutes } from "./routes/readiness.js";
18
22
  import { registerRunRoutes } from "./routes/runs.js";
19
23
  import { registerSecretRoutes } from "./routes/secrets.js";
@@ -30,9 +34,10 @@ export async function buildApp(deps) {
30
34
  ...deps,
31
35
  runs: new RunStore(deps.db),
32
36
  logs: new LogStore(join(deps.root, "logs")),
33
- sessions: new SessionStore(),
37
+ sessions: new SessionStore(new SessionRecords(deps.db)),
34
38
  workspacePath,
35
39
  artifactsDir: (runId) => join(deps.root, "artifacts", String(runId)),
40
+ runSecretsDir: (runId) => join(deps.root, "runs", String(runId), "secrets"),
36
41
  ensureWorkspace: async (slug) => {
37
42
  const entry = deps.config.project(slug);
38
43
  if (!entry)
@@ -61,6 +66,23 @@ export async function buildApp(deps) {
61
66
  });
62
67
  return;
63
68
  }
69
+ // Before the run, and outside it: `executeRun` is handed plaintext and
70
+ // never the vault, a boundary worth more than the convenience of moving
71
+ // this one call inside. A block that will not decrypt stops the run here,
72
+ // with the reason, rather than producing an artifact signed by nothing.
73
+ let credentials;
74
+ try {
75
+ credentials = await materialiseCredentials(ctx.vault, slug, ctx.runSecretsDir(runId));
76
+ }
77
+ catch (cause) {
78
+ ctx.runs.finish(runId, {
79
+ status: "failed",
80
+ exitCode: null,
81
+ errorSummary: ctx.vault.scrub(slug, cause.message),
82
+ });
83
+ ctx.sockets?.finish(runId, "failed");
84
+ return;
85
+ }
64
86
  await executeRun({
65
87
  runId,
66
88
  runs: ctx.runs,
@@ -77,6 +99,9 @@ export async function buildApp(deps) {
77
99
  },
78
100
  env: process.env,
79
101
  secrets: ctx.vault.resolve(slug),
102
+ credentialEnv: credentials.env,
103
+ androidKeystore: credentials.keystore,
104
+ cleanup: credentials.cleanup,
80
105
  maskedValues: ctx.vault.maskedValues(slug),
81
106
  signal,
82
107
  onChunk: (chunk, offset) => app.broadcastRunChunk?.(runId, chunk, offset),
@@ -130,7 +155,7 @@ export async function buildApp(deps) {
130
155
  throttle.recordSuccess(account);
131
156
  const token = ctx.sessions.issue(identity);
132
157
  return reply
133
- .setCookie(SESSION_COOKIE, token, { path: "/", httpOnly: true, sameSite: "lax" })
158
+ .setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
134
159
  .send({ ok: true, name: identity.name, role: identity.role });
135
160
  });
136
161
  // Every /api route except /api/login requires a session, and the routes on
@@ -180,9 +205,11 @@ export async function buildApp(deps) {
180
205
  await registerProjectRoutes(app, ctx);
181
206
  await registerRunRoutes(app, ctx);
182
207
  await registerSecretRoutes(app, ctx);
208
+ await registerCredentialRoutes(app, ctx);
183
209
  await registerReadinessRoutes(app, ctx);
184
210
  await registerFastfileRoutes(app, ctx);
185
211
  await registerUserRoutes(app, ctx);
212
+ await registerAccountRoutes(app, ctx);
186
213
  // Resolved from the module's location, not from the data folder:
187
214
  // `deps.root` is ~/.laneyard, the built SPA lives in the repository. Two
188
215
  // relative positions, depending on whether we're running on the sources
@@ -65,23 +65,47 @@ export async function authenticate(users, name, password) {
65
65
  return null;
66
66
  return { name: user.name, role: user.role };
67
67
  }
68
- /** In-memory sessions: they don't survive a restart, and that's just fine. */
68
+ /**
69
+ * How long a session lasts, and how long the cookie is kept for.
70
+ *
71
+ * Thirty days, not forever: a browser left on a desk is the reason the password
72
+ * change asks for the current password, and a session that never ended would
73
+ * make that precaution pointless. The cookie carries the same figure, so the
74
+ * browser stops presenting a token the server would refuse anyway.
75
+ */
76
+ export const SESSION_TTL_DAYS = 30;
77
+ export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
78
+ /**
79
+ * Sessions, kept in the database so they outlive a restart.
80
+ *
81
+ * They used to be a Map, with a comment saying they did not survive a restart
82
+ * "and that's just fine". It was not: restarting the server to pick up an edit
83
+ * to config.yml signed everybody out, which on a machine still being set up is
84
+ * several times an hour. The API is unchanged — every caller still issues, gets
85
+ * and revokes — only the shelf underneath is different.
86
+ */
69
87
  export class SessionStore {
70
- tokens = new Map();
71
- issue(identity) {
88
+ records;
89
+ constructor(records) {
90
+ this.records = records;
91
+ }
92
+ issue(identity, now = new Date()) {
72
93
  const token = randomBytes(32).toString("hex");
73
- this.tokens.set(token, identity);
94
+ this.records.insert(token, identity, new Date(now.getTime() + SESSION_TTL_MS));
74
95
  return token;
75
96
  }
76
97
  /** Who the token belongs to, or undefined if it belongs to nobody. */
77
98
  get(token) {
78
- return token === undefined ? undefined : this.tokens.get(token);
99
+ if (token === undefined)
100
+ return undefined;
101
+ const owner = this.records.find(token);
102
+ return owner ? { name: owner.name, role: owner.role } : undefined;
79
103
  }
80
104
  valid(token) {
81
105
  return this.get(token) !== undefined;
82
106
  }
83
107
  revoke(token) {
84
- this.tokens.delete(token);
108
+ this.records.remove(token);
85
109
  }
86
110
  /**
87
111
  * Drops every session belonging to a name.
@@ -93,13 +117,29 @@ export class SessionStore {
93
117
  * every interface that offers the first implies the second.
94
118
  */
95
119
  revokeAllFor(name) {
96
- for (const [token, identity] of this.tokens) {
97
- if (identity.name === name)
98
- this.tokens.delete(token);
99
- }
120
+ this.records.removeAllFor(name);
100
121
  }
101
122
  }
102
123
  export const SESSION_COOKIE = "laneyard_session";
124
+ /**
125
+ * How the session cookie is written, in one place so the two routes that issue
126
+ * one cannot disagree.
127
+ *
128
+ * `maxAge` is the half that was missing: without it the browser treats this as
129
+ * a session cookie and forgets it the moment the window closes, so signing in
130
+ * again was the price of quitting Chrome. It matches the server's own TTL, so
131
+ * the browser stops presenting a token the server would refuse anyway.
132
+ *
133
+ * Not `secure`: Laneyard is commonly reached over plain HTTP on a local
134
+ * network, and a cookie the browser refuses to send is a login page that never
135
+ * goes away.
136
+ */
137
+ export const COOKIE_OPTIONS = {
138
+ path: "/",
139
+ httpOnly: true,
140
+ sameSite: "lax",
141
+ maxAge: SESSION_TTL_MS / 1000,
142
+ };
103
143
  /** Past this many tracked names, the entries that no longer delay anyone go. */
104
144
  const THROTTLE_PRUNE_ABOVE = 1_000;
105
145
  /**
@@ -20,6 +20,8 @@
20
20
  export const REQUIRES_ADMIN = [
21
21
  { method: "*", path: "/api/secrets" },
22
22
  { method: "*", path: "/api/projects/:slug/secrets" },
23
+ { method: "*", path: "/api/credentials" },
24
+ { method: "*", path: "/api/projects/:slug/credentials" },
23
25
  { method: "PUT", path: "/api/projects/:slug/fastfile" },
24
26
  { method: "POST", path: "/api/projects/:slug/commit" },
25
27
  { method: "POST", path: "/api/projects/:slug/push" },
@@ -0,0 +1,30 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parseEnvExample } from "../heuristics/env-example.js";
4
+ export async function requiredSecrets(input) {
5
+ const fromExample = await envExampleNames(input.workspacePath, input.fastlaneDir);
6
+ const required = [
7
+ ...new Set([...input.lanes.flatMap((l) => l.env ?? []), ...input.declared, ...fromExample]),
8
+ ].sort();
9
+ const supplied = new Set([...input.vaultKeys, ...input.blockNames, ...input.serverEnv]);
10
+ return {
11
+ required,
12
+ missing: required.filter((name) => !supplied.has(name)),
13
+ };
14
+ }
15
+ /**
16
+ * The names a committed `.env.example` advertises, if there is one.
17
+ *
18
+ * Two conventional filenames and no more: a convenience for the common case,
19
+ * not a search. Never throws — an absent file is the ordinary situation and
20
+ * means "nothing declared", not a failure.
21
+ */
22
+ export async function envExampleNames(workspacePath, fastlaneDir) {
23
+ const names = [];
24
+ for (const file of [".env.example", ".env.sample"]) {
25
+ const text = await readFile(join(workspacePath, fastlaneDir, file), "utf8").catch(() => null);
26
+ if (text !== null)
27
+ names.push(...parseEnvExample(text));
28
+ }
29
+ return names;
30
+ }
@@ -0,0 +1,57 @@
1
+ import { MIN_PASSWORD_LENGTH, upsertUserInConfig } from "../../config/accounts.js";
2
+ import { COOKIE_OPTIONS, SESSION_COOKIE, authenticate } from "../auth.js";
3
+ /**
4
+ * What someone may do to their own account, whatever their role.
5
+ *
6
+ * Deliberately not under `/api/users`: that prefix is on the admin list in
7
+ * `permissions.ts`, and it means "the accounts on this machine". This is the
8
+ * other thing entirely — one person, their own password — and a builder must be
9
+ * able to reach it. Changing the password of somebody *else* stays admin-only,
10
+ * and stays over there.
11
+ *
12
+ * The current password is required even though the session already proves who
13
+ * this is. A session is a cookie in a browser that may have been left open on a
14
+ * desk; a password is the one thing that says the person at the keyboard is
15
+ * still the person who signed in.
16
+ */
17
+ export async function registerAccountRoutes(app, ctx) {
18
+ app.post("/api/account/password", async (req, reply) => {
19
+ // Non-null: the hook in `app.ts` rejected every request without a session
20
+ // before this handler could run.
21
+ const { name, role } = req.identity;
22
+ const { current, next } = (req.body ?? {});
23
+ if (typeof next !== "string" || next.length < MIN_PASSWORD_LENGTH) {
24
+ return reply
25
+ .code(400)
26
+ .send({ error: `A password is at least ${MIN_PASSWORD_LENGTH} characters.` });
27
+ }
28
+ const users = ctx.config.server()?.users ?? [];
29
+ if (typeof current !== "string" || !(await authenticate(users, name, current))) {
30
+ // No throttle here, unlike `/api/login`: reaching this route already costs
31
+ // a valid session, so there is nobody to slow down who is not already in.
32
+ return reply.code(401).send({ error: "That is not your current password." });
33
+ }
34
+ if (next === current) {
35
+ return reply.code(400).send({ error: "That is the password you already have." });
36
+ }
37
+ // The role is carried over rather than read from the body: this route
38
+ // changes a password and nothing else, and an account that could hand
39
+ // itself `admin` in a field named `role` would make the whole permission
40
+ // table decorative.
41
+ await upsertUserInConfig(ctx.config.configPath(), { name, role, password: next });
42
+ // Every session this account has, gone — including this one. That is the
43
+ // point: changing a password is what someone does when they think another
44
+ // browser somewhere should stop being signed in. A fresh session is issued
45
+ // immediately afterwards, so the person who just did it is not signed out
46
+ // of the page they did it on.
47
+ ctx.sessions.revokeAllFor(name);
48
+ const token = ctx.sessions.issue({ name, role });
49
+ // The file is watched on a debounce; loading here is what makes the very
50
+ // next request see the new hash instead of the old one.
51
+ await ctx.config.load();
52
+ return reply
53
+ .setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
54
+ .code(204)
55
+ .send();
56
+ });
57
+ }
@@ -0,0 +1,108 @@
1
+ import { CREDENTIAL_KINDS, defaultVarNames, fieldsOf } from "../../credentials/kinds.js";
2
+ /**
3
+ * Signing blocks over HTTP: the file and the fields that make it usable.
4
+ *
5
+ * The file arrives base64 inside a JSON body rather than as a multipart upload.
6
+ * No multipart plugin is registered — see `app.ts` — and a `.p8` is two
7
+ * kilobytes, so adding a dependency to carry it would be paying in supply chain
8
+ * for a convenience the browser can provide in one `FileReader` call.
9
+ *
10
+ * A block is taken or refused whole. That is what makes it a block rather than
11
+ * three loose rows: a keystore stored without its alias is not a partial
12
+ * success, it is a build that fails in a month with an unusable artifact.
13
+ */
14
+ /** POSIX environment variable names. Anything else would never reach fastlane. */
15
+ const VALID_VAR = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
+ export async function registerCredentialRoutes(app, ctx) {
17
+ app.get("/api/credentials", async () => ctx.vault.listGlobalCredentials());
18
+ app.get("/api/projects/:slug/credentials", async (req, reply) => {
19
+ const { slug } = req.params;
20
+ if (!ctx.config.project(slug))
21
+ return reply.code(404).send({ error: "Unknown project" });
22
+ return ctx.vault.listCredentials(slug);
23
+ });
24
+ const put = async (slug, kind, body, reply) => {
25
+ const spec = CREDENTIAL_KINDS.find((k) => k.kind === kind);
26
+ if (!spec) {
27
+ return reply.code(400).send({
28
+ error: `"${kind}" is not a kind of credential Laneyard knows: ${CREDENTIAL_KINDS.map((k) => k.kind).join(", ")}.`,
29
+ });
30
+ }
31
+ const { fileName, fileBase64, fields, varNames } = (body ?? {});
32
+ if (typeof fileName !== "string" || fileName === "") {
33
+ return reply.code(400).send({ error: "A file name is required" });
34
+ }
35
+ if (typeof fileBase64 !== "string" || fileBase64 === "") {
36
+ return reply.code(400).send({ error: `The ${spec.what} file is required, base64-encoded.` });
37
+ }
38
+ const given = (fields ?? {});
39
+ if (typeof given !== "object" || Array.isArray(given)) {
40
+ return reply.code(400).send({ error: "`fields` is an object of name to value." });
41
+ }
42
+ // Optional fields are the settings a block may leave unanswered — where a
43
+ // gradle properties file goes, what it is read under. Laneyard is allowed to
44
+ // ask; refusing the whole block over an unanswered one would be requiring.
45
+ const missing = fieldsOf(spec.kind)
46
+ .filter((f) => !f.optional && (typeof given[f.name] !== "string" || given[f.name] === ""))
47
+ .map((f) => f.name);
48
+ if (missing.length > 0) {
49
+ return reply.code(400).send({
50
+ error: `A ${spec.what} needs ${missing.join(", ")}. Without it the file alone signs nothing.`,
51
+ });
52
+ }
53
+ // Only the fields the kind declares are kept. An extra one would be stored,
54
+ // never read, and quietly disagree with what the block claims to be.
55
+ const kept = {};
56
+ for (const field of fieldsOf(spec.kind)) {
57
+ const value = given[field.name];
58
+ // An unanswered optional field is absent from the block rather than
59
+ // stored empty: the reader then falls back to what it would have used
60
+ // anyway, instead of taking "" for an answer someone gave.
61
+ if (typeof value === "string" && value !== "")
62
+ kept[field.name] = value;
63
+ }
64
+ const names = { ...defaultVarNames(spec.kind), ...(varNames ?? {}) };
65
+ for (const [slot, name] of Object.entries(names)) {
66
+ if (!(slot in defaultVarNames(spec.kind))) {
67
+ return reply.code(400).send({ error: `A ${spec.what} exports nothing called "${slot}".` });
68
+ }
69
+ if (typeof name !== "string" || !VALID_VAR.test(name)) {
70
+ return reply.code(400).send({
71
+ error: `"${name}" is not a valid environment variable name: letters, digits and underscore, not starting with a digit.`,
72
+ });
73
+ }
74
+ }
75
+ // Node's base64 decoder ignores what it cannot read rather than refusing, so
76
+ // a truncated or mistyped upload would be stored as a shorter file and only
77
+ // surface as an unreadable key at signing time.
78
+ const fileBytes = Buffer.from(fileBase64, "base64");
79
+ if (fileBytes.length === 0 || fileBytes.toString("base64").replace(/=+$/, "") !== fileBase64.replace(/=+$/, "")) {
80
+ return reply.code(400).send({ error: "The file is not valid base64." });
81
+ }
82
+ await ctx.vault.setCredential(slug, spec.kind, { fileName, fileBytes, fields: kept, varNames: names });
83
+ return reply.code(204).send();
84
+ };
85
+ app.put("/api/credentials/:kind", async (req, reply) => put(null, req.params.kind, req.body, reply));
86
+ app.put("/api/projects/:slug/credentials/:kind", async (req, reply) => {
87
+ const { slug, kind } = req.params;
88
+ if (!ctx.config.project(slug))
89
+ return reply.code(404).send({ error: "Unknown project" });
90
+ return put(slug, kind, req.body, reply);
91
+ });
92
+ app.delete("/api/credentials/:kind", async (req, reply) => {
93
+ const { kind } = req.params;
94
+ const removed = ctx.vault.removeCredential(null, kind);
95
+ return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown credential" });
96
+ });
97
+ /**
98
+ * Removes this project's own block, and only that one. A global block that
99
+ * was shadowed comes back into view, which is the deletion someone asked for:
100
+ * they are undoing an override, not deleting everyone's key from inside one
101
+ * project.
102
+ */
103
+ app.delete("/api/projects/:slug/credentials/:kind", async (req, reply) => {
104
+ const { slug, kind } = req.params;
105
+ const removed = ctx.vault.removeCredential(slug, kind);
106
+ return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown credential" });
107
+ });
108
+ }
@@ -20,7 +20,15 @@ export async function registerProjectRoutes(app, ctx) {
20
20
  * history of what this machine built is not the project's to take away;
21
21
  * - the clone and the artifacts stay on disk, named in the answer so they
22
22
  * can be removed by hand. Deleting files someone may still want, from a
23
- * web page, on one click, is not a thing to do.
23
+ * web page, on one click, is not a thing to do;
24
+ * - its secrets and its signing blocks stay in the vault, counted in the
25
+ * answer. They are the one thing here that cannot be looked at from the
26
+ * interface — no route ever sends a credential back — so if this does not
27
+ * say they are there, nothing ever will. They are also scoped by slug: a
28
+ * project set up later under the same name would find the old keystore
29
+ * waiting for it, and sign with a credential nobody uploaded. Removing
30
+ * them is `DELETE /api/projects/:slug/vault`, below, and it is a second
31
+ * act on purpose.
24
32
  */
25
33
  app.delete("/api/projects/:slug", async (req, reply) => {
26
34
  const { slug } = req.params;
@@ -48,7 +56,57 @@ export async function registerProjectRoutes(app, ctx) {
48
56
  ctx.workspacePath(slug),
49
57
  ...runs.map((run) => ctx.artifactsDir(run.id)),
50
58
  ].filter((path) => existsSync(path));
51
- return reply.send({ slug, name: entry.name, runsKept: runs.length, leftOnDisk });
59
+ // Counted after the block is gone, and read from the vault rather than
60
+ // inferred: the two stores are the only thing that knows, and this is the
61
+ // last moment anyone is looking.
62
+ const owned = ctx.vault.ownedBy(slug);
63
+ return reply.send({
64
+ slug,
65
+ name: entry.name,
66
+ runsKept: runs.length,
67
+ leftOnDisk,
68
+ vaultKept: {
69
+ secrets: owned.secrets.length,
70
+ signingBlocks: owned.credentials.length,
71
+ // Named separately because they are not this project's to lose. A
72
+ // global secret is read by every project on the machine, and an answer
73
+ // that folded them into one number would invite removing them.
74
+ globalSecrets: ctx.vault.listGlobal().length,
75
+ globalSigningBlocks: ctx.vault.listGlobalCredentials().length,
76
+ },
77
+ });
78
+ });
79
+ /**
80
+ * Removes what the vault still holds under a removed project's name.
81
+ *
82
+ * Deliberately not part of removing the project. That route destroys nothing,
83
+ * and the reason is the one this route has to earn instead: a signing block
84
+ * cannot be read back out of Laneyard — the `.p8` and the keystore that went
85
+ * in are the only copies it ever had — so deleting one on the same click that
86
+ * hides a project from a list would be destroying something unrecoverable as
87
+ * a side effect of tidying up.
88
+ *
89
+ * Refused while the slug is still a project, so this can only ever be the
90
+ * clean-up after a removal. A live project's secrets and blocks are removed
91
+ * one at a time, from the tabs that show them, where the user can see what
92
+ * each one is.
93
+ *
94
+ * Only rows carrying this slug go. A global secret and a global signing block
95
+ * are shared by every project and survive it.
96
+ */
97
+ app.delete("/api/projects/:slug/vault", async (req, reply) => {
98
+ const { slug } = req.params;
99
+ if (ctx.config.project(slug)) {
100
+ return reply.code(409).send({
101
+ error: `"${slug}" is still a project on this machine. Remove the project first, or remove its secrets and signing blocks one at a time from its own tabs.`,
102
+ });
103
+ }
104
+ const removed = ctx.vault.forget(slug);
105
+ return reply.send({
106
+ slug,
107
+ secretsRemoved: removed.secrets,
108
+ signingBlocksRemoved: removed.credentials,
109
+ });
52
110
  });
53
111
  app.get("/api/projects/:slug/lanes", async (req, reply) => {
54
112
  const { slug } = req.params;