laneyard 0.2.0 → 0.4.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 (53) hide show
  1. package/README.md +295 -20
  2. package/dist/src/cli/detect.js +69 -16
  3. package/dist/src/cli/prompt.js +61 -0
  4. package/dist/src/cli/secret-import.js +162 -0
  5. package/dist/src/cli/secret.js +162 -1
  6. package/dist/src/cli/setup.js +319 -0
  7. package/dist/src/cli/style.js +31 -0
  8. package/dist/src/cli/user.js +127 -0
  9. package/dist/src/config/accounts.js +163 -0
  10. package/dist/src/config/load.js +61 -1
  11. package/dist/src/config/schema.js +26 -1
  12. package/dist/src/config/store.js +10 -0
  13. package/dist/src/config/yaml.js +14 -0
  14. package/dist/src/credentials/kinds.js +120 -0
  15. package/dist/src/db/credentials.js +75 -0
  16. package/dist/src/db/schema.sql +39 -0
  17. package/dist/src/db/secrets.js +28 -0
  18. package/dist/src/db/sessions.js +61 -0
  19. package/dist/src/git/workspace.js +32 -6
  20. package/dist/src/heuristics/android-root.js +49 -0
  21. package/dist/src/heuristics/android-signing.js +98 -0
  22. package/dist/src/heuristics/appfile.js +60 -0
  23. package/dist/src/heuristics/blocking-actions.js +22 -0
  24. package/dist/src/heuristics/env-example.js +36 -0
  25. package/dist/src/heuristics/platforms.js +136 -0
  26. package/dist/src/heuristics/readiness.js +697 -25
  27. package/dist/src/main.js +58 -8
  28. package/dist/src/runner/gradle-properties.js +187 -0
  29. package/dist/src/runner/materialise.js +92 -0
  30. package/dist/src/runner/orchestrate.js +91 -2
  31. package/dist/src/secrets/vault.js +106 -5
  32. package/dist/src/server/app.js +94 -15
  33. package/dist/src/server/auth.js +127 -19
  34. package/dist/src/server/permissions.js +75 -0
  35. package/dist/src/server/required-secrets.js +30 -0
  36. package/dist/src/server/routes/account.js +57 -0
  37. package/dist/src/server/routes/credentials.js +108 -0
  38. package/dist/src/server/routes/projects.js +42 -0
  39. package/dist/src/server/routes/readiness.js +175 -6
  40. package/dist/src/server/routes/secrets.js +101 -0
  41. package/dist/src/server/routes/users.js +64 -0
  42. package/dist/src/sidecar/bridge.js +37 -1
  43. package/dist/src/sidecar/fastlane-dir.js +23 -0
  44. package/dist/src/sidecar/lanes.js +6 -0
  45. package/dist/src/sidecar/uses.js +21 -5
  46. package/dist/web/assets/{Editor-D5Q4uj4n.js → Editor-DynuBC2l.js} +1 -1
  47. package/dist/web/assets/index-CpwrNE-K.css +1 -0
  48. package/dist/web/assets/index-lyZs-Y7J.js +62 -0
  49. package/dist/web/index.html +2 -2
  50. package/package.json +1 -1
  51. package/ruby/introspect.rb +122 -9
  52. package/dist/web/assets/index-D9_EL8LA.js +0 -62
  53. package/dist/web/assets/index-ZUqTX6d1.css +0 -1
@@ -1,3 +1,4 @@
1
+ import { fieldsOf } from "../credentials/kinds.js";
1
2
  import { scrub } from "../logs/redact.js";
2
3
  import { decrypt, encrypt } from "./cipher.js";
3
4
  import { loadOrCreateKey } from "./key.js";
