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.
- package/README.md +227 -20
- package/dist/src/cli/detect.js +12 -3
- package/dist/src/cli/prompt.js +28 -3
- package/dist/src/cli/secret-import.js +162 -0
- package/dist/src/cli/secret.js +162 -1
- package/dist/src/cli/setup.js +44 -7
- package/dist/src/cli/uninstall.js +390 -0
- package/dist/src/credentials/kinds.js +120 -0
- package/dist/src/db/credentials.js +100 -0
- package/dist/src/db/schema.sql +39 -0
- package/dist/src/db/secrets.js +53 -0
- package/dist/src/db/sessions.js +61 -0
- package/dist/src/git/workspace.js +32 -6
- package/dist/src/heuristics/android-root.js +49 -0
- package/dist/src/heuristics/android-signing.js +98 -0
- package/dist/src/heuristics/appfile.js +60 -0
- package/dist/src/heuristics/blocking-actions.js +2 -0
- package/dist/src/heuristics/env-example.js +36 -0
- package/dist/src/heuristics/platforms.js +69 -10
- package/dist/src/heuristics/readiness.js +554 -25
- package/dist/src/main.js +23 -2
- package/dist/src/runner/gradle-properties.js +187 -0
- package/dist/src/runner/materialise.js +92 -0
- package/dist/src/runner/orchestrate.js +91 -2
- package/dist/src/secrets/vault.js +137 -5
- package/dist/src/server/app.js +30 -3
- package/dist/src/server/auth.js +50 -10
- package/dist/src/server/permissions.js +2 -0
- package/dist/src/server/required-secrets.js +30 -0
- package/dist/src/server/routes/account.js +57 -0
- package/dist/src/server/routes/credentials.js +108 -0
- package/dist/src/server/routes/projects.js +60 -2
- package/dist/src/server/routes/readiness.js +162 -6
- package/dist/src/server/routes/secrets.js +101 -0
- package/dist/src/sidecar/bridge.js +21 -1
- package/dist/src/sidecar/fastlane-dir.js +23 -0
- package/dist/src/sidecar/lanes.js +6 -0
- package/dist/src/sidecar/uses.js +21 -5
- package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-9nLYwtg7.js} +1 -1
- package/dist/web/assets/index-DsjxZtsO.css +1 -0
- package/dist/web/assets/index-pzPa9EZr.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/ruby/introspect.rb +122 -9
- package/dist/web/assets/index-De6sxx6G.css +0 -1
- package/dist/web/assets/index-TWRQ1cJg.js +0 -62
package/dist/src/server/app.js
CHANGED
|
@@ -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,
|
|
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
|
package/dist/src/server/auth.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
71
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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;
|