laneyard 0.2.0 → 0.3.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.
@@ -20,6 +20,9 @@ const META = {
20
20
  appStoreConnect: { id: "app-store-connect", title: "App Store Connect authentication" },
21
21
  match: { id: "match", title: "match usable without intervention" },
22
22
  blockingActions: { id: "blocking-actions", title: "no action known to stop and ask" },
23
+ androidKeystore: { id: "android-keystore", title: "keystore reachable without a prompt" },
24
+ playStore: { id: "play-store", title: "Play Store service account" },
25
+ platforms: { id: "platforms", title: "what this project builds for" },
23
26
  };
24
27
  /**
25
28
  * Does the remote answer without asking anyone for credentials?
@@ -161,33 +164,173 @@ export function checkBlockingActions(uses) {
161
164
  [...new Set(findings.map((f) => f.finding.fix))].join(" ")),
162
165
  };
163
166
  }
167
+ /** `build_android_app` is the newer name for the same action. */
168
+ const GRADLE_ACTIONS = new Set(["gradle", "build_android_app"]);
169
+ /**
170
+ * The arguments that say a lane hands gradle a keystore of its own.
171
+ *
172
+ * Read literally, like everything else here. `gradle(storePassword: ENV["PW"])`
173
+ * arrives with no `storePassword` at all — the sidecar drops what it cannot
174
+ * read — and this check says so rather than inventing a verdict.
175
+ */
176
+ const KEYSTORE_ARGS = ["storeFile", "storePassword"];
177
+ /** `ANDROID_KEYSTORE_PASSWORD`, `KEYSTORE_PASSWORD`, `STORE_PASSWORD`. */
178
+ const KEYSTORE_PASSWORD = /(^|_)(KEYSTORE|STORE)_PASSWORD$/;
179
+ const STORE_KEYSTORE_PASSWORD = "Store the keystore passphrase as `ANDROID_KEYSTORE_PASSWORD` from the secrets tab, " +
180
+ "and read it in the lane with `storePassword: ENV[\"ANDROID_KEYSTORE_PASSWORD\"]`.";
181
+ export function checkAndroidKeystore(uses, secretKeys) {
182
+ if (!uses.ok) {
183
+ return { ...META.androidKeystore, ...undetermined(`could not read the lanes: ${uses.reason}`) };
184
+ }
185
+ const calls = uses.value.flatMap((lane) => lane.actions.filter((a) => GRADLE_ACTIONS.has(a.name)).map((action) => ({ lane: lane.lane, action })));
186
+ if (calls.length === 0) {
187
+ return { ...META.androidKeystore, ...ok("no lane builds with gradle.") };
188
+ }
189
+ const signing = calls.filter((c) => KEYSTORE_ARGS.some((arg) => arg in c.action.args));
190
+ if (signing.length === 0) {
191
+ // Deliberately a statement about what was read, not about the build: a
192
+ // keystore configured in `build.gradle` or through the environment is
193
+ // invisible from here, and claiming it is absent would be a guess.
194
+ return {
195
+ ...META.androidKeystore,
196
+ ...ok("no lane passes `storeFile` or `storePassword` to gradle: nothing here needs unlocking."),
197
+ };
198
+ }
199
+ const withoutPassphrase = signing.filter((c) => !("storePassword" in c.action.args));
200
+ if (withoutPassphrase.length === 0) {
201
+ return {
202
+ ...META.androidKeystore,
203
+ ...ok(`${nameList(signing.map((c) => c.lane))} passes \`storePassword\` in the call itself: ` +
204
+ "gradle has what it needs."),
205
+ };
206
+ }
207
+ if (secretKeys.some((key) => KEYSTORE_PASSWORD.test(key))) {
208
+ return { ...META.androidKeystore, ...ok("a keystore passphrase is in the vault.") };
209
+ }
210
+ return {
211
+ ...META.androidKeystore,
212
+ ...warn(`${nameList(withoutPassphrase.map((c) => c.lane))} hands gradle a keystore, but no keystore ` +
213
+ "passphrase is in the vault: gradle asks for it and waits.", STORE_KEYSTORE_PASSWORD, "secrets"),
214
+ };
215
+ }
216
+ /** The two names for the same action. `supply` is the older one. */
217
+ const PLAY_UPLOAD_ACTIONS = new Set(["upload_to_play_store", "supply"]);
218
+ /** `SUPPLY_JSON_KEY` and `SUPPLY_JSON_KEY_DATA`, the names fastlane itself reads. */
219
+ const PLAY_JSON_KEY = /^SUPPLY_JSON_KEY/;
220
+ /** The same credential, named in the call instead of in the vault. */
221
+ const PLAY_KEY_ARGS = ["json_key", "json_key_data"];
222
+ const STORE_PLAY_KEY = "Store the service account JSON as `SUPPLY_JSON_KEY_DATA` from the secrets tab. " +
223
+ "A service account does not expire on its own.";
224
+ export function checkPlayStore(uses, secretKeys) {
225
+ if (!uses.ok) {
226
+ return { ...META.playStore, ...undetermined(`could not read the lanes: ${uses.reason}`) };
227
+ }
228
+ const calls = uses.value.flatMap((lane) => lane.actions
229
+ .filter((a) => PLAY_UPLOAD_ACTIONS.has(a.name))
230
+ .map((action) => ({ lane: lane.lane, action })));
231
+ if (calls.length === 0) {
232
+ return { ...META.playStore, ...ok("no lane uploads to the Play Store.") };
233
+ }
234
+ if (secretKeys.some((key) => PLAY_JSON_KEY.test(key))) {
235
+ return { ...META.playStore, ...ok("a Play Store service account is in the vault.") };
236
+ }
237
+ const named = calls.filter((c) => PLAY_KEY_ARGS.some((arg) => arg in c.action.args));
238
+ if (named.length > 0) {
239
+ // A path in a Fastfile says nothing about whether that file exists on this
240
+ // machine. Neither a tick nor a warning would be honest.
241
+ return {
242
+ ...META.playStore,
243
+ ...undetermined(`${nameList(named.map((c) => c.lane))} passes \`json_key\` in the call: whether that file is ` +
244
+ "on this machine is not something a Fastfile says.", STORE_PLAY_KEY),
245
+ };
246
+ }
247
+ return {
248
+ ...META.playStore,
249
+ ...warn(`${nameList(calls.map((c) => c.lane))} uploads to the Play Store, but no service account is in ` +
250
+ "the vault: supply stops and asks which credentials to use.", STORE_PLAY_KEY, "secrets"),
251
+ };
252
+ }
253
+ /**
254
+ * The line a project with no platform gets instead of half a checklist.
255
+ *
256
+ * It is not a check — nothing was examined — and it is not a warning either.
257
+ * It says what is missing and where to write it down, and then the shared
258
+ * checks above it still stand on their own.
259
+ */
260
+ function platformNote(platforms) {
261
+ return {
262
+ ...META.platforms,
263
+ ...undetermined(platforms.ok
264
+ ? "no platform detected: no Xcode project and no Gradle build in the repository, " +
265
+ "and laneyard.yml names none. Only the checks above apply."
266
+ : `could not tell: ${platforms.reason}. Only the checks above apply.`, "Add `platforms: [ios]`, `[android]` or both to laneyard.yml in the repository, " +
267
+ "and the checks for that platform appear here."),
268
+ };
269
+ }
164
270
  /**
165
271
  * The checklist itself: a table, so that adding a check is adding a row.
166
272
  *
167
273
  * Exported because the order it declares is the order the interface shows, and
168
274
  * a test that checks that should read the same list.
169
275
  */
