laneyard 0.1.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.
Files changed (74) hide show
  1. package/README.md +238 -32
  2. package/dist/src/cli/detect.js +60 -16
  3. package/dist/src/cli/prompt.js +36 -0
  4. package/dist/src/cli/secret.js +133 -0
  5. package/dist/src/cli/setup.js +282 -0
  6. package/dist/src/cli/style.js +31 -0
  7. package/dist/src/cli/user.js +127 -0
  8. package/dist/src/config/accounts.js +163 -0
  9. package/dist/src/config/load.js +61 -1
  10. package/dist/src/config/schema.js +47 -4
  11. package/dist/src/config/store.js +10 -0
  12. package/dist/src/config/yaml.js +14 -0
  13. package/dist/src/db/cache.js +8 -8
  14. package/dist/src/db/open.js +15 -0
  15. package/dist/src/db/runs.js +51 -6
  16. package/dist/src/db/schema.sql +20 -2
  17. package/dist/src/db/secrets.js +64 -0
  18. package/dist/src/fastfile/store.js +53 -0
  19. package/dist/src/git/workspace.js +95 -5
  20. package/dist/src/heuristics/blocking-actions.js +65 -0
  21. package/dist/src/heuristics/platforms.js +77 -0
  22. package/dist/src/heuristics/readiness.js +336 -0
  23. package/dist/src/logs/redact.js +86 -0
  24. package/dist/src/main.js +79 -9
  25. package/dist/src/runner/orchestrate.js +60 -10
  26. package/dist/src/runner/pty.js +27 -15
  27. package/dist/src/runner/queue.js +114 -0
  28. package/dist/src/secrets/cipher.js +28 -0
  29. package/dist/src/secrets/key.js +40 -0
  30. package/dist/src/secrets/vault.js +68 -0
  31. package/dist/src/server/app.js +135 -13
  32. package/dist/src/server/auth.js +85 -17
  33. package/dist/src/server/permissions.js +73 -0
  34. package/dist/src/server/routes/fastfile.js +131 -0
  35. package/dist/src/server/routes/projects.js +50 -1
  36. package/dist/src/server/routes/readiness.js +102 -0
  37. package/dist/src/server/routes/runs.js +27 -45
  38. package/dist/src/server/routes/secrets.js +51 -0
  39. package/dist/src/server/routes/users.js +64 -0
  40. package/dist/src/sidecar/bridge.js +17 -1
  41. package/dist/src/sidecar/lanes.js +2 -2
  42. package/dist/src/sidecar/uses.js +40 -0
  43. package/dist/web/assets/Editor-DNFBA4gv.js +14 -0
  44. package/dist/web/assets/index-De6sxx6G.css +1 -0
  45. package/dist/web/assets/index-TWRQ1cJg.js +62 -0
  46. package/dist/web/index.html +2 -2
  47. package/package.json +13 -5
  48. package/ruby/introspect.rb +80 -0
  49. package/dist/tests/cli/add.test.js +0 -64
  50. package/dist/tests/cli/detect.test.js +0 -63
  51. package/dist/tests/config/load.test.js +0 -68
  52. package/dist/tests/config/resolve.test.js +0 -44
  53. package/dist/tests/config/store.test.js +0 -58
  54. package/dist/tests/db/runs.test.js +0 -54
  55. package/dist/tests/e2e/full-thread.test.js +0 -65
  56. package/dist/tests/fixtures/repos.js +0 -30
  57. package/dist/tests/git/workspace.test.js +0 -66
  58. package/dist/tests/heuristics/error-summary.test.js +0 -31
  59. package/dist/tests/logs/store.test.js +0 -38
  60. package/dist/tests/main.test.js +0 -27
  61. package/dist/tests/ruby/introspect.test.js +0 -59
  62. package/dist/tests/runner/artifacts.test.js +0 -49
  63. package/dist/tests/runner/live-steps.test.js +0 -35
  64. package/dist/tests/runner/orchestrate.test.js +0 -124
  65. package/dist/tests/runner/pty.test.js +0 -53
  66. package/dist/tests/runner/report.test.js +0 -91
  67. package/dist/tests/server/api.test.js +0 -132
  68. package/dist/tests/server/auth.test.js +0 -53
  69. package/dist/tests/server/ws.test.js +0 -43
  70. package/dist/tests/sidecar/lanes.test.js +0 -52
  71. package/dist/tests/sidecar/ruby-env.test.js +0 -25
  72. package/dist/tests/smoke.test.js +0 -7
  73. package/dist/web/assets/index-583x0xuo.css +0 -1
  74. package/dist/web/assets/index-BEpABKPS.js +0 -61