@@ -11,12 +12,14 @@ import { loadOrCreateKey } from "./key.js";
11
12
  export class Vault {
12
13
  key;
13
14
  store;
14
- constructor(key, store) {
15
+ credentials;
16
+ constructor(key, store, credentials) {
15
17
  this.key = key;
16
18
  this.store = store;
19
+ this.credentials = credentials;
17
20
  }
18
- static async open(home, store) {
19
- return new Vault(await loadOrCreateKey(home), store);
21
+ static async open(home, store, credentials) {
22
+ return new Vault(await loadOrCreateKey(home), store, credentials);
20
23
  }
21
24
  async set(projectSlug, key, value, masked) {
22
25
  this.store.set(projectSlug, key, encrypt(value, this.key), masked);
@@ -58,11 +61,109 @@ export class Vault {
58
61
  }
59
62
  return out;
60
63
  }
61
- /** The values a run's output must not contain. */
64
+ /**
65
+ * One value, in the clear — and only when it was never declared secret.
66
+ *
67
+ * The refusal is the point. This vault has been write-only since it was
68
+ * written: the server never sent a value back, so the interface had nothing to
69
+ * uncover and no browser ever held one. That is worth keeping for anything
70
+ * anyone called a secret.
71
+ *
72
+ * But not everything stored here is one. `APP_VERSION`, `SENTRY_ORG`, an
73
+ * issuer id — those are identifiers, and being unable to check what was stored
74
+ * makes an import something you have to take on faith. The line between the
75
+ * two already existed and is the user's own: `masked` is "keep this out of the
76
+ * logs". A value that carries it is never returned, whoever asks.
77
+ *
78
+ * Returns null for an unknown key, and throws for a masked one — a caller
79
+ * that forgot to check must fail loudly rather than leak.
80
+ */
81
+ reveal(projectSlug, key) {
82
+ const row = this.store.find(projectSlug, key);
83
+ if (!row)
84
+ return null;
85
+ if (row.masked) {
86
+ throw new Error(`${key} is kept out of the logs, so its value is never sent back.`);
87
+ }
88
+ return decrypt(row.valueEnc, this.key);
89
+ }
90
+ /** Flips whether a value is kept out of the logs, leaving the value alone. */
91
+ setMasked(projectSlug, key, masked) {
92
+ return this.store.setMasked(projectSlug, key, masked);
93
+ }
94
+ /**
95
+ * Stores a signing block: the file, and the fields that make it usable.
96
+ *
97
+ * `cipher.ts` speaks strings and a `.jks` is bytes, so the file makes the trip
98
+ * as base64. The fields travel as one JSON object rather than one row each,
99
+ * because a keystore missing its alias is not a block with a gap in it — it is
100
+ * not a block.
101
+ */
102
+ async setCredential(projectSlug, kind, block) {
103
+ this.credentials.set(projectSlug, kind, {
104
+ fileName: block.fileName,
105
+ fileEnc: encrypt(block.fileBytes.toString("base64"), this.key),
106
+ fieldsEnc: encrypt(JSON.stringify(block.fields), this.key),
107
+ varNames: block.varNames,
108
+ });
109
+ }
110
+ /**
111
+ * One block that applies to a project, in the clear, or undefined if there is none.
112
+ *
113
+ * Unlike `resolve`, an unreadable row throws. The leniency there is earned: a
114
+ * missing variable makes fastlane stop and say which one. A block that quietly
115
+ * fails to decrypt costs a debug-signed artifact that builds, uploads, and is
116
+ * rejected by the store days later — by which point nobody is looking at this
117
+ * run's log. The kind is in the message because that is the part you need to
118
+ * know before you can act.
119
+ */
120
+ resolveCredential(projectSlug, kind) {
121
+ const row = this.credentials.find(projectSlug, kind);
122
+ if (!row)
123
+ return undefined;
124
+ try {
125
+ return {
126
+ fileName: row.fileName,
127
+ fileBytes: Buffer.from(decrypt(row.fileEnc, this.key), "base64"),
128
+ fields: JSON.parse(decrypt(row.fieldsEnc, this.key)),
129
+ varNames: row.varNames,
130
+ };
131
+ }
132
+ catch {
133
+ throw new Error(`The stored ${kind} block cannot be decrypted. Its encryption key changed or the row is damaged; upload the credential again.`);
134
+ }
135
+ }
136
+ listCredentials(projectSlug) {
137
+ return this.credentials.list(projectSlug);
138
+ }
139
+ listGlobalCredentials() {
140
+ return this.credentials.listGlobal();
141
+ }
142
+ removeCredential(projectSlug, kind) {
143
+ return this.credentials.remove(projectSlug, kind);
144
+ }
145
+ /**
146
+ * The values a run's output must not contain.
147
+ *
148
+ * A block's secret fields belong here as much as a masked secret does: a
149
+ * keystore password reaches the build as an environment variable, and gradle
150
+ * is perfectly willing to echo one back on failure.
151
+ */
62
152
  maskedValues(projectSlug) {
63
153
  const masked = this.store.maskedKeys(projectSlug);
64
- return Object.entries(this.resolve(projectSlug))
154
+ const values = Object.entries(this.resolve(projectSlug))
65
155
  .filter(([key]) => masked.has(key))
66
156
  .map(([, value]) => value);
157
+ for (const summary of this.credentials.list(projectSlug)) {
158
+ const block = this.resolveCredential(projectSlug, summary.kind);
159
+ if (!block)
160
+ continue;
161
+ for (const field of fieldsOf(summary.kind)) {
162
+ const value = block.fields[field.name];
163
+ if (field.secret && value)
164
+ values.push(value);
165
+ }
166
+ }
167
+ return values;
67
168
  }
68
169
  }
@@ -4,29 +4,40 @@ 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";
9
+ import { SessionRecords } from "../db/sessions.js";
8
10
  import { Workspace } from "../git/workspace.js";
9
11
  import { LogStore } from "../logs/store.js";
12
+ import { materialiseCredentials } from "../runner/materialise.js";
10
13
  import { executeRun } from "../runner/orchestrate.js";
11
14
  import { RunQueue } from "../runner/queue.js";
12
- import { LoginThrottle, SESSION_COOKIE, SessionStore, verifyPassword } from "./auth.js";
15
+ import { authenticate, LoginThrottle, COOKIE_OPTIONS, SESSION_COOKIE, SessionStore } from "./auth.js";
16
+ import { requiresAdmin } from "./permissions.js";
17
+ import { registerCredentialRoutes } from "./routes/credentials.js";
13
18
  import { registerFastfileRoutes } from "./routes/fastfile.js";
14
19
  import { registerProjectRoutes } from "./routes/projects.js";
20
+ import { registerAccountRoutes } from "./routes/account.js";
15
21
  import { registerReadinessRoutes } from "./routes/readiness.js";
16
22
  import { registerRunRoutes } from "./routes/runs.js";
17
23
  import { registerSecretRoutes } from "./routes/secrets.js";
24
+ import { registerUserRoutes } from "./routes/users.js";
18
25
  import { registerWebSocket } from "./ws.js";
19
26
  export async function buildApp(deps) {
20
27
  const app = Fastify({ logger: false });
21
28
  await app.register(cookie);
29
+ // Declared up front so every request carries the field on the same shape,
30
+ // rather than each one growing a property the first time a hook writes it.
31
+ app.decorateRequest("identity", undefined);
22
32
  const workspacePath = (slug) => join(deps.root, "workspaces", slug);
23
33
  const ctx = {
24
34
  ...deps,
25
35
  runs: new RunStore(deps.db),
26
36
  logs: new LogStore(join(deps.root, "logs")),
27
- sessions: new SessionStore(),
37
+ sessions: new SessionStore(new SessionRecords(deps.db)),
28
38
  workspacePath,
29
39
  artifactsDir: (runId) => join(deps.root, "artifacts", String(runId)),
40
+ runSecretsDir: (runId) => join(deps.root, "runs", String(runId), "secrets"),
30
41
  ensureWorkspace: async (slug) => {
31
42
  const entry = deps.config.project(slug);
32
43
  if (!entry)
@@ -55,6 +66,23 @@ export async function buildApp(deps) {
55
66
  });
56
67
  return;
57
68
  }
69
+ // Before the run, and outside it: `executeRun` is handed plaintext and
70
+ // never the vault, a boundary worth more than the convenience of moving
71
+ // this one call inside. A block that will not decrypt stops the run here,
72
+ // with the reason, rather than producing an artifact signed by nothing.
73
+ let credentials;
74
+ try {
75
+ credentials = await materialiseCredentials(ctx.vault, slug, ctx.runSecretsDir(runId));
76
+ }
77
+ catch (cause) {
78
+ ctx.runs.finish(runId, {
79
+ status: "failed",
80
+ exitCode: null,
81
+ errorSummary: ctx.vault.scrub(slug, cause.message),
82
+ });
83
+ ctx.sockets?.finish(runId, "failed");
84
+ return;
85
+ }
58
86
  await executeRun({
59
87
  runId,
60
88
  runs: ctx.runs,
@@ -71,6 +99,9 @@ export async function buildApp(deps) {
71
99
  },
72
100
  env: process.env,
73
101
  secrets: ctx.vault.resolve(slug),
102
+ credentialEnv: credentials.env,
103
+ androidKeystore: credentials.keystore,
104
+ cleanup: credentials.cleanup,
74
105
  maskedValues: ctx.vault.maskedValues(slug),
75
106
  signal,
76
107
  onChunk: (chunk, offset) => app.broadcastRunChunk?.(runId, chunk, offset),
@@ -98,39 +129,87 @@ export async function buildApp(deps) {
98
129
  app.addHook("onClose", async () => queue.close());
99
130
  const throttle = new LoginThrottle();
100
131
  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();
132
+ const { name, password } = (req.body ?? {});
133
+ // A body with no name is the 0.2 login form, which knew only a password.
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);
104
140
  if (waitMs > 0) {
105
141
  return reply
106
142
  .code(429)
107
143
  .header("retry-after", Math.ceil(waitMs / 1000))
108
144
  .send({ error: `Too many attempts. Try again in ${Math.ceil(waitMs / 1000)}s.` });
109
145
  }
110
- if (!password || !hash || !(await verifyPassword(password, hash))) {
111
- throttle.recordFailure();
112
- return reply.code(401).send({ error: "Incorrect password" });
146
+ const users = deps.config.server()?.users ?? [];
147
+ const identity = password ? await authenticate(users, account, password) : null;
148
+ if (!identity) {
149
+ throttle.recordFailure(account);
150
+ // One message for a wrong password and for a name that does not exist:
151
+ // telling them apart is telling a stranger which accounts are worth
152
+ // attacking.
153
+ return reply.code(401).send({ error: "Incorrect name or password" });
113
154
  }
114
- throttle.recordSuccess();
115
- const token = ctx.sessions.issue();
155
+ throttle.recordSuccess(account);
156
+ const token = ctx.sessions.issue(identity);
116
157
  return reply
117
- .setCookie(SESSION_COOKIE, token, { path: "/", httpOnly: true, sameSite: "lax" })
118
- .send({ ok: true });
158
+ .setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
159
+ .send({ ok: true, name: identity.name, role: identity.role });
119
160
  });
120
- // Every /api route except /api/login requires a session.
161
+ // Every /api route except /api/login requires a session, and the routes on
162
+ // the admin list require an admin. Both decided here, once: a permission
163
+ // expressed as an `if` inside a handler is one nobody finds during an audit.
121
164
  app.addHook("onRequest", async (req, reply) => {
122
- if (!req.url.startsWith("/api") || req.url === "/api/login")
165
+ if (!req.url.startsWith("/api") || req.url.split("?")[0] === "/api/login")
123
166
  return;
124
- if (!ctx.sessions.valid(req.cookies[SESSION_COOKIE])) {
167
+ const session = ctx.sessions.get(req.cookies[SESSION_COOKIE]);
168
+ // The session holds who someone was when they signed in; the configuration
169
+ // holds who they are. They part company whenever config.yml is edited —
170
+ // from `laneyard user add`, which is another process entirely, or by hand.
171
+ // So the account is looked up again on every request: an account that is
172
+ // gone has no session, and a demotion takes effect at once rather than at
173
+ // the next restart. It is one find over a handful of entries.
174
+ const account = session && ctx.config.server()?.users.find((u) => u.name === session.name);
175
+ if (!session || !account) {
125
176
  return reply.code(401).send({ error: "Session required" });
126
177
  }
178
+ const identity = { name: account.name, role: account.role };
179
+ req.identity = identity;
180
+ if (identity.role !== "admin" && requiresAdmin(req.method, req.url)) {
181
+ return reply.code(403).send({ error: "This action requires the admin role" });
182
+ }
183
+ });
184
+ /**
185
+ * Signing out ends this session and no other.
186
+ *
187
+ * The same person may be signed in on a laptop and on a phone; pressing sign
188
+ * out on one of them must not be an act on the other. Only removing the
189
+ * account ends every session it has, and that is a different action with a
190
+ * different name.
191
+ */
192
+ app.post("/api/logout", async (req, reply) => {
193
+ const token = req.cookies[SESSION_COOKIE];
194
+ if (token)
195
+ ctx.sessions.revoke(token);
196
+ return reply.clearCookie(SESSION_COOKIE, { path: "/" }).code(204).send();
197
+ });
198
+ app.get("/api/me", async (req) => {
199
+ // Non-null because the hook above rejected every request that has no
200
+ // identity before this handler could be reached.
201
+ const { name, role } = req.identity;
202
+ return { name, role };
127
203
  });
128
204
  ctx.sockets = await registerWebSocket(app, ctx);
129
205
  await registerProjectRoutes(app, ctx);
130
206
  await registerRunRoutes(app, ctx);
131
207
  await registerSecretRoutes(app, ctx);
208
+ await registerCredentialRoutes(app, ctx);
132
209
  await registerReadinessRoutes(app, ctx);
133
210
  await registerFastfileRoutes(app, ctx);
211
+ await registerUserRoutes(app, ctx);
212
+ await registerAccountRoutes(app, ctx);
134
213
  // Resolved from the module's location, not from the data folder:
135
214
  // `deps.root` is ~/.laneyard, the built SPA lives in the repository. Two
136
215
  // relative positions, depending on whether we're running on the sources
@@ -37,45 +37,153 @@ export async function verifyPassword(password, stored) {
37
37
  return false;
38
38
  }
39
39
  }
40
- /** In-memory sessions: they don't survive a restart, and that's just fine. */
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
+ }
68
+ /**
69
+ * How long a session lasts, and how long the cookie is kept for.
70
+ *
71
+ * Thirty days, not forever: a browser left on a desk is the reason the password
72
+ * change asks for the current password, and a session that never ended would
73
+ * make that precaution pointless. The cookie carries the same figure, so the
74
+ * browser stops presenting a token the server would refuse anyway.
75
+ */
76
+ export const SESSION_TTL_DAYS = 30;
77
+ export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
78
+ /**
79
+ * Sessions, kept in the database so they outlive a restart.
80
+ *
81
+ * They used to be a Map, with a comment saying they did not survive a restart
82
+ * "and that's just fine". It was not: restarting the server to pick up an edit
83
+ * to config.yml signed everybody out, which on a machine still being set up is
84
+ * several times an hour. The API is unchanged — every caller still issues, gets
85
+ * and revokes — only the shelf underneath is different.
86
+ */
41
87
  export class SessionStore {
42
- tokens = new Set();
43
- issue() {
88
+ records;
89
+ constructor(records) {
90
+ this.records = records;
91
+ }
92
+ issue(identity, now = new Date()) {
44
93
  const token = randomBytes(32).toString("hex");
45
- this.tokens.add(token);
94
+ this.records.insert(token, identity, new Date(now.getTime() + SESSION_TTL_MS));
46
95
  return token;
47
96
  }
97
+ /** Who the token belongs to, or undefined if it belongs to nobody. */
98
+ get(token) {
99
+ if (token === undefined)
100
+ return undefined;
101
+ const owner = this.records.find(token);
102
+ return owner ? { name: owner.name, role: owner.role } : undefined;
103
+ }
48
104
  valid(token) {
49
- return token !== undefined && this.tokens.has(token);
105
+ return this.get(token) !== undefined;
50
106
  }
51
107
  revoke(token) {
52
- this.tokens.delete(token);
108
+ this.records.remove(token);
109
+ }
110
+ /**
111
+ * Drops every session belonging to a name.
112
+ *
113
+ * The identity is snapshotted when the session is issued, which is what makes
114
+ * a request cheap — but it also means removing an account or changing its role
115
+ * leaves the old answer live in whatever browsers already had it. Without this,
116
+ * "remove the account" and "revoke access" are two different things, while
117
+ * every interface that offers the first implies the second.
118
+ */
119
+ revokeAllFor(name) {
120
+ this.records.removeAllFor(name);
53
121
  }
54
122
  }
55
123
  export const SESSION_COOKIE = "laneyard_session";
56
124
  /**
57
- * Slows down repeated login attempts.
125
+ * How the session cookie is written, in one place so the two routes that issue
126
+ * one cannot disagree.
127
+ *
128
+ * `maxAge` is the half that was missing: without it the browser treats this as
129
+ * a session cookie and forgets it the moment the window closes, so signing in
130
+ * again was the price of quitting Chrome. It matches the server's own TTL, so
131
+ * the browser stops presenting a token the server would refuse anyway.
132
+ *
133
+ * Not `secure`: Laneyard is commonly reached over plain HTTP on a local
134
+ * network, and a cookie the browser refuses to send is a login page that never
135
+ * goes away.
136
+ */
137
+ export const COOKIE_OPTIONS = {
138
+ path: "/",
139
+ httpOnly: true,
140
+ sameSite: "lax",
141
+ maxAge: SESSION_TTL_MS / 1000,
142
+ };
143
+ /** Past this many tracked names, the entries that no longer delay anyone go. */
144
+ const THROTTLE_PRUNE_ABOVE = 1_000;
145
+ /**
146
+ * Slows down repeated login attempts, one account at a time.
58
147
  *
59
148
  * Without this, a network neighbour could try passwords as fast as the
60
149
  * server responds. The delay grows with failures and resets to zero on a
61
150
  * success: the legitimate user who mistypes once doesn't feel it.
151
+ *
152
+ * Counted per name rather than globally: a single counter means anyone able to
153
+ * reach the login form can lock out every account on the server by hammering
154
+ * one name — a denial of service disguised as a security measure.
62
155
  */
63
156
  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);
157
+ perName = new Map();
158
+ /** Milliseconds left to wait for that name, 0 if the way is clear. */
159
+ retryAfterMs(name, now = Date.now()) {
160
+ return Math.max(0, (this.perName.get(name)?.until ?? 0) - now);
69
161
  }
70
- recordFailure(now = Date.now()) {
71
- this.failures += 1;
162
+ recordFailure(name, now = Date.now()) {
163
+ const state = this.perName.get(name) ?? { failures: 0, until: 0 };
164
+ state.failures += 1;
72
165
  // 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);
166
+ if (state.failures > 3) {
167
+ state.until = now + Math.min(60_000, 2 ** (state.failures - 4) * 1000);
75
168
  }
169
+ this.perName.set(name, state);
170
+ // The key is whatever the caller sent, so an attacker chooses how many
171
+ // entries exist. Dropping those whose delay has run out bounds the map by
172
+ // how fast someone can be delayed, not by how many names they can invent.
173
+ if (this.perName.size > THROTTLE_PRUNE_ABOVE)
174
+ this.prune(now);
76
175
  }
77
- recordSuccess() {
78
- this.failures = 0;
79
- this.until = 0;
176
+ recordSuccess(name) {
177
+ this.perName.delete(name);
178
+ }
179
+ /** Number of names currently tracked. Exposed so the pruning can be tested. */
180
+ size() {
181
+ return this.perName.size;
182
+ }
183
+ prune(now) {
184
+ for (const [name, state] of this.perName) {
185
+ if (state.until <= now)
186
+ this.perName.delete(name);
187
+ }
80
188
  }
81
189
  }
@@ -0,0 +1,75 @@
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: "*", path: "/api/credentials" },
24
+ { method: "*", path: "/api/projects/:slug/credentials" },
25
+ { method: "PUT", path: "/api/projects/:slug/fastfile" },
26
+ { method: "POST", path: "/api/projects/:slug/commit" },
27
+ { method: "POST", path: "/api/projects/:slug/push" },
28
+ { method: "DELETE", path: "/api/projects/:slug" },
29
+ { method: "*", path: "/api/users" },
30
+ ];
31
+ /**
32
+ * `/api/secrets/APP_KEY?x=1` → `["api", "secrets", "APP_KEY"]`.
33
+ *
34
+ * Each segment is percent-decoded, because the router decodes before it matches
35
+ * and this must see the same path the router did. Comparing the raw text sent
36
+ * `GET /api/%73ecrets` straight past the admin list and into the vault: Fastify
37
+ * routed it to `/api/secrets`, and this function had been looking at `%73ecrets`.
38
+ */
39
+ function segments(url) {
40
+ return (url.split("?")[0] ?? "")
41
+ .split("/")
42
+ .filter((s) => s !== "")
43
+ .map((s) => {
44
+ try {
45
+ return decodeURIComponent(s);
46
+ }
47
+ catch {
48
+ // Malformed escapes reach no route either; left as-is rather than
49
+ // swallowed, so a segment is never silently emptied into a match.
50
+ return s;
51
+ }
52
+ });
53
+ }
54
+ /**
55
+ * Does this request need an admin?
56
+ *
57
+ * A pattern matches a request whose path *starts with* it, segment by segment,
58
+ * with `:name` standing for any one segment. The prefix is deliberate:
59
+ * `/api/secrets` means the vault, not one URL, and a table naming every key's
60
+ * route separately would be a table someone forgets to extend the next time a
61
+ * route is added under it. It only ever errs towards refusing, since every
62
+ * entry here is a restriction.
63
+ */
64
+ export function requiresAdmin(method, url) {
65
+ const parts = segments(url);
66
+ const verb = method.toUpperCase();
67
+ return REQUIRES_ADMIN.some((pattern) => {
68
+ if (pattern.method !== "*" && pattern.method.toUpperCase() !== verb)
69
+ return false;
70
+ const expected = segments(pattern.path);
71
+ if (parts.length < expected.length)
72
+ return false;
73
+ return expected.every((seg, i) => seg.startsWith(":") || seg === parts[i]);
74
+ });
75
+ }
@@ -0,0 +1,30 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parseEnvExample } from "../heuristics/env-example.js";
4
+ export async function requiredSecrets(input) {
5
+ const fromExample = await envExampleNames(input.workspacePath, input.fastlaneDir);
6
+ const required = [
7
+ ...new Set([...input.lanes.flatMap((l) => l.env ?? []), ...input.declared, ...fromExample]),
8
+ ].sort();
9
+ const supplied = new Set([...input.vaultKeys, ...input.blockNames, ...input.serverEnv]);
10
+ return {
11
+ required,
12
+ missing: required.filter((name) => !supplied.has(name)),
13
+ };
14
+ }
15
+ /**
16
+ * The names a committed `.env.example` advertises, if there is one.
17
+ *
18
+ * Two conventional filenames and no more: a convenience for the common case,
19
+ * not a search. Never throws — an absent file is the ordinary situation and
20
+ * means "nothing declared", not a failure.
21
+ */
22
+ export async function envExampleNames(workspacePath, fastlaneDir) {
23
+ const names = [];
24
+ for (const file of [".env.example", ".env.sample"]) {
25
+ const text = await readFile(join(workspacePath, fastlaneDir, file), "utf8").catch(() => null);
26
+ if (text !== null)
27
+ names.push(...parseEnvExample(text));
28
+ }
29
+ return names;
30
+ }
@@ -0,0 +1,57 @@
1
+ import { MIN_PASSWORD_LENGTH, upsertUserInConfig } from "../../config/accounts.js";
2
+ import { COOKIE_OPTIONS, SESSION_COOKIE, authenticate } from "../auth.js";
3
+ /**
4
+ * What someone may do to their own account, whatever their role.
5
+ *
6
+ * Deliberately not under `/api/users`: that prefix is on the admin list in
7
+ * `permissions.ts`, and it means "the accounts on this machine". This is the
8
+ * other thing entirely — one person, their own password — and a builder must be
9
+ * able to reach it. Changing the password of somebody *else* stays admin-only,
10
+ * and stays over there.
11
+ *
12
+ * The current password is required even though the session already proves who
13
+ * this is. A session is a cookie in a browser that may have been left open on a
14
+ * desk; a password is the one thing that says the person at the keyboard is
15
+ * still the person who signed in.
16
+ */
17
+ export async function registerAccountRoutes(app, ctx) {
18
+ app.post("/api/account/password", async (req, reply) => {
19
+ // Non-null: the hook in `app.ts` rejected every request without a session
20
+ // before this handler could run.
21
+ const { name, role } = req.identity;
22
+ const { current, next } = (req.body ?? {});
23
+ if (typeof next !== "string" || next.length < MIN_PASSWORD_LENGTH) {
24
+ return reply
25
+ .code(400)
26
+ .send({ error: `A password is at least ${MIN_PASSWORD_LENGTH} characters.` });
27
+ }
28
+ const users = ctx.config.server()?.users ?? [];
29
+ if (typeof current !== "string" || !(await authenticate(users, name, current))) {
30
+ // No throttle here, unlike `/api/login`: reaching this route already costs
31
+ // a valid session, so there is nobody to slow down who is not already in.
32
+ return reply.code(401).send({ error: "That is not your current password." });
33
+ }
34
+ if (next === current) {
35
+ return reply.code(400).send({ error: "That is the password you already have." });
36
+ }
37
+ // The role is carried over rather than read from the body: this route
38
+ // changes a password and nothing else, and an account that could hand
39
+ // itself `admin` in a field named `role` would make the whole permission
40
+ // table decorative.
41
+ await upsertUserInConfig(ctx.config.configPath(), { name, role, password: next });
42
+ // Every session this account has, gone — including this one. That is the
43
+ // point: changing a password is what someone does when they think another
44
+ // browser somewhere should stop being signed in. A fresh session is issued
45
+ // immediately afterwards, so the person who just did it is not signed out
46
+ // of the page they did it on.
47
+ ctx.sessions.revokeAllFor(name);
48
+ const token = ctx.sessions.issue({ name, role });
49
+ // The file is watched on a debounce; loading here is what makes the very
50
+ // next request see the new hash instead of the old one.
51
+ await ctx.config.load();
52
+ return reply
53
+ .setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
54
+ .code(204)
55
+ .send();
56
+ });
57
+ }