170
- export const CHECKS = [
171
- { ...META.repository, run: (i) => checkRepository(i.probeRepository) },
172
- { ...META.dependencies, run: (i) => checkDependencies(i.dependencies) },
173
- { ...META.appStoreConnect, run: (i) => checkAppStoreConnect(i.secretKeys) },
174
- { ...META.match, run: (i) => checkMatch(i.uses, i.secretKeys) },
175
- { ...META.blockingActions, run: (i) => checkBlockingActions(i.uses) },
276
+ export const SECTIONS = [
277
+ {
278
+ platform: "all",
279
+ checks: [
280
+ { ...META.repository, run: (i) => checkRepository(i.probeRepository) },
281
+ { ...META.dependencies, run: (i) => checkDependencies(i.dependencies) },
282
+ { ...META.blockingActions, run: (i) => checkBlockingActions(i.uses) },
283
+ ],
284
+ },
285
+ {
286
+ platform: "ios",
287
+ checks: [
288
+ { ...META.appStoreConnect, run: (i) => checkAppStoreConnect(i.secretKeys) },
289
+ { ...META.match, run: (i) => checkMatch(i.uses, i.secretKeys) },
290
+ ],
291
+ },
292
+ {
293
+ platform: "android",
294
+ checks: [
295
+ { ...META.androidKeystore, run: (i) => checkAndroidKeystore(i.uses, i.secretKeys) },
296
+ { ...META.playStore, run: (i) => checkPlayStore(i.uses, i.secretKeys) },
297
+ ],
298
+ },
176
299
  ];
177
300
  /**
178
- * Runs every check, and guarantees the list comes back whole.
301
+ * Runs the sections that apply, and guarantees the list comes back whole.
179
302
  *
180
303
  * Each check already commits to not throwing. This wrapper exists because that
181
304
  * promise is worth exactly as much as the next person to edit a check: one
182
305
  * probe misbehaving must cost one line of the checklist, not the checklist.
183
306
  */
184
- export async function runChecks(input) {
185
- return Promise.all(CHECKS.map(async ({ id, title, run }) => {
186
- try {
187
- return await run(input);
188
- }
189
- catch (cause) {
190
- return { id, title, state: "unknown", detail: `the check itself failed: ${reasonOf(cause)}` };
191
- }
192
- }));
307
+ export async function runChecklist(input) {
308
+ // Read once and defensively: this is the one value used outside a check body,
309
+ // and a malformed one must not be the thing that empties the whole screen.
310
+ const platforms = input.platforms && input.platforms.ok && Array.isArray(input.platforms.value)
311
+ ? input.platforms.value
312
+ : [];
313
+ const sections = await Promise.all(SECTIONS.filter((s) => s.platform === "all" || platforms.includes(s.platform)).map(async (section) => ({
314
+ platform: section.platform,
315
+ checks: await Promise.all(section.checks.map(async ({ id, title, run }) => {
316
+ try {
317
+ return await run(input);
318
+ }
319
+ catch (cause) {
320
+ return {
321
+ id,
322
+ title,
323
+ state: "unknown",
324
+ detail: `the check itself failed: ${reasonOf(cause)}`,
325
+ };
326
+ }
327
+ })),
328
+ })));
329
+ // The shared section is always first and always present, so the note about a
330
+ // missing platform sits under the checks that still applied.
331
+ const shared = sections[0];
332
+ if (platforms.length === 0 && shared) {
333
+ shared.checks.push(platformNote(input.platforms ?? { ok: true, value: [] }));
334
+ }
335
+ return sections;
193
336
  }
package/dist/src/main.js CHANGED
@@ -3,9 +3,11 @@ import { realpathSync } from "node:fs";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { bold, dim, field, heading } from "./cli/style.js";
6
7
  import { fileURLToPath } from "node:url";
7
- import { runAddCommand } from "./cli/add.js";
8
+ import { runSetupCommand } from "./cli/setup.js";
8
9
  import { runSecretCommand } from "./cli/secret.js";
10
+ import { runUserCommand } from "./cli/user.js";
9
11
  import { ConfigStore } from "./config/store.js";
10
12
  import { CacheStore } from "./db/cache.js";
11
13
  import { openDatabase } from "./db/open.js";
@@ -16,7 +18,7 @@ import { Vault } from "./secrets/vault.js";
16
18
  import { makeInvoke } from "./sidecar/bridge.js";
17
19
  import { LaneReader } from "./sidecar/lanes.js";
18
20
  import { UsesReader } from "./sidecar/uses.js";
19
- export const version = "0.2.0";
21
+ export const version = "0.3.0";
20
22
  /** Assembles the server from a data folder. */
21
23
  export async function createServerFromConfig(root) {
22
24
  const config = new ConfigStore(join(root, "config.yml"));
@@ -61,7 +63,24 @@ async function main() {
61
63
  });
62
64
  const server = config.server();
63
65
  await app.listen({ port: server.port, host: server.bind });
64
- console.log(`Laneyard is listening on http://localhost:${server.port}`);
66
+ const projects = config.projects();
67
+ process.stdout.write(heading(`laneyard ${version}`) +
68
+ field("listening", `http://localhost:${server.port}`) +
69
+ "\n" +
70
+ field("config", join(root, "config.yml")) +
71
+ "\n" +
72
+ field("projects", projects.length === 0
73
+ ? dim("none yet")
74
+ : projects.map((p) => p.slug).join(", ")) +
75
+ "\n" +
76
+ (projects.length === 0
77
+ ? // A server with nothing to build should say what to do next rather
78
+ // than sit there looking successful.
79
+ "\n" +
80
+ dim(" No project yet. From a folder that already uses fastlane:\n") +
81
+ ` ${bold("laneyard setup")}\n`
82
+ : "") +
83
+ "\n");
65
84
  }
66
85
  /**
67
86
  * True when this file is what the user launched, rather than something that
@@ -84,9 +103,12 @@ function invokedDirectly() {
84
103
  }
85
104
  const USAGE = `laneyard — a self-hosted web UI for fastlane
86
105
 
87
- laneyard add adopt the project in the current directory
106
+ laneyard setup set up the project in the current directory
107
+ --yes accepts every detected value without asking
88
108
  laneyard secret set NAME [--project <slug>]
89
109
  store a secret, its value read from standard input
110
+ laneyard user add NAME [--role admin|builder]
111
+ create an account, its password read from standard input
90
112
  laneyard start the server
91
113
  laneyard --version print the version
92
114
 
@@ -101,7 +123,7 @@ function explainStartupFailure(cause) {
101
123
  if (message.includes("ENOENT") && message.includes("config.yml")) {
102
124
  return ("No configuration yet.\n\n" +
103
125
  " cd into a project that already uses fastlane, then run:\n" +
104
- " laneyard add\n\n" +
126
+ " laneyard setup\n\n" +
105
127
  "That writes " + join(homeDir(), "config.yml") + " for you.");
106
128
  }
107
129
  return message;
@@ -116,13 +138,16 @@ if (invokedDirectly()) {
116
138
  process.stdout.write(`${version}\n`);
117
139
  process.exit(0);
118
140
  }
119
- if (command === "add") {
141
+ if (command === "setup") {
120
142
  const home = homeDir();
121
143
  await mkdir(home, { recursive: true });
122
144
  const slugIndex = rest.indexOf("--slug");
123
145
  const slug = slugIndex === -1 ? undefined : rest[slugIndex + 1];
146
+ // `--yes` accepts every proposal, for scripts and for anyone who has done
147
+ // this before. Interactive is the default because the values are guesses.
148
+ const yes = rest.includes("--yes") || rest.includes("-y");
124
149
  try {
125
- process.exit(await runAddCommand(process.cwd(), join(home, "config.yml"), slug));
150
+ process.exit(await runSetupCommand(process.cwd(), join(home, "config.yml"), { slug, yes }));
126
151
  }
127
152
  catch (cause) {
128
153
  // A taken slug or an unreadable file are ordinary situations. A stack
@@ -142,6 +167,22 @@ if (invokedDirectly()) {
142
167
  err: (text) => process.stderr.write(text),
143
168
  }));
144
169
  }
170
+ if (command === "user") {
171
+ const home = homeDir();
172
+ await mkdir(home, { recursive: true });
173
+ process.exit(await runUserCommand(home, rest, {
174
+ stdin: process.stdin,
175
+ interactive: process.stdin.isTTY === true,
176
+ out: (text) => process.stdout.write(text),
177
+ err: (text) => process.stderr.write(text),
178
+ }));
179
+ }
180
+ // 0.1.0 shipped this as `add`. Anyone who learned that name deserves a
181
+ // sentence rather than "Unknown command".
182
+ if (command === "add") {
183
+ process.stderr.write("`laneyard add` is now `laneyard setup`.\n");
184
+ process.exit(1);
185
+ }
145
186
  if (command !== undefined) {
146
187
  process.stderr.write(`Unknown command: ${command}\n\n${USAGE}`);
147
188
  process.exit(1);
@@ -4,21 +4,27 @@ 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";
7
8
  import { RunStore } from "../db/runs.js";
8
9
  import { Workspace } from "../git/workspace.js";
9
10
  import { LogStore } from "../logs/store.js";
10
11
  import { executeRun } from "../runner/orchestrate.js";
11
12
  import { RunQueue } from "../runner/queue.js";
12
- import { LoginThrottle, SESSION_COOKIE, SessionStore, verifyPassword } from "./auth.js";
13
+ import { authenticate, LoginThrottle, SESSION_COOKIE, SessionStore } from "./auth.js";
14
+ import { requiresAdmin } from "./permissions.js";
13
15
  import { registerFastfileRoutes } from "./routes/fastfile.js";
14
16
  import { registerProjectRoutes } from "./routes/projects.js";
15
17
  import { registerReadinessRoutes } from "./routes/readiness.js";
16
18
  import { registerRunRoutes } from "./routes/runs.js";
17
19
  import { registerSecretRoutes } from "./routes/secrets.js";
20
+ import { registerUserRoutes } from "./routes/users.js";
18
21
  import { registerWebSocket } from "./ws.js";
19
22
  export async function buildApp(deps) {
20
23
  const app = Fastify({ logger: false });
21
24
  await app.register(cookie);
25
+ // Declared up front so every request carries the field on the same shape,
26
+ // rather than each one growing a property the first time a hook writes it.
27
+ app.decorateRequest("identity", undefined);
22
28
  const workspacePath = (slug) => join(deps.root, "workspaces", slug);
23
29
  const ctx = {
24
30
  ...deps,
@@ -98,32 +104,77 @@ export async function buildApp(deps) {
98
104
  app.addHook("onClose", async () => queue.close());
99
105
  const throttle = new LoginThrottle();
100
106
  app.post("/api/login", async (req, reply) => {
101
- const { password } = req.body;
102
- const hash = deps.config.server()?.password_hash;
103
- const waitMs = throttle.retryAfterMs();
107
+ const { name, password } = (req.body ?? {});
108
+ // A body with no name is the 0.2 login form, which knew only a password.
109
+ // It authenticates as `admin`, which is precisely the account a lone
110
+ // `server.password_hash` is loaded as — so an upgraded install keeps
111
+ // working, and the loader still owes nobody an answer about which form
112
+ // the file used.
113
+ const account = name ?? LEGACY_ADMIN_NAME;
114
+ const waitMs = throttle.retryAfterMs(account);
104
115
  if (waitMs > 0) {
105
116
  return reply
106
117
  .code(429)
107
118
  .header("retry-after", Math.ceil(waitMs / 1000))
108
119
  .send({ error: `Too many attempts. Try again in ${Math.ceil(waitMs / 1000)}s.` });
109
120
  }
110
- if (!password || !hash || !(await verifyPassword(password, hash))) {
111
- throttle.recordFailure();
112
- return reply.code(401).send({ error: "Incorrect password" });
121
+ const users = deps.config.server()?.users ?? [];
122
+ const identity = password ? await authenticate(users, account, password) : null;
123
+ if (!identity) {
124
+ throttle.recordFailure(account);
125
+ // One message for a wrong password and for a name that does not exist:
126
+ // telling them apart is telling a stranger which accounts are worth
127
+ // attacking.
128
+ return reply.code(401).send({ error: "Incorrect name or password" });
113
129
  }
114
- throttle.recordSuccess();
115
- const token = ctx.sessions.issue();
130
+ throttle.recordSuccess(account);
131
+ const token = ctx.sessions.issue(identity);
116
132
  return reply
117
133
  .setCookie(SESSION_COOKIE, token, { path: "/", httpOnly: true, sameSite: "lax" })
118
- .send({ ok: true });
134
+ .send({ ok: true, name: identity.name, role: identity.role });
119
135
  });
120
- // Every /api route except /api/login requires a session.
136
+ // Every /api route except /api/login requires a session, and the routes on
137
+ // the admin list require an admin. Both decided here, once: a permission
138
+ // expressed as an `if` inside a handler is one nobody finds during an audit.
121
139
  app.addHook("onRequest", async (req, reply) => {
122
- if (!req.url.startsWith("/api") || req.url === "/api/login")
140
+ if (!req.url.startsWith("/api") || req.url.split("?")[0] === "/api/login")
123
141
  return;
124
- if (!ctx.sessions.valid(req.cookies[SESSION_COOKIE])) {
142
+ const session = ctx.sessions.get(req.cookies[SESSION_COOKIE]);
143
+ // The session holds who someone was when they signed in; the configuration
144
+ // holds who they are. They part company whenever config.yml is edited —
145
+ // from `laneyard user add`, which is another process entirely, or by hand.
146
+ // So the account is looked up again on every request: an account that is
147
+ // gone has no session, and a demotion takes effect at once rather than at
148
+ // the next restart. It is one find over a handful of entries.
149
+ const account = session && ctx.config.server()?.users.find((u) => u.name === session.name);
150
+ if (!session || !account) {
125
151
  return reply.code(401).send({ error: "Session required" });
126
152
  }
153
+ const identity = { name: account.name, role: account.role };
154
+ req.identity = identity;
155
+ if (identity.role !== "admin" && requiresAdmin(req.method, req.url)) {
156
+ return reply.code(403).send({ error: "This action requires the admin role" });
157
+ }
158
+ });
159
+ /**
160
+ * Signing out ends this session and no other.
161
+ *
162
+ * The same person may be signed in on a laptop and on a phone; pressing sign
163
+ * out on one of them must not be an act on the other. Only removing the
164
+ * account ends every session it has, and that is a different action with a
165
+ * different name.
166
+ */
167
+ app.post("/api/logout", async (req, reply) => {
168
+ const token = req.cookies[SESSION_COOKIE];
169
+ if (token)
170
+ ctx.sessions.revoke(token);
171
+ return reply.clearCookie(SESSION_COOKIE, { path: "/" }).code(204).send();
172
+ });
173
+ app.get("/api/me", async (req) => {
174
+ // Non-null because the hook above rejected every request that has no
175
+ // identity before this handler could be reached.
176
+ const { name, role } = req.identity;
177
+ return { name, role };
127
178
  });
128
179
  ctx.sockets = await registerWebSocket(app, ctx);
129
180
  await registerProjectRoutes(app, ctx);
@@ -131,6 +182,7 @@ export async function buildApp(deps) {
131
182
  await registerSecretRoutes(app, ctx);
132
183
  await registerReadinessRoutes(app, ctx);
133
184
  await registerFastfileRoutes(app, ctx);
185
+ await registerUserRoutes(app, ctx);
134
186
  // Resolved from the module's location, not from the data folder:
135
187
  // `deps.root` is ~/.laneyard, the built SPA lives in the repository. Two
136
188
  // relative positions, depending on whether we're running on the sources
@@ -37,45 +37,113 @@ export async function verifyPassword(password, stored) {
37
37
  return false;
38
38
  }
39
39
  }
40
+ /**
41
+ * A hash no password matches, used to spend the same work on an unknown name.
42
+ *
43
+ * Built from random bytes rather than by hashing something: `hashPassword` is
44
+ * synchronous and would freeze the server for its duration. The shape is what
45
+ * matters — `verifyPassword` derives a 32-byte key with the same parameters and
46
+ * then fails the comparison, which is exactly what a wrong password costs.
47
+ */
48
+ const DECOY_HASH = `scrypt$${randomBytes(16).toString("hex")}$${randomBytes(32).toString("hex")}`;
49
+ /**
50
+ * Turns a name and a password into who that is, or into nothing.
51
+ *
52
+ * An unknown name is verified against a decoy hash instead of returning early.
53
+ * Without that, a refusal for a name that does not exist comes back in
54
+ * microseconds while a wrong password takes the thirty milliseconds scrypt
55
+ * costs — and the login form becomes a way to enumerate accounts before trying
56
+ * a single password against them.
57
+ */
58
+ export async function authenticate(users, name, password) {
59
+ const user = users.find((u) => u.name === name);
60
+ if (!user) {
61
+ await verifyPassword(password, DECOY_HASH);
62
+ return null;
63
+ }
64
+ if (!(await verifyPassword(password, user.password_hash)))
65
+ return null;
66
+ return { name: user.name, role: user.role };
67
+ }
40
68
  /** In-memory sessions: they don't survive a restart, and that's just fine. */
41
69
  export class SessionStore {
42
- tokens = new Set();
43
- issue() {
70
+ tokens = new Map();
71
+ issue(identity) {
44
72
  const token = randomBytes(32).toString("hex");
45
- this.tokens.add(token);
73
+ this.tokens.set(token, identity);
46
74
  return token;
47
75
  }
76
+ /** Who the token belongs to, or undefined if it belongs to nobody. */
77
+ get(token) {
78
+ return token === undefined ? undefined : this.tokens.get(token);
79
+ }
48
80
  valid(token) {
49
- return token !== undefined && this.tokens.has(token);
81
+ return this.get(token) !== undefined;
50
82
  }
51
83
  revoke(token) {
52
84
  this.tokens.delete(token);
53
85
  }
86
+ /**
87
+ * Drops every session belonging to a name.
88
+ *
89
+ * The identity is snapshotted when the session is issued, which is what makes
90
+ * a request cheap — but it also means removing an account or changing its role
91
+ * leaves the old answer live in whatever browsers already had it. Without this,
92
+ * "remove the account" and "revoke access" are two different things, while
93
+ * every interface that offers the first implies the second.
94
+ */
95
+ revokeAllFor(name) {
96
+ for (const [token, identity] of this.tokens) {
97
+ if (identity.name === name)
98
+ this.tokens.delete(token);
99
+ }
100
+ }
54
101
  }
55
102
  export const SESSION_COOKIE = "laneyard_session";
103
+ /** Past this many tracked names, the entries that no longer delay anyone go. */
104
+ const THROTTLE_PRUNE_ABOVE = 1_000;
56
105
  /**
57
- * Slows down repeated login attempts.
106
+ * Slows down repeated login attempts, one account at a time.
58
107
  *
59
108
  * Without this, a network neighbour could try passwords as fast as the
60
109
  * server responds. The delay grows with failures and resets to zero on a
61
110
  * success: the legitimate user who mistypes once doesn't feel it.
111
+ *
112
+ * Counted per name rather than globally: a single counter means anyone able to
113
+ * reach the login form can lock out every account on the server by hammering
114
+ * one name — a denial of service disguised as a security measure.
62
115
  */
63
116
  export class LoginThrottle {
64
- failures = 0;
65
- until = 0;
66
- /** Milliseconds left to wait, 0 if the way is clear. */
67
- retryAfterMs(now = Date.now()) {
68
- return Math.max(0, this.until - now);
117
+ perName = new Map();
118
+ /** Milliseconds left to wait for that name, 0 if the way is clear. */
119
+ retryAfterMs(name, now = Date.now()) {
120
+ return Math.max(0, (this.perName.get(name)?.until ?? 0) - now);
69
121
  }
70
- recordFailure(now = Date.now()) {
71
- this.failures += 1;
122
+ recordFailure(name, now = Date.now()) {
123
+ const state = this.perName.get(name) ?? { failures: 0, until: 0 };
124
+ state.failures += 1;
72
125
  // 0, 0, 0, then 1s, 2s, 4s… capped at one minute.
73
- if (this.failures > 3) {
74
- this.until = now + Math.min(60_000, 2 ** (this.failures - 4) * 1000);
126
+ if (state.failures > 3) {
127
+ state.until = now + Math.min(60_000, 2 ** (state.failures - 4) * 1000);
75
128
  }
129
+ this.perName.set(name, state);
130
+ // The key is whatever the caller sent, so an attacker chooses how many
131
+ // entries exist. Dropping those whose delay has run out bounds the map by
132
+ // how fast someone can be delayed, not by how many names they can invent.
133
+ if (this.perName.size > THROTTLE_PRUNE_ABOVE)
134
+ this.prune(now);
76
135
  }
77
- recordSuccess() {
78
- this.failures = 0;
79
- this.until = 0;
136
+ recordSuccess(name) {
137
+ this.perName.delete(name);
138
+ }
139
+ /** Number of names currently tracked. Exposed so the pruning can be tested. */
140
+ size() {
141
+ return this.perName.size;
142
+ }
143
+ prune(now) {
144
+ for (const [name, state] of this.perName) {
145
+ if (state.until <= now)
146
+ this.perName.delete(name);
147
+ }
80
148
  }
81
149
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Who can do what — the whole answer, in one file.
3
+ *
4
+ * A permission expressed as an `if` inside a handler is a permission nobody
5
+ * finds when it matters: during an audit, or after an incident. So there are no
6
+ * such `if`s. Every route that needs more than a session is named below, and the
7
+ * single hook in `app.ts` is the only thing that reads this.
8
+ *
9
+ * Two roles, and only two. `admin` may do everything; `builder` may start a
10
+ * build, watch it, cancel it and download what it produced — nothing that
11
+ * changes what a build does, and nothing that reveals a credential.
12
+ */
13
+ /**
14
+ * What each route needs. Everything absent from this list needs only a session.
15
+ *
16
+ * Reading the Fastfile is deliberately not here: a builder who can start a lane
17
+ * benefits from seeing what it does, and it contains no credential — anything
18
+ * that does is in the vault, which is.
19
+ */
20
+ export const REQUIRES_ADMIN = [
21
+ { method: "*", path: "/api/secrets" },
22
+ { method: "*", path: "/api/projects/:slug/secrets" },
23
+ { method: "PUT", path: "/api/projects/:slug/fastfile" },
24
+ { method: "POST", path: "/api/projects/:slug/commit" },
25
+ { method: "POST", path: "/api/projects/:slug/push" },
26
+ { method: "DELETE", path: "/api/projects/:slug" },
27
+ { method: "*", path: "/api/users" },
28
+ ];
29
+ /**
30
+ * `/api/secrets/APP_KEY?x=1` → `["api", "secrets", "APP_KEY"]`.
31
+ *
32
+ * Each segment is percent-decoded, because the router decodes before it matches
33
+ * and this must see the same path the router did. Comparing the raw text sent
34
+ * `GET /api/%73ecrets` straight past the admin list and into the vault: Fastify
35
+ * routed it to `/api/secrets`, and this function had been looking at `%73ecrets`.
36
+ */
37
+ function segments(url) {
38
+ return (url.split("?")[0] ?? "")
39
+ .split("/")
40
+ .filter((s) => s !== "")
41
+ .map((s) => {
42
+ try {
43
+ return decodeURIComponent(s);
44
+ }
45
+ catch {
46
+ // Malformed escapes reach no route either; left as-is rather than
47
+ // swallowed, so a segment is never silently emptied into a match.
48
+ return s;
49
+ }
50
+ });
51
+ }
52
+ /**
53
+ * Does this request need an admin?
54
+ *
55
+ * A pattern matches a request whose path *starts with* it, segment by segment,
56
+ * with `:name` standing for any one segment. The prefix is deliberate:
57
+ * `/api/secrets` means the vault, not one URL, and a table naming every key's
58
+ * route separately would be a table someone forgets to extend the next time a
59
+ * route is added under it. It only ever errs towards refusing, since every
60
+ * entry here is a restriction.
61
+ */
62
+ export function requiresAdmin(method, url) {
63
+ const parts = segments(url);
64
+ const verb = method.toUpperCase();
65
+ return REQUIRES_ADMIN.some((pattern) => {
66
+ if (pattern.method !== "*" && pattern.method.toUpperCase() !== verb)
67
+ return false;
68
+ const expected = segments(pattern.path);
69
+ if (parts.length < expected.length)
70
+ return false;
71
+ return expected.every((seg, i) => seg.startsWith(":") || seg === parts[i]);
72
+ });
73
+ }