@@ -2,6 +2,7 @@ import { rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { Workspace } from "../git/workspace.js";
4
4
  import { summarizeFailure } from "../heuristics/error-summary.js";
5
+ import { Redactor, scrub } from "../logs/redact.js";
5
6
  import { collectArtifacts } from "./artifacts.js";
6
7
  import { LiveStepTracker } from "./live-steps.js";
7
8
  import { startPty } from "./pty.js";
@@ -17,19 +18,47 @@ export async function executeRun(opts) {
17
18
  const { runId, runs, logs } = opts;
18
19
  const writer = await logs.open(runId);
19
20
  const tracker = new LiveStepTracker();
21
+ const redactor = new Redactor(opts.maskedValues ?? []);
22
+ // Redaction happens here and nowhere else: this is the single point through
23
+ // which every byte of output passes on its way to the file, the step tracker
24
+ // and the browser. Filtering further downstream would mean filtering three
25
+ // times, and forgetting one of them eventually.
20
26
  const emit = async (text) => {
21
- const offset = await writer.append(text);
22
- tracker.consume(text, offset);
23
- opts.onChunk(text, offset);
27
+ const safe = redactor.push(text);
28
+ if (safe === "")
29
+ return;
30
+ const offset = await writer.append(safe);
31
+ tracker.consume(safe, offset);
32
+ opts.onChunk(safe, offset);
24
33
  };
25
- const fail = async (message) => {
34
+ const emitRest = async () => {
35
+ const rest = redactor.flush();
36
+ if (rest === "")
37
+ return;
38
+ const offset = await writer.append(rest);
39
+ tracker.consume(rest, offset);
40
+ opts.onChunk(rest, offset);
41
+ };
42
+ // The error summary is stored in the database and rendered in the interface
43
+ // without passing through the stream above, so it needs its own, one-shot pass.
44
+ const hide = (text) => scrub(text, opts.maskedValues ?? []);
45
+ // Shared by every early exit, so flushing and closing the log writer cannot
46
+ // drift between the failure path and the two cancellation checkpoints below.
47
+ const finishAs = async (status, message) => {
26
48
  await emit(`\n${message}\n`);
49
+ await emitRest();
27
50
  await writer.close();
28
- runs.finish(runId, { status: "failed", exitCode: null, errorSummary: message });
29
- return { status: "failed" };
51
+ runs.finish(runId, { status, exitCode: null, errorSummary: hide(message) });
52
+ return { status };
30
53
  };
54
+ const fail = (message) => finishAs("failed", message);
31
55
  // --- Preparation --------------------------------------------------------
32
56
  runs.setStatus(runId, "preparing");
57
+ // Cancelled before it even started: no point cloning or fetching a
58
+ // workspace nobody wants built anymore.
59
+ if (opts.signal?.aborted) {
60
+ return finishAs("cancelled", "Cancelled");
61
+ }
33
62
  const workspace = new Workspace(opts.workspacePath, opts.gitUrl, opts.gitAuth);
34
63
  let commitSha;
35
64
  try {
@@ -50,6 +79,10 @@ export async function executeRun(opts) {
50
79
  catch (cause) {
51
80
  return fail(`Unreadable project settings: ${cause.message}`);
52
81
  }
82
+ // Cancelled during preparation: fastlane never gets to start.
83
+ if (opts.signal?.aborted) {
84
+ return finishAs("cancelled", "Cancelled");
85
+ }
53
86
  // --- Execution -----------------------------------------------------------
54
87
  const useBundle = settings.runtime === "bundle";
55
88
  const reportPath = join(opts.workspacePath, settings.fastlane_dir, "report.xml");
@@ -65,16 +98,20 @@ export async function executeRun(opts) {
65
98
  cwd: opts.workspacePath,
66
99
  env: {
67
100
  ...opts.env,
68
- // A non-interactive run fails fast instead of freezing on an invisible prompt.
101
+ ...(opts.secrets ?? {}),
102
+ // Order matters: secrets come after opts.env so a stored secret wins over
103
+ // a variable that happens to exist in the server's own environment, and
104
+ // before these three fixed variables so no secret can override CI.
69
105
  CI: "true",
70
106
  FASTLANE_SKIP_UPDATE_CHECK: "1",
71
107
  FORCE_COLOR: "1",
72
108
  },
73
109
  onData: (chunk) => void emit(chunk),
74
110
  timeoutMs: settings.timeout_minutes * 60_000,
111
+ signal: opts.signal,
75
112
  });
76
113
  const outcome = await done;
77
- await writer.close();
114
+ await emitRest();
78
115
  // --- Timeline -------------------------------------------------------------
79
116
  // Everything that follows is after-sales service: the timeline and the
80
117
  // artifacts embellish a run that's already finished. A database that
@@ -86,14 +123,27 @@ export async function executeRun(opts) {
86
123
  }
87
124
  catch (cause) {
88
125
  await emit(`\nIncomplete timeline or artifacts: ${cause.message}\n`);
126
+ await emitRest();
89
127
  }
128
+ await writer.close();
90
129
  if (outcome.exitCode === 0 && !outcome.timedOut) {
91
130
  runs.finish(runId, { status: "success", exitCode: 0, errorSummary: null });
92
131
  return { status: "success" };
93
132
  }
133
+ if (opts.signal?.aborted) {
134
+ // Checked ahead of the failure case: the SIGINT/SIGKILL escalation leaves
135
+ // fastlane with a nonzero exit code, which would otherwise read as a crash
136
+ // rather than the cancellation it actually was.
137
+ runs.finish(runId, {
138
+ status: "cancelled",
139
+ exitCode: outcome.exitCode,
140
+ errorSummary: "Cancelled",
141
+ });
142
+ return { status: "cancelled" };
143
+ }
94
144
  const summary = outcome.timedOut
95
- ? `Run interrupted after ${settings.timeout_minutes} minutes`
96
- : summarizeFailure(await logs.read(runId), outcome.exitCode);
145
+ ? hide(`Run interrupted after ${settings.timeout_minutes} minutes`)
146
+ : hide(summarizeFailure(await logs.read(runId), outcome.exitCode));
97
147
  runs.finish(runId, { status: "failed", exitCode: outcome.exitCode, errorSummary: summary });
98
148
  return { status: "failed" };
99
149
  async function recordOutcome() {
@@ -28,30 +28,42 @@ export function startPty(opts) {
28
28
  let timedOut = false;
29
29
  let timer;
30
30
  proc.onData(opts.onData);
31
+ /**
32
+ * SIGINT first so fastlane can clean up after itself; SIGKILL five seconds
33
+ * later if it will not. One path, so cancelling and timing out cannot drift
34
+ * apart — and so neither can leave the single worker blocked.
35
+ */
36
+ const stop = () => {
37
+ try {
38
+ proc.kill("SIGINT");
39
+ }
40
+ catch {
41
+ /* already gone */
42
+ }
43
+ setTimeout(() => {
44
+ try {
45
+ proc.kill("SIGKILL");
46
+ }
47
+ catch {
48
+ /* already gone */
49
+ }
50
+ }, 5000);
51
+ };
52
+ opts.signal?.addEventListener("abort", stop, { once: true });
31
53
  const done = new Promise((resolve) => {
32
54
  if (opts.timeoutMs !== undefined) {
33
55
  timer = setTimeout(() => {
34
56
  timedOut = true;
35
- // SIGINT first: fastlane cleans up after itself. SIGKILL if it keeps stalling.
36
- try {
37
- proc.kill("SIGINT");
38
- }
39
- catch {
40
- /* the process may have died in the meantime */
41
- }
42
- setTimeout(() => {
43
- try {
44
- proc.kill("SIGKILL");
45
- }
46
- catch {
47
- /* same */
48
- }
49
- }, 5000);
57
+ stop();
50
58
  }, opts.timeoutMs);
51
59
  }
52
60
  proc.onExit(({ exitCode, signal }) => {
53
61
  if (timer)
54
62
  clearTimeout(timer);
63
+ // The listener would otherwise accumulate on every run sharing a
64
+ // long-lived signal (the queue's per-run AbortController is not one,
65
+ // but nothing here should depend on that).
66
+ opts.signal?.removeEventListener("abort", stop);
55
67
  // `waitpid` only reports an exit code for a normal end: a process
56
68
  // killed by a signal leaves 0, which would pass a cancellation off
57
69
  // as a success. We apply the shell convention, 128 + signal, so an
@@ -0,0 +1,114 @@
1
+ /** Statuses from which a run never moves again. */
2
+ const TERMINAL = ["success", "failed", "cancelled", "interrupted"];
3
+ /**
4
+ * Drains queued runs, one at a time.
5
+ *
6
+ * The queue is not held in memory: a run's `queued` status in the database *is*
7
+ * its place in line. That is what lets the server restart mid-queue and carry
8
+ * on, and what makes the position shown in the interface the truth rather than
9
+ * a second copy of it.
10
+ *
11
+ * One at a time, globally. A mobile build saturates the machine it runs on, and
12
+ * two at once are frequently slower than two in sequence.
13
+ */
14
+ export class RunQueue {
15
+ runs;
16
+ job;
17
+ draining = false;
18
+ pending = false;
19
+ closed = false;
20
+ idlePromise = Promise.resolve();
21
+ running = new Map();
22
+ constructor(runs, job) {
23
+ this.runs = runs;
24
+ this.job = job;
25
+ }
26
+ /** Tells the queue there may be work. Safe to call at any time, from anywhere. */
27
+ wake() {
28
+ if (this.closed)
29
+ return;
30
+ if (this.draining) {
31
+ // A call arriving mid-drain is remembered rather than dropped: the drain
32
+ // may already have read an empty queue and be on its way out.
33
+ this.pending = true;
34
+ return;
35
+ }
36
+ this.draining = true;
37
+ this.idlePromise = this.drainUntilQuiet().finally(() => {
38
+ this.draining = false;
39
+ });
40
+ }
41
+ async drainUntilQuiet() {
42
+ do {
43
+ this.pending = false;
44
+ await this.drain();
45
+ } while (this.pending && !this.closed);
46
+ }
47
+ /** Resolves when the queue has nothing left to do. For tests and shutdown. */
48
+ async idle() {
49
+ await this.idlePromise;
50
+ }
51
+ async drain() {
52
+ for (;;) {
53
+ if (this.closed)
54
+ return;
55
+ // One at a time: if something is already in flight — including a run this
56
+ // process did not start — the queue waits rather than doubling up.
57
+ if (this.runs.activeCount() > 0)
58
+ return;
59
+ const next = this.runs.queued()[0];
60
+ if (!next)
61
+ return;
62
+ const controller = new AbortController();
63
+ this.running.set(next.id, controller);
64
+ try {
65
+ await this.job(next.id, controller.signal);
66
+ }
67
+ catch {
68
+ // A job that throws must not stall every run behind it. `executeRun`
69
+ // promises not to, but the queue cannot afford to depend on that.
70
+ }
71
+ finally {
72
+ this.running.delete(next.id);
73
+ // Whatever happened, the run must not still be waiting or in flight.
74
+ //
75
+ // Two failures hide here, and both are worse than a wrong status. If the
76
+ // job returns without moving the run out of `queued` — a project deleted
77
+ // from config.yml while its run waited, say — the next iteration reads
78
+ // the same row and calls the job again, forever. And a job that throws
79
+ // after `executeRun` reached `preparing` leaves a run with no end, which
80
+ // the interface polls until someone gives up.
81
+ const after = this.runs.get(next.id);
82
+ if (after && !TERMINAL.includes(after.status)) {
83
+ this.runs.finish(next.id, {
84
+ status: "failed",
85
+ exitCode: null,
86
+ errorSummary: "The run ended unexpectedly",
87
+ });
88
+ }
89
+ }
90
+ }
91
+ }
92
+ /**
93
+ * Cancels a run whether it has started or not.
94
+ *
95
+ * A run still waiting is finished on the spot: there is nothing to signal, and
96
+ * making someone wait for a build they have cancelled would be absurd.
97
+ */
98
+ cancel(runId) {
99
+ const active = this.running.get(runId);
100
+ if (active) {
101
+ active.abort();
102
+ return true;
103
+ }
104
+ const run = this.runs.get(runId);
105
+ if (run?.status !== "queued")
106
+ return false;
107
+ this.runs.finish(runId, { status: "cancelled", exitCode: null, errorSummary: "Cancelled before it started" });
108
+ return true;
109
+ }
110
+ /** Stops taking new work; the run in flight is left to finish. */
111
+ close() {
112
+ this.closed = true;
113
+ }
114
+ }
@@ -0,0 +1,28 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
+ /**
3
+ * AES-256-GCM, from Node's standard library — no dependency to audit.
4
+ *
5
+ * GCM is authenticated: a modified ciphertext fails to decrypt rather than
6
+ * producing plausible rubbish, which is what you want for a value that will be
7
+ * handed to a build as a password.
8
+ *
9
+ * Payload format: `v1.<iv>.<tag>.<ciphertext>`, each part base64. The version
10
+ * prefix exists so the format can be changed without guessing what old rows are.
11
+ */
12
+ const VERSION = "v1";
13
+ const IV_BYTES = 12; // 96 bits, the size GCM is specified for.
14
+ export function encrypt(plaintext, key) {
15
+ const iv = randomBytes(IV_BYTES);
16
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
17
+ const body = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
18
+ return [VERSION, iv.toString("base64"), cipher.getAuthTag().toString("base64"), body.toString("base64")].join(".");
19
+ }
20
+ export function decrypt(payload, key) {
21
+ const [version, ivB64, tagB64, bodyB64] = payload.split(".");
22
+ if (version !== VERSION || !ivB64 || !tagB64 || bodyB64 === undefined) {
23
+ throw new Error("Unrecognised secret format");
24
+ }
25
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64"));
26
+ decipher.setAuthTag(Buffer.from(tagB64, "base64"));
27
+ return Buffer.concat([decipher.update(Buffer.from(bodyB64, "base64")), decipher.final()]).toString("utf8");
28
+ }
@@ -0,0 +1,40 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ const KEY_BYTES = 32;
5
+ /**
6
+ * Reads the vault key, creating it on first use.
7
+ *
8
+ * The key lives beside the database but never inside it: someone who walks off
9
+ * with `laneyard.db` gets ciphertext and nothing else.
10
+ *
11
+ * A key another user can read is treated as an error rather than a warning —
12
+ * the same stance `ssh` takes on private keys, and for the same reason: silently
13
+ * carrying on would make the encryption decorative.
14
+ */
15
+ export async function loadOrCreateKey(home) {
16
+ const path = join(home, "key");
17
+ try {
18
+ const info = await stat(path);
19
+ if ((info.mode & 0o077) !== 0) {
20
+ throw new Error(`Vault key ${path} is readable by other users. Run \`chmod 600 ${path}\` and start again.`);
21
+ }
22
+ const key = await readFile(path);
23
+ if (key.byteLength !== KEY_BYTES) {
24
+ throw new Error(`Vault key ${path} is ${key.byteLength} bytes, expected ${KEY_BYTES}. ` +
25
+ "Refusing to guess: move it aside and Laneyard will create a new one, " +
26
+ "but every stored secret will have to be entered again.");
27
+ }
28
+ return key;
29
+ }
30
+ catch (cause) {
31
+ if (cause.code !== "ENOENT")
32
+ throw cause;
33
+ }
34
+ await mkdir(home, { recursive: true });
35
+ const key = randomBytes(KEY_BYTES);
36
+ // The mode is set at creation, not after: a `chmod` afterwards leaves a window
37
+ // during which the key exists and is world-readable.
38
+ await writeFile(path, key, { mode: 0o600 });
39
+ return key;
40
+ }
@@ -0,0 +1,68 @@
1
+ import { scrub } from "../logs/redact.js";
2
+ import { decrypt, encrypt } from "./cipher.js";
3
+ import { loadOrCreateKey } from "./key.js";
4
+ /**
5
+ * The only component that ever holds a decrypted secret.
6
+ *
7
+ * Everything else — the store, the API, the interface — deals in names and
8
+ * ciphertext. Keeping plaintext to one small file is what makes "a secret never
9
+ * reaches a log" a claim you can check by reading, rather than a hope.
10
+ */
11
+ export class Vault {
12
+ key;
13
+ store;
14
+ constructor(key, store) {
15
+ this.key = key;
16
+ this.store = store;
17
+ }
18
+ static async open(home, store) {
19
+ return new Vault(await loadOrCreateKey(home), store);
20
+ }
21
+ async set(projectSlug, key, value, masked) {
22
+ this.store.set(projectSlug, key, encrypt(value, this.key), masked);
23
+ }
24
+ remove(projectSlug, key) {
25
+ return this.store.remove(projectSlug, key);
26
+ }
27
+ list(projectSlug) {
28
+ return this.store.list(projectSlug);
29
+ }
30
+ listGlobal() {
31
+ return this.store.listGlobal();
32
+ }
33
+ /**
34
+ * Removes this project's secret values from a piece of text, in one shot.
35
+ *
36
+ * Separate from `Redactor`, which is stateful and belongs to a live stream:
37
+ * reusing that instance here would corrupt its buffer mid-run.
38
+ */
39
+ scrub(projectSlug, text) {
40
+ return scrub(text, this.maskedValues(projectSlug));
41
+ }
42
+ /**
43
+ * Every secret that applies to a project, ready to become environment variables.
44
+ *
45
+ * A row that will not decrypt is skipped rather than thrown: a key that was
46
+ * rotated or a corrupted row should cost one variable, not the whole build.
47
+ * The run then fails on its own terms, with fastlane saying what was missing.
48
+ */
49
+ resolve(projectSlug) {
50
+ const out = {};
51
+ for (const [key, payload] of Object.entries(this.store.encrypted(projectSlug))) {
52
+ try {
53
+ out[key] = decrypt(payload, this.key);
54
+ }
55
+ catch {
56
+ // Deliberately silent here; the interface reports unreadable secrets.
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ /** The values a run's output must not contain. */
62
+ maskedValues(projectSlug) {
63
+ const masked = this.store.maskedKeys(projectSlug);
64
+ return Object.entries(this.resolve(projectSlug))
65
+ .filter(([key]) => masked.has(key))
66
+ .map(([, value]) => value);
67
+ }
68
+ }
@@ -4,16 +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
- import { LoginThrottle, SESSION_COOKIE, SessionStore, verifyPassword } from "./auth.js";
11
+ import { executeRun } from "../runner/orchestrate.js";
12
+ import { RunQueue } from "../runner/queue.js";
13
+ import { authenticate, LoginThrottle, SESSION_COOKIE, SessionStore } from "./auth.js";
14
+ import { requiresAdmin } from "./permissions.js";
15
+ import { registerFastfileRoutes } from "./routes/fastfile.js";
11
16
  import { registerProjectRoutes } from "./routes/projects.js";
17
+ import { registerReadinessRoutes } from "./routes/readiness.js";
12
18
  import { registerRunRoutes } from "./routes/runs.js";
19
+ import { registerSecretRoutes } from "./routes/secrets.js";
20
+ import { registerUserRoutes } from "./routes/users.js";
13
21
  import { registerWebSocket } from "./ws.js";
14
22
  export async function buildApp(deps) {
15
23
  const app = Fastify({ logger: false });
16
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);
17
28
  const workspacePath = (slug) => join(deps.root, "workspaces", slug);
18
29
  const ctx = {
19
30
  ...deps,
@@ -29,38 +40,149 @@ export async function buildApp(deps) {
29
40
  await new Workspace(workspacePath(slug), entry.git_url, entry.git_auth).ensureCloned();
30
41
  },
31
42
  };
43
+ // The queue is assembled here rather than in `createServerFromConfig`: its job
44
+ // needs `runs`, `logs`, the workspace and artifact paths and the sockets, none
45
+ // of which exist before `ctx` does. It is assigned after the context literal
46
+ // because the job it drives closes over that very context — hence the field
47
+ // being optional in the type rather than a cast pretending it is already set.
48
+ ctx.queue = new RunQueue(ctx.runs, async (runId, signal) => {
49
+ const run = ctx.runs.get(runId);
50
+ if (!run)
51
+ return;
52
+ const slug = run.projectSlug;
53
+ const entry = deps.config.project(slug);
54
+ if (!entry) {
55
+ // The project was removed from config.yml while this run waited. Ending it
56
+ // here is what keeps the queue from re-reading the same row for ever.
57
+ ctx.runs.finish(runId, {
58
+ status: "failed",
59
+ exitCode: null,
60
+ errorSummary: `Project "${slug}" is no longer in the configuration`,
61
+ });
62
+ return;
63
+ }
64
+ await executeRun({
65
+ runId,
66
+ runs: ctx.runs,
67
+ logs: ctx.logs,
68
+ workspacePath: ctx.workspacePath(slug),
69
+ artifactsDir: ctx.artifactsDir(runId),
70
+ gitUrl: entry.git_url,
71
+ gitAuth: entry.git_auth,
72
+ branch: entry.default_branch,
73
+ // Resolved after the clone, once the repository's laneyard.yml is finally readable.
74
+ resolveSettings: async () => {
75
+ const r = await ctx.config.resolve(slug, ctx.workspacePath(slug));
76
+ return r.settings;
77
+ },
78
+ env: process.env,
79
+ secrets: ctx.vault.resolve(slug),
80
+ maskedValues: ctx.vault.maskedValues(slug),
81
+ signal,
82
+ onChunk: (chunk, offset) => app.broadcastRunChunk?.(runId, chunk, offset),
83
+ })
84
+ .then((r) => ctx.sockets?.finish(runId, r.status))
85
+ .catch((cause) => {
86
+ // Last safety net. `executeRun` commits to never throwing, but the queue
87
+ // cannot afford to depend on that: a run left neither finished nor
88
+ // failed is a row the interface polls until someone gives up.
89
+ ctx.runs.finish(runId, {
90
+ status: "failed",
91
+ exitCode: null,
92
+ errorSummary: ctx.vault.scrub(slug, `Unexpected failure: ${cause.message}`),
93
+ });
94
+ ctx.sockets?.finish(runId, "failed");
95
+ });
96
+ });
97
+ // `ctx.queue` is assigned immediately above; naming it here rather than
98
+ // reaching through the optional field is what lets every route treat the
99
+ // queue as simply present.
100
+ const queue = ctx.queue;
101
+ app.decorate("queue", queue);
102
+ // A closed server must stop taking new work: the run in flight is left to
103
+ // finish, but nothing behind it starts on a server nobody is listening to.
104
+ app.addHook("onClose", async () => queue.close());
32
105
  const throttle = new LoginThrottle();
33
106
  app.post("/api/login", async (req, reply) => {
34
- const { password } = req.body;
35
- const hash = deps.config.server()?.password_hash;
36
- 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);
37
115
  if (waitMs > 0) {
38
116
  return reply
39
117
  .code(429)
40
118
  .header("retry-after", Math.ceil(waitMs / 1000))
41
119
  .send({ error: `Too many attempts. Try again in ${Math.ceil(waitMs / 1000)}s.` });
42
120
  }
43
- if (!password || !hash || !(await verifyPassword(password, hash))) {
44
- throttle.recordFailure();
45
- 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" });
46
129
  }
47
- throttle.recordSuccess();
48
- const token = ctx.sessions.issue();
130
+ throttle.recordSuccess(account);
131
+ const token = ctx.sessions.issue(identity);
49
132
  return reply
50
133
  .setCookie(SESSION_COOKIE, token, { path: "/", httpOnly: true, sameSite: "lax" })
51
- .send({ ok: true });
134
+ .send({ ok: true, name: identity.name, role: identity.role });
52
135
  });
53
- // 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.
54
139
  app.addHook("onRequest", async (req, reply) => {
55
- if (!req.url.startsWith("/api") || req.url === "/api/login")
140
+ if (!req.url.startsWith("/api") || req.url.split("?")[0] === "/api/login")
56
141
  return;
57
- 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) {
58
151
  return reply.code(401).send({ error: "Session required" });
59
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 };
60
178
  });
61
179
  ctx.sockets = await registerWebSocket(app, ctx);
62
180
  await registerProjectRoutes(app, ctx);
63
181
  await registerRunRoutes(app, ctx);
182
+ await registerSecretRoutes(app, ctx);
183
+ await registerReadinessRoutes(app, ctx);
184
+ await registerFastfileRoutes(app, ctx);
185
+ await registerUserRoutes(app, ctx);
64
186
  // Resolved from the module's location, not from the data folder:
65
187
  // `deps.root` is ~/.laneyard, the built SPA lives in the repository. Two
66
188
  // relative positions, depending on whether we're running on the sources