laneyard 0.4.1 → 0.5.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 +95 -34
- package/dist/src/cli/home.js +46 -0
- package/dist/src/cli/remove.js +173 -0
- package/dist/src/cli/reset.js +117 -0
- package/dist/src/cli/setup.js +93 -11
- package/dist/src/cli/uninstall.js +2 -24
- package/dist/src/config/accounts.js +103 -39
- package/dist/src/config/load.js +3 -27
- package/dist/src/config/resolve.js +36 -0
- package/dist/src/config/schema.js +15 -5
- package/dist/src/config/store.js +27 -3
- package/dist/src/data/remove-project.js +70 -0
- package/dist/src/db/runs.js +11 -0
- package/dist/src/logs/store.js +9 -1
- package/dist/src/main.js +23 -1
- package/dist/src/secrets/vault.js +6 -5
- package/dist/src/server/app.js +17 -12
- package/dist/src/server/permissions.js +50 -0
- package/dist/src/server/routes/account.js +56 -1
- package/dist/src/server/routes/fastfile.js +7 -0
- package/dist/src/server/routes/projects.js +95 -86
- package/dist/src/server/routes/users.js +47 -4
- package/dist/src/sidecar/fastlane-dir.js +8 -8
- package/dist/web/assets/{Editor-9nLYwtg7.js → Editor-Bqz_dClT.js} +1 -1
- package/dist/web/assets/index-CMEreqtd.js +62 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-pzPa9EZr.js +0 -62
package/dist/src/db/runs.js
CHANGED
|
@@ -39,6 +39,17 @@ export class RunStore {
|
|
|
39
39
|
.all(slug, limit);
|
|
40
40
|
return rows.map(toRun);
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Deletes every run of a project, and returns how many rows went.
|
|
44
|
+
*
|
|
45
|
+
* The steps and the artifact records go with them, by the `ON DELETE CASCADE`
|
|
46
|
+
* on their foreign keys — with `foreign_keys = ON`, which `openDatabase` sets.
|
|
47
|
+
* The log files and the artifact folders live on disk, not here: the caller
|
|
48
|
+
* removes those, which is why it reads the run ids before calling this.
|
|
49
|
+
*/
|
|
50
|
+
removeByProject(slug) {
|
|
51
|
+
return this.db.prepare("DELETE FROM run WHERE project_slug = ?").run(slug).changes;
|
|
52
|
+
}
|
|
42
53
|
setStatus(id, status) {
|
|
43
54
|
this.db.prepare("UPDATE run SET status = ? WHERE id = ?").run(status, id);
|
|
44
55
|
}
|
package/dist/src/logs/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createReadStream } from "node:fs";
|
|
2
|
-
import { mkdir, open, readFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, open, readFile, rm } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
/**
|
|
5
5
|
* Append-only writer for a run.
|
|
@@ -63,4 +63,12 @@ export class LogStore {
|
|
|
63
63
|
stream(runId, fromOffset = 0) {
|
|
64
64
|
return createReadStream(this.pathFor(runId), { start: fromOffset });
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Removes a run's log file. A run that never opened one is not an error:
|
|
68
|
+
* `force` makes a missing file a no-op, which is exactly the case for a run
|
|
69
|
+
* that failed before it wrote a line.
|
|
70
|
+
*/
|
|
71
|
+
async remove(runId) {
|
|
72
|
+
await rm(this.pathFor(runId), { force: true });
|
|
73
|
+
}
|
|
66
74
|
}
|
package/dist/src/main.js
CHANGED
|
@@ -7,6 +7,8 @@ import { bold, dim, field, heading } from "./cli/style.js";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { PromptAborted } from "./cli/prompt.js";
|
|
9
9
|
import { runSetupCommand } from "./cli/setup.js";
|
|
10
|
+
import { runRemoveCommand } from "./cli/remove.js";
|
|
11
|
+
import { runResetCommand } from "./cli/reset.js";
|
|
10
12
|
import { runSecretCommand } from "./cli/secret.js";
|
|
11
13
|
import { runUninstallCommand } from "./cli/uninstall.js";
|
|
12
14
|
import { runUserCommand } from "./cli/user.js";
|
|
@@ -21,7 +23,7 @@ import { Vault } from "./secrets/vault.js";
|
|
|
21
23
|
import { makeInvoke } from "./sidecar/bridge.js";
|
|
22
24
|
import { LaneReader } from "./sidecar/lanes.js";
|
|
23
25
|
import { UsesReader } from "./sidecar/uses.js";
|
|
24
|
-
export const version = "0.
|
|
26
|
+
export const version = "0.5.0";
|
|
25
27
|
/** Assembles the server from a data folder. */
|
|
26
28
|
export async function createServerFromConfig(root) {
|
|
27
29
|
const config = new ConfigStore(join(root, "config.yml"));
|
|
@@ -112,6 +114,11 @@ const USAGE = `laneyard — a self-hosted web UI for fastlane
|
|
|
112
114
|
store a secret, its value read from standard input
|
|
113
115
|
laneyard user add NAME [--role admin|builder]
|
|
114
116
|
create an account, its password read from standard input
|
|
117
|
+
laneyard remove <slug>
|
|
118
|
+
remove one project and everything Laneyard holds for it
|
|
119
|
+
--dry-run shows what would go and stops
|
|
120
|
+
laneyard reset wipe the data, keeping the accounts and the vault key
|
|
121
|
+
--dry-run shows what would go and stops
|
|
115
122
|
laneyard uninstall remove Laneyard's data folder, after showing what is in it
|
|
116
123
|
--dry-run shows the inventory and stops
|
|
117
124
|
laneyard start the server
|
|
@@ -198,6 +205,21 @@ if (invokedDirectly()) {
|
|
|
198
205
|
err: (text) => process.stderr.write(text),
|
|
199
206
|
}));
|
|
200
207
|
}
|
|
208
|
+
// No `mkdir` either: like `uninstall`, these read what is already there.
|
|
209
|
+
if (command === "remove") {
|
|
210
|
+
process.exit(await runRemoveCommand(homeDir(), rest, {
|
|
211
|
+
stdin: process.stdin,
|
|
212
|
+
out: (text) => process.stdout.write(text),
|
|
213
|
+
err: (text) => process.stderr.write(text),
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
if (command === "reset") {
|
|
217
|
+
process.exit(await runResetCommand(homeDir(), rest, {
|
|
218
|
+
stdin: process.stdin,
|
|
219
|
+
out: (text) => process.stdout.write(text),
|
|
220
|
+
err: (text) => process.stderr.write(text),
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
201
223
|
// 0.1.0 shipped this as `add`. Anyone who learned that name deserves a
|
|
202
224
|
// sentence rather than "Unknown command".
|
|
203
225
|
if (command === "add") {
|
|
@@ -51,12 +51,13 @@ export class Vault {
|
|
|
51
51
|
/**
|
|
52
52
|
* Forgets everything stored under one project's name.
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
54
|
+
* Part of removing a project: that act clears the vault along with the clone,
|
|
55
|
+
* the artifacts and the run history, behind the one typed confirmation that
|
|
56
|
+
* covers all of it. What it forgets is Laneyard's own encrypted copy — the
|
|
57
|
+
* `.p8` and the keystore that went in are still wherever the user keeps them.
|
|
58
58
|
*
|
|
59
|
-
* Scoped by slug, so a global secret or a global signing block survives it
|
|
59
|
+
* Scoped by slug, so a global secret or a global signing block survives it:
|
|
60
|
+
* those are read by every project and are never one project's to remove.
|
|
60
61
|
*/
|
|
61
62
|
forget(projectSlug) {
|
|
62
63
|
return {
|
package/dist/src/server/app.js
CHANGED
|
@@ -4,7 +4,6 @@ import Fastify from "fastify";
|
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { LEGACY_ADMIN_NAME } from "../config/load.js";
|
|
8
7
|
import { RunStore } from "../db/runs.js";
|
|
9
8
|
import { SessionRecords } from "../db/sessions.js";
|
|
10
9
|
import { Workspace } from "../git/workspace.js";
|
|
@@ -13,7 +12,7 @@ import { materialiseCredentials } from "../runner/materialise.js";
|
|
|
13
12
|
import { executeRun } from "../runner/orchestrate.js";
|
|
14
13
|
import { RunQueue } from "../runner/queue.js";
|
|
15
14
|
import { authenticate, LoginThrottle, COOKIE_OPTIONS, SESSION_COOKIE, SessionStore } from "./auth.js";
|
|
16
|
-
import { requiresAdmin } from "./permissions.js";
|
|
15
|
+
import { accountMayReach, projectSlugOfRequest, requiresAdmin } from "./permissions.js";
|
|
17
16
|
import { registerCredentialRoutes } from "./routes/credentials.js";
|
|
18
17
|
import { registerFastfileRoutes } from "./routes/fastfile.js";
|
|
19
18
|
import { registerProjectRoutes } from "./routes/projects.js";
|
|
@@ -130,13 +129,7 @@ export async function buildApp(deps) {
|
|
|
130
129
|
const throttle = new LoginThrottle();
|
|
131
130
|
app.post("/api/login", async (req, reply) => {
|
|
132
131
|
const { name, password } = (req.body ?? {});
|
|
133
|
-
|
|
134
|
-
// It authenticates as `admin`, which is precisely the account a lone
|
|
135
|
-
// `server.password_hash` is loaded as — so an upgraded install keeps
|
|
136
|
-
// working, and the loader still owes nobody an answer about which form
|
|
137
|
-
// the file used.
|
|
138
|
-
const account = name ?? LEGACY_ADMIN_NAME;
|
|
139
|
-
const waitMs = throttle.retryAfterMs(account);
|
|
132
|
+
const waitMs = name ? throttle.retryAfterMs(name) : 0;
|
|
140
133
|
if (waitMs > 0) {
|
|
141
134
|
return reply
|
|
142
135
|
.code(429)
|
|
@@ -144,15 +137,16 @@ export async function buildApp(deps) {
|
|
|
144
137
|
.send({ error: `Too many attempts. Try again in ${Math.ceil(waitMs / 1000)}s.` });
|
|
145
138
|
}
|
|
146
139
|
const users = deps.config.server()?.users ?? [];
|
|
147
|
-
const identity = password ? await authenticate(users,
|
|
140
|
+
const identity = name && password ? await authenticate(users, name, password) : null;
|
|
148
141
|
if (!identity) {
|
|
149
|
-
|
|
142
|
+
if (name)
|
|
143
|
+
throttle.recordFailure(name);
|
|
150
144
|
// One message for a wrong password and for a name that does not exist:
|
|
151
145
|
// telling them apart is telling a stranger which accounts are worth
|
|
152
146
|
// attacking.
|
|
153
147
|
return reply.code(401).send({ error: "Incorrect name or password" });
|
|
154
148
|
}
|
|
155
|
-
throttle.recordSuccess(
|
|
149
|
+
throttle.recordSuccess(identity.name);
|
|
156
150
|
const token = ctx.sessions.issue(identity);
|
|
157
151
|
return reply
|
|
158
152
|
.setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
|
|
@@ -180,6 +174,17 @@ export async function buildApp(deps) {
|
|
|
180
174
|
if (identity.role !== "admin" && requiresAdmin(req.method, req.url)) {
|
|
181
175
|
return reply.code(403).send({ error: "This action requires the admin role" });
|
|
182
176
|
}
|
|
177
|
+
// Per-project reach, for a non-admin only: a project this account may not
|
|
178
|
+
// reach is invisible, not shown-and-locked, so it answers 404 with the very
|
|
179
|
+
// body a genuinely unknown project gives. A 403 would confirm the project
|
|
180
|
+
// exists. The slug is resolved here, in the one hook, rather than in each
|
|
181
|
+
// run and project handler — the same reason `requiresAdmin` lives here.
|
|
182
|
+
if (identity.role !== "admin") {
|
|
183
|
+
const slug = projectSlugOfRequest(req.url, (id) => ctx.runs.get(id)?.projectSlug ?? null);
|
|
184
|
+
if (slug !== null && !accountMayReach(account, slug)) {
|
|
185
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
183
188
|
});
|
|
184
189
|
/**
|
|
185
190
|
* Signing out ends this session and no other.
|
|
@@ -73,3 +73,53 @@ export function requiresAdmin(method, url) {
|
|
|
73
73
|
return expected.every((seg, i) => seg.startsWith(":") || seg === parts[i]);
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* May this account reach that project?
|
|
78
|
+
*
|
|
79
|
+
* The other half of the auth answer, and data-driven rather than a role: it is
|
|
80
|
+
* still two roles, with the builder's reach narrowed by a list. An `admin`
|
|
81
|
+
* reaches everything — managing the server is the whole role. A `builder` with
|
|
82
|
+
* no `projects` field reaches everything too (an old config, untouched by this
|
|
83
|
+
* feature); an empty list reaches nothing; a list reaches its slugs.
|
|
84
|
+
*
|
|
85
|
+
* Named and beside `REQUIRES_ADMIN` on purpose: the reach check is a permission,
|
|
86
|
+
* and a permission expressed as an `if` inside a handler is one nobody finds
|
|
87
|
+
* during an audit.
|
|
88
|
+
*/
|
|
89
|
+
export function accountMayReach(account, slug) {
|
|
90
|
+
if (account.role === "admin")
|
|
91
|
+
return true;
|
|
92
|
+
if (account.projects === undefined)
|
|
93
|
+
return true;
|
|
94
|
+
return account.projects.includes(slug);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The project a request concerns, or null when it concerns none.
|
|
98
|
+
*
|
|
99
|
+
* Two shapes carry a project:
|
|
100
|
+
*
|
|
101
|
+
* `/api/projects/:slug/*` — the slug is in the path. The bare `/api/projects`
|
|
102
|
+
* listing carries none: it is filtered in its own handler, per account.
|
|
103
|
+
* `/api/runs/:id/*` — a run addresses a project by id, not a slug, so the id
|
|
104
|
+
* is mapped through `runSlug`. An id that names no run yields null, and the
|
|
105
|
+
* handler answers its own 404 for the unknown run.
|
|
106
|
+
*
|
|
107
|
+
* Everything else — the vault, the accounts, `/api/me` — concerns no single
|
|
108
|
+
* project and returns null, passing the reach check untouched.
|
|
109
|
+
*
|
|
110
|
+
* `runSlug` is handed in rather than the run store reached into, so the mapping
|
|
111
|
+
* this depends on stays a one-line lookup the caller owns and this stays a pure,
|
|
112
|
+
* testable function.
|
|
113
|
+
*/
|
|
114
|
+
export function projectSlugOfRequest(url, runSlug) {
|
|
115
|
+
const parts = segments(url);
|
|
116
|
+
if (parts[0] !== "api")
|
|
117
|
+
return null;
|
|
118
|
+
if (parts[1] === "projects" && parts.length >= 3)
|
|
119
|
+
return parts[2] ?? null;
|
|
120
|
+
if (parts[1] === "runs" && parts.length >= 3) {
|
|
121
|
+
const id = Number(parts[2]);
|
|
122
|
+
return Number.isInteger(id) ? runSlug(id) : null;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MIN_PASSWORD_LENGTH, upsertUserInConfig } from "../../config/accounts.js";
|
|
1
|
+
import { MIN_PASSWORD_LENGTH, VALID_NAME, renameUserInConfig, upsertUserInConfig } from "../../config/accounts.js";
|
|
2
2
|
import { COOKIE_OPTIONS, SESSION_COOKIE, authenticate } from "../auth.js";
|
|
3
3
|
/**
|
|
4
4
|
* What someone may do to their own account, whatever their role.
|
|
@@ -54,4 +54,59 @@ export async function registerAccountRoutes(app, ctx) {
|
|
|
54
54
|
.code(204)
|
|
55
55
|
.send();
|
|
56
56
|
});
|
|
57
|
+
/**
|
|
58
|
+
* Changing your own name — the other thing only you can do for yourself.
|
|
59
|
+
*
|
|
60
|
+
* Everything the password route is careful about is careful here for the same
|
|
61
|
+
* reasons: the current password is asked for because a session is a cookie in
|
|
62
|
+
* a browser that may have been left open; the role comes from the session and
|
|
63
|
+
* never from the body, so a rename cannot smuggle in a promotion; and the
|
|
64
|
+
* account this touches is only ever the session's own, there being no name in
|
|
65
|
+
* the body to point anywhere else.
|
|
66
|
+
*
|
|
67
|
+
* The one thing that is not like the password route is the write. A rename is
|
|
68
|
+
* not `upsertUserInConfig`, which keys by name and would add a second account
|
|
69
|
+
* under the new name and leave the old one — and its `projects` grants —
|
|
70
|
+
* behind. `renameUserInConfig` edits the `name` field of the existing entry in
|
|
71
|
+
* place, so role, hash and grants ride along untouched.
|
|
72
|
+
*/
|
|
73
|
+
app.post("/api/account/name", async (req, reply) => {
|
|
74
|
+
const { name, role } = req.identity;
|
|
75
|
+
const { current, next } = (req.body ?? {});
|
|
76
|
+
if (typeof next !== "string" || !VALID_NAME.test(next)) {
|
|
77
|
+
return reply.code(400).send({
|
|
78
|
+
error: "A name is letters, digits, dot, dash and underscore, starting with a letter or a digit.",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const users = ctx.config.server()?.users ?? [];
|
|
82
|
+
if (typeof current !== "string" || !(await authenticate(users, name, current))) {
|
|
83
|
+
// No throttle, for the same reason as the password route: reaching here
|
|
84
|
+
// already costs a valid session.
|
|
85
|
+
return reply.code(401).send({ error: "That is not your current password." });
|
|
86
|
+
}
|
|
87
|
+
if (next === name) {
|
|
88
|
+
return reply.code(400).send({ error: "That is the name you already have." });
|
|
89
|
+
}
|
|
90
|
+
// A collision is refused rather than allowed to collapse two accounts into
|
|
91
|
+
// one. The comparison is case-sensitive, because the stored names are: `Ci`
|
|
92
|
+
// and `ci` are two accounts, and a rename to either is a rename to that one.
|
|
93
|
+
if (users.some((u) => u.name === next)) {
|
|
94
|
+
return reply.code(409).send({ error: `${next} is already the name of another account.` });
|
|
95
|
+
}
|
|
96
|
+
await renameUserInConfig(ctx.config.configPath(), name, next);
|
|
97
|
+
// Every session under the old name goes — the auth hook matches a session's
|
|
98
|
+
// name against a live account, and after this there is no account by that
|
|
99
|
+
// name for it to match. A fresh session under the new name is issued at once,
|
|
100
|
+
// so the person who just did it stays signed in on this page.
|
|
101
|
+
ctx.sessions.revokeAllFor(name);
|
|
102
|
+
const token = ctx.sessions.issue({ name: next, role });
|
|
103
|
+
// The file is watched on a debounce; loading here is what makes the very next
|
|
104
|
+
// request see the account under its new name rather than its old one.
|
|
105
|
+
await ctx.config.load();
|
|
106
|
+
// The new name is returned so the interface can update the header and this
|
|
107
|
+
// page, now that the cookie it carries belongs to a different name.
|
|
108
|
+
return reply
|
|
109
|
+
.setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
|
|
110
|
+
.send({ name: next, role });
|
|
111
|
+
});
|
|
57
112
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { FastfileStore } from "../../fastfile/store.js";
|
|
3
3
|
import { Workspace } from "../../git/workspace.js";
|
|
4
|
+
import { assertFastlaneDir } from "../../sidecar/fastlane-dir.js";
|
|
4
5
|
export async function registerFastfileRoutes(app, ctx) {
|
|
5
6
|
// Stateless: the previous content it keeps in memory during a write lives
|
|
6
7
|
// on the call stack, not on the instance, so sharing one across requests
|
|
@@ -40,6 +41,12 @@ export async function registerFastfileRoutes(app, ctx) {
|
|
|
40
41
|
if (!r)
|
|
41
42
|
return;
|
|
42
43
|
try {
|
|
44
|
+
// The common case is not a hand-deleted Fastfile but a `fastlane_dir`
|
|
45
|
+
// that never reached the clone — a path only in someone's working copy,
|
|
46
|
+
// uncommitted or gitignored, or a stray local copy. `store.read` would
|
|
47
|
+
// surface that as a bare ENOENT; this turns it into the same sentence the
|
|
48
|
+
// sidecar gives, naming the directory and how to fix it.
|
|
49
|
+
await assertFastlaneDir(join(r.workspacePath, r.fastlaneDir), r.fastlaneDir);
|
|
43
50
|
const [content, dirty, diff] = await Promise.all([
|
|
44
51
|
store.read(r.workspacePath, r.fastlaneDir),
|
|
45
52
|
r.workspace.isDirty(),
|
|
@@ -1,40 +1,69 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { accountMayReach } from "../permissions.js";
|
|
2
|
+
import { removeProjectData } from "../../data/remove-project.js";
|
|
3
3
|
export async function registerProjectRoutes(app, ctx) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
// The listing every account is shown, filtered to what it may reach. This is
|
|
5
|
+
// the source of the invisibility the interface shows — the nav and the project
|
|
6
|
+
// list are driven by it, so a filtered response filters the UI with no change
|
|
7
|
+
// beyond what the data carries. The account is looked up fresh, the way the
|
|
8
|
+
// auth hook does, because config.yml is the truth on every request. An admin,
|
|
9
|
+
// and a builder with no `projects` field, are served every project.
|
|
10
|
+
app.get("/api/projects", async (req) => {
|
|
11
|
+
const account = ctx.config.server()?.users.find((u) => u.name === req.identity.name);
|
|
12
|
+
return ctx.config
|
|
13
|
+
.projects()
|
|
14
|
+
.filter((p) => account !== undefined && accountMayReach(account, p.slug))
|
|
15
|
+
.map((p) => {
|
|
16
|
+
const last = ctx.runs.listByProject(p.slug, 1)[0] ?? null;
|
|
17
|
+
return {
|
|
18
|
+
slug: p.slug,
|
|
19
|
+
name: p.name,
|
|
20
|
+
color: p.color,
|
|
21
|
+
lastRun: last && { id: last.id, status: last.status, lane: last.lane, finishedAt: last.finishedAt },
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
});
|
|
13
25
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
26
|
+
* Removes everything Laneyard holds for a project, in one confirmed act.
|
|
27
|
+
*
|
|
28
|
+
* It is the destructive route in the product, and it is destructive on
|
|
29
|
+
* purpose. The block leaves config.yml, through the YAML document so the rest
|
|
30
|
+
* of a hand-written file is untouched; the clone is deleted; every artifact
|
|
31
|
+
* folder goes; the run history — the rows and their logs — is deleted; and
|
|
32
|
+
* the project's own secrets and signing blocks are forgotten from the vault.
|
|
33
|
+
* The history is the one thing here that cannot be made again, which is why
|
|
34
|
+
* the whole act is behind a slug typed back rather than a click.
|
|
16
35
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
* - the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
36
|
+
* What it still does not reach, and why each is out of scope:
|
|
37
|
+
*
|
|
38
|
+
* - the git remote. The repository is on GitHub and on the user's disk. It
|
|
39
|
+
* is theirs, not Laneyard's, and nothing here reads or writes it.
|
|
40
|
+
* - the credential originals. Laneyard removes its own encrypted copy of a
|
|
41
|
+
* `.p8` or a keystore; the file that went in is still in the password
|
|
42
|
+
* manager or the safe it came from. The answer says so, the way
|
|
43
|
+
* `uninstall` does, so nobody is left thinking their keystore is gone.
|
|
44
|
+
* - global secrets and global signing blocks. They are read by every project
|
|
45
|
+
* on the machine, not this one's to take — `vault.forget` touches only
|
|
46
|
+
* slug-scoped rows, and the answer counts the global ones it left alone.
|
|
47
|
+
*
|
|
48
|
+
* The confirmation is the project's own slug, sent back as `?confirm=`.
|
|
49
|
+
* Without a match nothing is removed: a bare DELETE is a refusal, not a
|
|
50
|
+
* deletion. This is the gate `laneyard uninstall` uses for the same reason —
|
|
51
|
+
* the one irreversible thing must not be reachable by a reflex.
|
|
32
52
|
*/
|
|
33
53
|
app.delete("/api/projects/:slug", async (req, reply) => {
|
|
34
54
|
const { slug } = req.params;
|
|
55
|
+
const { confirm } = req.query;
|
|
35
56
|
const entry = ctx.config.project(slug);
|
|
36
57
|
if (!entry)
|
|
37
58
|
return reply.code(404).send({ error: "Unknown project" });
|
|
59
|
+
// The slug typed back is the confirmation. This deletes the run history,
|
|
60
|
+
// which nothing can rebuild, so a request that does not carry the slug
|
|
61
|
+
// removes nothing and says why.
|
|
62
|
+
if (confirm !== slug) {
|
|
63
|
+
return reply.code(400).send({
|
|
64
|
+
error: `Removing "${slug}" deletes its runs, its clone, its artifacts and its stored secrets, and cannot be undone. Send the project's slug as confirmation to remove it. Nothing was removed.`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
38
67
|
// A run that has begun is reading the workspace this project points at.
|
|
39
68
|
// Queued runs are not: the queue already fails a run whose project went
|
|
40
69
|
// away, so waiting on them would only mean refusing for longer.
|
|
@@ -43,69 +72,49 @@ export async function registerProjectRoutes(app, ctx) {
|
|
|
43
72
|
error: `"${slug}" has a run in flight. Wait for it to finish, or cancel it, then remove the project.`,
|
|
44
73
|
});
|
|
45
74
|
}
|
|
46
|
-
|
|
47
|
-
|
|
75
|
+
// The global counts are read now — the last moment anyone is looking — so
|
|
76
|
+
// the answer can say what it left alone. They are shared by every project
|
|
77
|
+
// and survive one of them going away, which is why the removal never takes
|
|
78
|
+
// them and the reply names them apart.
|
|
79
|
+
const globalSecrets = ctx.vault.listGlobal().length;
|
|
80
|
+
const globalSigningBlocks = ctx.vault.listGlobalCredentials().length;
|
|
81
|
+
// The removal itself lives in one place, shared with `laneyard remove`: the
|
|
82
|
+
// route confirms and shapes the reply, the core does the deleting.
|
|
83
|
+
const result = await removeProjectData({
|
|
84
|
+
configPath: ctx.config.configPath(),
|
|
85
|
+
// The file is watched, but on a debounce: reloading here is what makes
|
|
86
|
+
// the very next request — the listing this page is about to ask for —
|
|
87
|
+
// truthful.
|
|
88
|
+
reloadConfig: () => ctx.config.load(),
|
|
89
|
+
runs: ctx.runs,
|
|
90
|
+
logs: ctx.logs,
|
|
91
|
+
vault: ctx.vault,
|
|
92
|
+
workspacePath: ctx.workspacePath,
|
|
93
|
+
artifactsDir: ctx.artifactsDir,
|
|
94
|
+
}, slug);
|
|
95
|
+
if (!result.found)
|
|
48
96
|
return reply.code(404).send({ error: "Unknown project" });
|
|
49
|
-
// The
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
// behind, and a project with sixty runs must not be told about fifty of them.
|
|
54
|
-
const runs = ctx.runs.listByProject(slug, -1);
|
|
55
|
-
const leftOnDisk = [
|
|
56
|
-
ctx.workspacePath(slug),
|
|
57
|
-
...runs.map((run) => ctx.artifactsDir(run.id)),
|
|
58
|
-
].filter((path) => existsSync(path));
|
|
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);
|
|
97
|
+
// The slug is also stripped from every account's access grants, in the core
|
|
98
|
+
// beside the other "forget for this slug" steps: a grant pointing at a
|
|
99
|
+
// project that no longer exists is dead data, and a project re-created later
|
|
100
|
+
// under the same slug must not silently inherit an old grant.
|
|
63
101
|
return reply.send({
|
|
64
102
|
slug,
|
|
65
103
|
name: entry.name,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
104
|
+
removed: {
|
|
105
|
+
runs: result.runs,
|
|
106
|
+
artifacts: result.artifacts,
|
|
107
|
+
workspace: result.workspace,
|
|
108
|
+
secrets: result.secrets,
|
|
109
|
+
signingBlocks: result.signingBlocks,
|
|
110
|
+
},
|
|
111
|
+
// Named, not removed. The git remote and the credential originals are the
|
|
112
|
+
// user's and are never touched here; the global rows are shared by every
|
|
113
|
+
// project and survive one of them going away.
|
|
114
|
+
untouched: {
|
|
115
|
+
globalSecrets,
|
|
116
|
+
globalSigningBlocks,
|
|
76
117
|
},
|
|
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
118
|
});
|
|
110
119
|
});
|
|
111
120
|
app.get("/api/projects/:slug/lanes", async (req, reply) => {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { MIN_PASSWORD_LENGTH, VALID_NAME, refusalFor, removeUserFromConfig, upsertUserInConfig, } from "../../config/accounts.js";
|
|
1
|
+
import { MIN_PASSWORD_LENGTH, VALID_NAME, refusalFor, removeUserFromConfig, setUserProjectsInConfig, upsertUserInConfig, } from "../../config/accounts.js";
|
|
2
|
+
/** The same slug rule the schema enforces, stated once for the grant route. */
|
|
3
|
+
const VALID_SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
2
4
|
const ROLES = ["admin", "builder"];
|
|
3
5
|
/**
|
|
4
6
|
* The accounts, as the interface sees them.
|
|
@@ -9,9 +11,12 @@ const ROLES = ["admin", "builder"];
|
|
|
9
11
|
*/
|
|
10
12
|
export async function registerUserRoutes(app, ctx) {
|
|
11
13
|
const accounts = () => ctx.config.server()?.users ?? [];
|
|
12
|
-
// Name
|
|
13
|
-
//
|
|
14
|
-
|
|
14
|
+
// Name, role, and the project grants — never the hash, which is simply never
|
|
15
|
+
// put on the wire. `projects` is sent as it stands: an array when the account
|
|
16
|
+
// carries a grant list, `null` when it has no field at all (every project),
|
|
17
|
+
// the same three-way the storage keeps. The accounts screen ticks a checkbox
|
|
18
|
+
// from it; an admin's is `null` and its checklist is not drawn.
|
|
19
|
+
app.get("/api/users", async () => accounts().map((u) => ({ name: u.name, role: u.role, projects: u.projects ?? null })));
|
|
15
20
|
app.post("/api/users", async (req, reply) => {
|
|
16
21
|
const { name, role, password } = (req.body ?? {});
|
|
17
22
|
if (typeof name !== "string" || !VALID_NAME.test(name)) {
|
|
@@ -46,6 +51,44 @@ export async function registerUserRoutes(app, ctx) {
|
|
|
46
51
|
await ctx.config.load();
|
|
47
52
|
return reply.code(created ? 201 : 200).send({ name, role });
|
|
48
53
|
});
|
|
54
|
+
/**
|
|
55
|
+
* Sets which projects an account may reach.
|
|
56
|
+
*
|
|
57
|
+
* Admin-only, like every route in this file: it is covered by the
|
|
58
|
+
* `/api/users` prefix in `REQUIRES_ADMIN`, so nothing here checks a role. The
|
|
59
|
+
* list is written through the YAML document and takes effect on the next
|
|
60
|
+
* request, since the auth hook re-reads config.yml every time — no session is
|
|
61
|
+
* revoked, because the role has not changed, only what it reaches.
|
|
62
|
+
*
|
|
63
|
+
* An admin has no reach to restrict: the field is ignored for them, so writing
|
|
64
|
+
* one would be a lie the server does not tell. The request is refused rather
|
|
65
|
+
* than quietly written and forgotten.
|
|
66
|
+
*/
|
|
67
|
+
app.put("/api/users/:name/projects", async (req, reply) => {
|
|
68
|
+
const { name } = req.params;
|
|
69
|
+
const { projects } = (req.body ?? {});
|
|
70
|
+
if (!Array.isArray(projects) ||
|
|
71
|
+
!projects.every((p) => typeof p === "string" && VALID_SLUG.test(p))) {
|
|
72
|
+
return reply
|
|
73
|
+
.code(400)
|
|
74
|
+
.send({ error: "Projects is a list of slugs: lowercase letters, digits and hyphens." });
|
|
75
|
+
}
|
|
76
|
+
const account = accounts().find((u) => u.name === name);
|
|
77
|
+
if (!account)
|
|
78
|
+
return reply.code(404).send({ error: "Unknown account" });
|
|
79
|
+
if (account.role === "admin") {
|
|
80
|
+
return reply
|
|
81
|
+
.code(400)
|
|
82
|
+
.send({ error: "An admin reaches every project; there is no access list to set." });
|
|
83
|
+
}
|
|
84
|
+
const written = await setUserProjectsInConfig(ctx.config.configPath(), name, projects);
|
|
85
|
+
if (!written)
|
|
86
|
+
return reply.code(404).send({ error: "Unknown account" });
|
|
87
|
+
// The file is watched on a debounce; reloading here makes the very next
|
|
88
|
+
// request — and the reach check in the hook — see the grant at once.
|
|
89
|
+
await ctx.config.load();
|
|
90
|
+
return reply.code(200).send({ name, projects });
|
|
91
|
+
});
|
|
49
92
|
app.delete("/api/users/:name", async (req, reply) => {
|
|
50
93
|
const { name } = req.params;
|
|
51
94
|
if (!accounts().some((u) => u.name === name)) {
|
|
@@ -6,18 +6,18 @@ import { stat } from "node:fs/promises";
|
|
|
6
6
|
* '…/workspaces/popotheque-app/fastlane'` — technically accurate and no help at
|
|
7
7
|
* all, because the interesting part is *why* it was looking there.
|
|
8
8
|
*
|
|
9
|
-
* It is nearly always the same story
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* It is nearly always the same story: the configured fastlane folder is in the
|
|
10
|
+
* working copy setup ran against, but not in the clone Laneyard builds from. A
|
|
11
|
+
* folder only present locally — never committed, not yet pushed, or gitignored
|
|
12
|
+
* — does not reach the clone, and neither does a `laneyard.yml` that would
|
|
13
|
+
* point at it until it too is committed and pushed.
|
|
14
14
|
*/
|
|
15
15
|
export async function assertFastlaneDir(dir, configured) {
|
|
16
16
|
const found = await stat(dir).then((s) => s.isDirectory(), () => false);
|
|
17
17
|
if (found)
|
|
18
18
|
return;
|
|
19
19
|
throw new Error(`No ${configured}/ in the clone. Laneyard builds from a clone of the remote, ` +
|
|
20
|
-
"so
|
|
21
|
-
"
|
|
22
|
-
"on the project's block in config.yml.");
|
|
20
|
+
"so a fastlane folder that exists only in your working copy — uncommitted, " +
|
|
21
|
+
"unpushed, or gitignored — never reaches it. Commit and push it, or set " +
|
|
22
|
+
"`fastlane_dir` on the project's block in config.yml.");
|
|
23
23
|
}
|