@kubitnodes/stellaris 0.1.0 → 0.2.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -2
  3. package/dist/cli/index.js +167 -12
  4. package/dist/server/index.js +543 -32
  5. package/package.json +2 -1
  6. package/web/dist/assets/ChannelView-O1FQ_Cbf.js +1 -0
  7. package/web/dist/assets/CitizenView-D0rSRBnj.js +2 -0
  8. package/web/dist/assets/CitizensView-BPx5ChvL.js +1 -0
  9. package/web/dist/assets/KnowledgeView-Cjf5Axu5.js +1 -0
  10. package/web/dist/assets/Markdown-Dkiv3bKi.js +29 -0
  11. package/web/dist/assets/MessageItem-sep2BGUh.js +1 -0
  12. package/web/dist/assets/MetricsView-BIH8FLob.js +1 -0
  13. package/web/dist/assets/NeedsYouView-CuB7daZ0.js +1 -0
  14. package/web/dist/assets/Overview-C6Ae0n9b.js +1 -0
  15. package/web/dist/assets/Pane-BXpuXst6.js +1 -0
  16. package/web/dist/assets/ProjectView-CpTcrvFa.js +1 -0
  17. package/web/dist/assets/ProposalView-BXisoFis.js +1 -0
  18. package/web/dist/assets/ProposalsView-BjPeXIxG.js +1 -0
  19. package/web/dist/assets/RunnersView-0ou0ekMc.js +1 -0
  20. package/web/dist/assets/SocietyView-C8lU_XY4.js +1 -0
  21. package/web/dist/assets/SubjectThread-BWbOV_wk.js +1 -0
  22. package/web/dist/assets/TaskView-CDLRRfS6.js +1 -0
  23. package/web/dist/assets/TasksView-p6PdvdWY.js +1 -0
  24. package/web/dist/assets/ThreadCard-BisvLnYg.js +1 -0
  25. package/web/dist/assets/ThreadForms-DZ1yLsjU.js +1 -0
  26. package/web/dist/assets/ThreadView-CQUPQ5oR.js +1 -0
  27. package/web/dist/assets/{citizen-BXVebeiP.js → citizen-BwK8Ciw3.js} +1 -1
  28. package/web/dist/assets/format-DZLzLk7S.js +55 -0
  29. package/web/dist/assets/index-CbJnPhSw.js +21 -0
  30. package/web/dist/assets/index-DJ30ENcc.css +2 -0
  31. package/web/dist/assets/{tasks-rltmWd5a.js → tasks-DZgkXWwu.js} +1 -1
  32. package/web/dist/assets/{useStickyScroll-DDS3s21P.js → useStickyScroll-CVTdIoKj.js} +1 -1
  33. package/web/dist/index.html +4 -4
  34. package/web/dist/assets/ChannelView-B8BZLsf1.js +0 -1
  35. package/web/dist/assets/CitizenView-GomLDcrr.js +0 -2
  36. package/web/dist/assets/CitizensView-yNv8WtND.js +0 -1
  37. package/web/dist/assets/KnowledgeView--jxofSSN.js +0 -1
  38. package/web/dist/assets/Markdown-BpYzeV5r.js +0 -28
  39. package/web/dist/assets/MessageItem-BGPpqgpq.js +0 -1
  40. package/web/dist/assets/MetricsView-CcqLr6S1.js +0 -1
  41. package/web/dist/assets/NeedsYouView-r7gJGgVG.js +0 -1
  42. package/web/dist/assets/Overview-Cmz4_gfI.js +0 -1
  43. package/web/dist/assets/Pane-DRpHmeJ9.js +0 -1
  44. package/web/dist/assets/ProjectView-CuACZj4L.js +0 -1
  45. package/web/dist/assets/ProposalView-BiVcNEDL.js +0 -1
  46. package/web/dist/assets/ProposalsView-BIdkOfZO.js +0 -1
  47. package/web/dist/assets/SocietyView-Dya97szC.js +0 -1
  48. package/web/dist/assets/SubjectThread-2j_msY38.js +0 -1
  49. package/web/dist/assets/TaskView-CcJr7xnH.js +0 -1
  50. package/web/dist/assets/TasksView-BUvHntvK.js +0 -1
  51. package/web/dist/assets/ThreadCard-tzMGvn9j.js +0 -1
  52. package/web/dist/assets/ThreadForms-DE2yJEhz.js +0 -1
  53. package/web/dist/assets/ThreadView-FsNOTyVC.js +0 -1
  54. package/web/dist/assets/format-Ca2VAvEx.js +0 -77
  55. package/web/dist/assets/index-CtuchAFa.css +0 -2
  56. package/web/dist/assets/index-D4GJkeKU.js +0 -237
@@ -695,6 +695,9 @@ var BOARD_EVENT_TYPES = [
695
695
  "knowledge.written",
696
696
  "skill.promoted",
697
697
  "runner.added",
698
+ "user.signed_in",
699
+ "user.signed_out",
700
+ "user.token_rotated",
698
701
  "runner.changed",
699
702
  "ops.signal",
700
703
  "paused.changed",
@@ -803,6 +806,36 @@ var RunnerHelloSchema = z6.object({
803
806
  turns: z6.array(UlidSchema).default([])
804
807
  });
805
808
  var RunnerWelcomeSchema = z6.object({ name: NameSchema, version: z6.string() });
809
+ var RunnerEnrollRequestSchema = z6.object({
810
+ protocol: z6.number().int().positive(),
811
+ version: z6.string().min(1).max(64),
812
+ hostname: z6.string().min(1).max(253),
813
+ os: RunnerOsSchema,
814
+ clis: z6.array(CliKindSchema).max(8),
815
+ capabilities: z6.array(z6.string().min(1).max(64)).max(32).default([])
816
+ });
817
+ var RunnerEnrollmentSchema = z6.object({
818
+ deviceCode: z6.string().min(1),
819
+ userCode: z6.string().min(1),
820
+ expiresAt: IsoDateTimeSchema,
821
+ intervalMs: z6.number().int().positive()
822
+ });
823
+ var RunnerEnrollPollSchema = z6.object({ deviceCode: z6.string().min(1) });
824
+ var RunnerEnrollStatusSchema = z6.discriminatedUnion("status", [
825
+ z6.object({ status: z6.literal("pending") }),
826
+ z6.object({ status: z6.literal("approved"), name: NameSchema, token: z6.string().min(1) }),
827
+ z6.object({ status: z6.literal("denied") })
828
+ ]);
829
+ var PendingEnrollmentSchema = z6.object({
830
+ userCode: z6.string().min(1),
831
+ hostname: z6.string(),
832
+ os: RunnerOsSchema,
833
+ clis: z6.array(CliKindSchema),
834
+ capabilities: z6.array(z6.string()),
835
+ version: z6.string(),
836
+ requestedAt: IsoDateTimeSchema,
837
+ expiresAt: IsoDateTimeSchema
838
+ });
806
839
  var ProjectRepoSchema = z6.object({
807
840
  slug: NameSchema,
808
841
  origin: z6.string().nullable(),
@@ -1121,6 +1154,12 @@ var MetricsSchema = z9.object({
1121
1154
  // packages/shared/dist/config.js
1122
1155
  import { z as z10 } from "zod";
1123
1156
  var LogLevelSchema = z10.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
1157
+ var GithubSignInSchema = z10.object({
1158
+ clientId: z10.string().min(1),
1159
+ clientSecret: z10.string().min(1),
1160
+ /** Logins, lowercased; GitHub logins are case-insensitive. */
1161
+ users: z10.array(z10.string().min(1)).min(1)
1162
+ });
1124
1163
  var ServerConfigSchema = z10.object({
1125
1164
  dataDir: z10.string().min(1),
1126
1165
  host: z10.string().min(1),
@@ -1138,7 +1177,14 @@ var ServerConfigSchema = z10.object({
1138
1177
  /** Rounds of tool calls one Claude turn may take, or null for no limit. */
1139
1178
  toolRounds: z10.number().int().positive().nullable(),
1140
1179
  /** How long a resident session stays warm after its last turn. */
1141
- residentIdleMs: z10.number().int().positive()
1180
+ residentIdleMs: z10.number().int().positive(),
1181
+ /** Signing in to the board with GitHub, or null when only a token lets anyone in. */
1182
+ github: GithubSignInSchema.nullable(),
1183
+ /**
1184
+ * Reverse proxies in front of the server whose X-Forwarded-For entries name the client, for the
1185
+ * rate limits on what anyone may ask without a token: 0 when clients connect directly.
1186
+ */
1187
+ trustedProxies: z10.number().int().min(0).max(16)
1142
1188
  });
1143
1189
  function limit(value, fallback) {
1144
1190
  if (value === void 0 || value.trim() === "") {
@@ -1146,6 +1192,21 @@ function limit(value, fallback) {
1146
1192
  }
1147
1193
  return value.trim().toLowerCase() === "unlimited" ? null : Number(value);
1148
1194
  }
1195
+ function list(value) {
1196
+ return (value ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
1197
+ }
1198
+ function githubSignIn(env) {
1199
+ const clientId = env["STELLARIS_GITHUB_CLIENT_ID"] ?? "";
1200
+ const clientSecret = env["STELLARIS_GITHUB_CLIENT_SECRET"] ?? "";
1201
+ const users = list(env["STELLARIS_GITHUB_USERS"]).map((login) => login.toLowerCase());
1202
+ if (clientId === "" && clientSecret === "" && users.length === 0) {
1203
+ return null;
1204
+ }
1205
+ if (clientId === "" || clientSecret === "" || users.length === 0) {
1206
+ throw new Error("GitHub sign-in needs STELLARIS_GITHUB_CLIENT_ID, STELLARIS_GITHUB_CLIENT_SECRET, and STELLARIS_GITHUB_USERS together");
1207
+ }
1208
+ return GithubSignInSchema.parse({ clientId, clientSecret, users });
1209
+ }
1149
1210
  function loadServerConfig(env) {
1150
1211
  return ServerConfigSchema.parse({
1151
1212
  dataDir: env["STELLARIS_DATA_DIR"] ?? "./data",
@@ -1156,14 +1217,19 @@ function loadServerConfig(env) {
1156
1217
  concurrency: limit(env["STELLARIS_CONCURRENCY"], null),
1157
1218
  turnTimeoutMs: limit(env["STELLARIS_TURN_TIMEOUT_MS"], 20 * 6e4),
1158
1219
  toolRounds: limit(env["STELLARIS_TOOL_ROUNDS"], 60),
1159
- residentIdleMs: Number(env["STELLARIS_RESIDENT_IDLE_MS"] ?? String(10 * 6e4))
1220
+ residentIdleMs: Number(env["STELLARIS_RESIDENT_IDLE_MS"] ?? String(10 * 6e4)),
1221
+ github: githubSignIn(env),
1222
+ trustedProxies: Number(env["STELLARIS_TRUSTED_PROXIES"] ?? "0")
1160
1223
  });
1161
1224
  }
1162
1225
  var RunnerConfigSchema = z10.object({
1163
1226
  /** The board server, for example http://127.0.0.1:4700. */
1164
1227
  serverUrl: z10.string().url(),
1165
- /** The runner's token, minted by `runner add`. */
1166
- token: z10.string().min(1),
1228
+ /**
1229
+ * The runner's token, from `runner add`, or null for the one it saved when it was enrolled, or
1230
+ * to enroll it.
1231
+ */
1232
+ token: z10.string().min(1).nullable(),
1167
1233
  /** The runner's own data directory: the board mirror, agent homes, repositories, worktrees. */
1168
1234
  dataDir: z10.string().min(1),
1169
1235
  logLevel: LogLevelSchema,
@@ -1174,7 +1240,7 @@ var RunnerConfigSchema = z10.object({
1174
1240
  });
1175
1241
 
1176
1242
  // packages/shared/dist/version.js
1177
- var STELLARIS_VERSION = "0.1.0";
1243
+ var STELLARIS_VERSION = "0.2.0";
1178
1244
 
1179
1245
  // packages/board-core/dist/errors.js
1180
1246
  var BoardError = class extends Error {
@@ -1228,9 +1294,9 @@ async function readMarkdown(file, schema) {
1228
1294
  return { data: schema.parse(parsed.data), body: parsed.content.replace(/^\n/, "") };
1229
1295
  }
1230
1296
  async function writeMarkdown(file, data, body) {
1231
- const normalized = body.endsWith("\n") || body.length === 0 ? body : `${body}
1297
+ const normalized2 = body.endsWith("\n") || body.length === 0 ? body : `${body}
1232
1298
  `;
1233
- await writeFileAtomic(file, matter.stringify(normalized, data));
1299
+ await writeFileAtomic(file, matter.stringify(normalized2, data));
1234
1300
  }
1235
1301
  function isMissing(error) {
1236
1302
  return typeof error === "object" && error !== null && error.code === "ENOENT";
@@ -1969,11 +2035,11 @@ function computeMetrics(input) {
1969
2035
  unanswered: waiting.length,
1970
2036
  medianMs: median(allLatencies),
1971
2037
  slowestMs: allLatencies.length === 0 ? null : Math.max(...allLatencies),
1972
- byAgent: [...latencies].map(([agent, list]) => ({
2038
+ byAgent: [...latencies].map(([agent, list2]) => ({
1973
2039
  agent,
1974
- mentions: list.length,
1975
- medianMs: median(list) ?? 0,
1976
- slowestMs: Math.max(...list)
2040
+ mentions: list2.length,
2041
+ medianMs: median(list2) ?? 0,
2042
+ slowestMs: Math.max(...list2)
1977
2043
  })).toSorted((a, b) => b.medianMs - a.medianMs)
1978
2044
  },
1979
2045
  decisions: {
@@ -2207,6 +2273,14 @@ var SKIPPED_IN_HOME = /* @__PURE__ */ new Set([".git", "turns", ".claude", ".cod
2207
2273
  var RunnerTokensSchema = z12.object({
2208
2274
  runners: z12.record(z12.string(), z12.object({ tokenHash: z12.string().min(1) }))
2209
2275
  });
2276
+ var SignInsSchema = z12.object({
2277
+ signIns: z12.array(z12.object({
2278
+ tokenHash: z12.string().min(1),
2279
+ login: z12.string().min(1),
2280
+ createdAt: IsoDateTimeSchema,
2281
+ expiresAt: IsoDateTimeSchema
2282
+ })).default([])
2283
+ });
2210
2284
  var TurnHistoryPayloadSchema = z12.object({
2211
2285
  project: NameSchema,
2212
2286
  thread: UlidSchema.optional(),
@@ -2412,6 +2486,8 @@ var Board = class _Board {
2412
2486
  turnTokens = /* @__PURE__ */ new Map();
2413
2487
  /** Runner token hashes to runner names. */
2414
2488
  runnerTokens = /* @__PURE__ */ new Map();
2489
+ /** Sign-ins by token hash, each acting as the user until it expires. */
2490
+ signIns = /* @__PURE__ */ new Map();
2415
2491
  files = new FileTree();
2416
2492
  homes;
2417
2493
  constructor(dataDir, options) {
@@ -2440,6 +2516,7 @@ var Board = class _Board {
2440
2516
  await board2.mutex.run(() => board2.applyAlignments());
2441
2517
  await board2.loadTokenIndex();
2442
2518
  await board2.loadRunnerTokens();
2519
+ await board2.loadSignIns();
2443
2520
  await board2.homes.installHooks();
2444
2521
  return board2;
2445
2522
  }
@@ -2762,6 +2839,14 @@ ${charter.purpose}
2762
2839
  if (persistent !== void 0) {
2763
2840
  return persistent;
2764
2841
  }
2842
+ const signIn = this.signIns.get(hash);
2843
+ if (signIn !== void 0) {
2844
+ if (signIn.expiresAt > this.now().getTime()) {
2845
+ return this.userActor();
2846
+ }
2847
+ this.signIns.delete(hash);
2848
+ return null;
2849
+ }
2765
2850
  const temporary = this.turnTokens.get(hash);
2766
2851
  if (temporary === void 0) {
2767
2852
  return null;
@@ -2806,6 +2891,77 @@ ${charter.purpose}
2806
2891
  userActor() {
2807
2892
  return { name: USER_NAME, role: USER_ROLE };
2808
2893
  }
2894
+ /**
2895
+ * Signs the user in, as `login` on the identity provider, for `ttlMs`, and returns the sign-in's
2896
+ * token, shown once. Only its hash is kept, in `state/sign-ins.json`, so a sign-in outlives a
2897
+ * restart; it acts as the user, like the user's own token, until it expires or is signed out.
2898
+ */
2899
+ async signIn(login, ttlMs) {
2900
+ return this.mutex.run(async () => {
2901
+ const token = mintToken();
2902
+ const now = this.now().getTime();
2903
+ const record = {
2904
+ tokenHash: hashToken(token),
2905
+ login,
2906
+ createdAt: new Date(now).toISOString(),
2907
+ expiresAt: new Date(now + ttlMs).toISOString()
2908
+ };
2909
+ const live = (await this.readSignIns()).filter((each) => Date.parse(each.expiresAt) > now);
2910
+ await writeJson(this.signInsFile(), { signIns: [...live, record] });
2911
+ this.signIns.set(record.tokenHash, { login, expiresAt: Date.parse(record.expiresAt) });
2912
+ await this.events.append("user.signed_in", USER_NAME, { login });
2913
+ return token;
2914
+ });
2915
+ }
2916
+ /** Ends the sign-in a token belongs to; false for a token that is no sign-in's. */
2917
+ async signOut(token) {
2918
+ const hash = hashToken(token);
2919
+ return this.mutex.run(async () => {
2920
+ const signIn = this.signIns.get(hash);
2921
+ if (signIn === void 0) {
2922
+ return false;
2923
+ }
2924
+ this.signIns.delete(hash);
2925
+ await writeJson(this.signInsFile(), {
2926
+ signIns: (await this.readSignIns()).filter((each) => each.tokenHash !== hash)
2927
+ });
2928
+ await this.events.append("user.signed_out", USER_NAME, { login: signIn.login });
2929
+ return true;
2930
+ });
2931
+ }
2932
+ /**
2933
+ * Issues the user a new token and revokes the old one, returning the new one, shown once: the way
2934
+ * back in when the token is lost. A server already running keeps the old token's hash in memory
2935
+ * until it restarts, since the admin CLI that calls this writes the data directory beside it.
2936
+ */
2937
+ async rotateUserToken() {
2938
+ return this.mutex.run(async () => {
2939
+ const previous = await this.readAgent(USER_NAME);
2940
+ const token = mintToken();
2941
+ await this.updateAgent(USER_NAME, (agent) => ({ ...agent, tokenHash: hashToken(token) }));
2942
+ this.tokenIndex.delete(previous.tokenHash);
2943
+ this.tokenIndex.set(hashToken(token), this.userActor());
2944
+ await this.events.append("user.token_rotated", USER_NAME, {});
2945
+ return token;
2946
+ });
2947
+ }
2948
+ signInsFile() {
2949
+ return path6.join(this.paths.state(), "sign-ins.json");
2950
+ }
2951
+ async readSignIns() {
2952
+ const file = this.signInsFile();
2953
+ return await exists(file) ? (await readJson(file, SignInsSchema)).signIns : [];
2954
+ }
2955
+ async loadSignIns() {
2956
+ this.signIns.clear();
2957
+ const now = this.now().getTime();
2958
+ for (const signIn of await this.readSignIns()) {
2959
+ const expiresAt = Date.parse(signIn.expiresAt);
2960
+ if (expiresAt > now) {
2961
+ this.signIns.set(signIn.tokenHash, { login: signIn.login, expiresAt });
2962
+ }
2963
+ }
2964
+ }
2809
2965
  async actorFor(name) {
2810
2966
  const agent = await this.readAgent(name);
2811
2967
  return { name: agent.name, role: agent.role };
@@ -4802,8 +4958,9 @@ ${rationale}`}`);
4802
4958
  /**
4803
4959
  * Registers a runner and mints its token, returned once; only its hash is kept, outside the
4804
4960
  * projection. The runner reports its operating system, CLIs, and capabilities when it connects.
4961
+ * An enrolled runner names the machine that asked, which the event records.
4805
4962
  */
4806
- async addRunner(actor, name) {
4963
+ async addRunner(actor, name, enrolled) {
4807
4964
  if (actor.role !== USER_ROLE) {
4808
4965
  throw new BoardError("FORBIDDEN", "only the user adds runners");
4809
4966
  }
@@ -4827,7 +4984,10 @@ ${rationale}`}`);
4827
4984
  runners: { ...tokens, [parsed]: { tokenHash: hashToken(token) } }
4828
4985
  });
4829
4986
  this.runnerTokens.set(hashToken(token), parsed);
4830
- await this.events.append("runner.added", actor.name, { name: parsed });
4987
+ await this.events.append("runner.added", actor.name, {
4988
+ name: parsed,
4989
+ ...enrolled === void 0 ? {} : { enrolledFrom: enrolled.hostname }
4990
+ });
4831
4991
  return { runner, token };
4832
4992
  });
4833
4993
  }
@@ -8282,14 +8442,205 @@ async function handleMcpRequest(board2, actor, request, version) {
8282
8442
  }
8283
8443
 
8284
8444
  // apps/server/dist/app.js
8285
- import { Hono as Hono2 } from "hono";
8445
+ import { Hono as Hono3 } from "hono";
8286
8446
  import { compress } from "hono/compress";
8287
8447
  import { streamSSE as streamSSE2 } from "hono/streaming";
8288
- import { z as z14, ZodError } from "zod";
8448
+ import { z as z15, ZodError } from "zod";
8449
+
8450
+ // apps/server/dist/github-auth.js
8451
+ import { randomBytes as randomBytes2 } from "node:crypto";
8452
+ import { Hono } from "hono";
8453
+ import { z as z14 } from "zod";
8454
+ var SIGN_IN_TTL_MS = 30 * 24 * 36e5;
8455
+ var STATE_TTL_MS = 10 * 6e4;
8456
+ var MAX_STATES = 1e3;
8457
+ var TokenAnswerSchema = z14.object({ access_token: z14.string().min(1) });
8458
+ var GithubUserSchema = z14.object({ login: z14.string().min(1), id: z14.number().int() });
8459
+ function safeNext(next) {
8460
+ return next !== void 0 && /^\/(?![/\\])/.test(next) ? next : "/";
8461
+ }
8462
+ function land(c, next, fragment) {
8463
+ return c.redirect(`${next}#${fragment}`, 302);
8464
+ }
8465
+ function githubAuthRoutes(options) {
8466
+ const { board: board2, github } = options;
8467
+ const fetchImpl = options.fetch ?? fetch;
8468
+ const now = options.now ?? Date.now;
8469
+ const states = /* @__PURE__ */ new Map();
8470
+ const routes = new Hono();
8471
+ routes.get("/config", (c) => c.json({ github: github !== null }));
8472
+ if (github === null) {
8473
+ return routes;
8474
+ }
8475
+ routes.get("/github", options.limit ?? ((_c, next) => next()), (c) => {
8476
+ for (const [state2, pending] of states) {
8477
+ if (pending.expiresAt <= now() || states.size >= MAX_STATES) {
8478
+ states.delete(state2);
8479
+ }
8480
+ }
8481
+ const state = randomBytes2(24).toString("base64url");
8482
+ states.set(state, { next: safeNext(c.req.query("next")), expiresAt: now() + STATE_TTL_MS });
8483
+ const authorize = new URL("https://github.com/login/oauth/authorize");
8484
+ authorize.searchParams.set("client_id", github.clientId);
8485
+ authorize.searchParams.set("state", state);
8486
+ authorize.searchParams.set("allow_signup", "false");
8487
+ return c.redirect(authorize.toString(), 302);
8488
+ });
8489
+ routes.get("/github/callback", async (c) => {
8490
+ const state = c.req.query("state") ?? "";
8491
+ const pending = states.get(state);
8492
+ states.delete(state);
8493
+ if (pending === void 0 || pending.expiresAt <= now()) {
8494
+ return land(c, "/", "signin-error=expired");
8495
+ }
8496
+ const code = c.req.query("code");
8497
+ if (code === void 0 || code === "") {
8498
+ return land(c, pending.next, "signin-error=declined");
8499
+ }
8500
+ let login;
8501
+ let id;
8502
+ try {
8503
+ const tokenAnswer = await fetchImpl("https://github.com/login/oauth/access_token", {
8504
+ method: "POST",
8505
+ headers: { accept: "application/json", "content-type": "application/json" },
8506
+ body: JSON.stringify({
8507
+ client_id: github.clientId,
8508
+ client_secret: github.clientSecret,
8509
+ code
8510
+ })
8511
+ });
8512
+ const { access_token: accessToken } = TokenAnswerSchema.parse(await tokenAnswer.json());
8513
+ const userAnswer = await fetchImpl("https://api.github.com/user", {
8514
+ headers: {
8515
+ accept: "application/vnd.github+json",
8516
+ authorization: `Bearer ${accessToken}`,
8517
+ "user-agent": "stellaris"
8518
+ }
8519
+ });
8520
+ if (!userAnswer.ok) {
8521
+ throw new Error(`GitHub answered ${userAnswer.status} for the user`);
8522
+ }
8523
+ ({ login, id } = GithubUserSchema.parse(await userAnswer.json()));
8524
+ } catch (error) {
8525
+ options.log?.warn({ error: String(error) }, "GitHub sign-in failed");
8526
+ return land(c, pending.next, "signin-error=github");
8527
+ }
8528
+ if (!github.users.includes(login.toLowerCase()) && !github.users.includes(String(id))) {
8529
+ options.log?.warn({ login }, "GitHub sign-in refused a login that is not on the list");
8530
+ return land(c, pending.next, "signin-error=not-allowed");
8531
+ }
8532
+ const token = await board2.signIn(login, SIGN_IN_TTL_MS);
8533
+ return land(c, pending.next, `session=${encodeURIComponent(token)}`);
8534
+ });
8535
+ return routes;
8536
+ }
8537
+
8538
+ // apps/server/dist/rate-limit.js
8539
+ import { isIPv4, isIPv6 } from "node:net";
8540
+ import { getConnInfo } from "@hono/node-server/conninfo";
8541
+ var ENROLL_LIMIT = { limit: 5, windowMs: 10 * 6e4 };
8542
+ var SIGN_IN_LIMIT = { limit: 20, windowMs: 10 * 6e4 };
8543
+ var MAX_ADDRESSES = 1e4;
8544
+ var RateLimiter = class {
8545
+ options;
8546
+ hits = /* @__PURE__ */ new Map();
8547
+ constructor(options) {
8548
+ this.options = options;
8549
+ }
8550
+ take(key) {
8551
+ const now = (this.options.now ?? Date.now)();
8552
+ const since = now - this.options.windowMs;
8553
+ let entry = this.hits.get(key);
8554
+ if (entry === void 0) {
8555
+ this.makeRoom(since);
8556
+ entry = { times: [], warned: false };
8557
+ }
8558
+ this.hits.delete(key);
8559
+ this.hits.set(key, entry);
8560
+ entry.times = entry.times.filter((time) => time > since);
8561
+ const oldest = entry.times[0];
8562
+ if (oldest !== void 0 && entry.times.length >= this.options.limit) {
8563
+ const first = !entry.warned;
8564
+ entry.warned = true;
8565
+ return { ok: false, retryAfterMs: oldest + this.options.windowMs - now, first };
8566
+ }
8567
+ entry.times.push(now);
8568
+ entry.warned = false;
8569
+ return { ok: true };
8570
+ }
8571
+ makeRoom(since) {
8572
+ if (this.hits.size < MAX_ADDRESSES) {
8573
+ return;
8574
+ }
8575
+ for (const [key, entry] of this.hits) {
8576
+ if (entry.times.every((time) => time <= since)) {
8577
+ this.hits.delete(key);
8578
+ }
8579
+ }
8580
+ for (const key of this.hits.keys()) {
8581
+ if (this.hits.size < MAX_ADDRESSES) {
8582
+ break;
8583
+ }
8584
+ this.hits.delete(key);
8585
+ }
8586
+ }
8587
+ };
8588
+ function clientAddress(c, trustedProxies) {
8589
+ let socket = "unknown";
8590
+ try {
8591
+ socket = getConnInfo(c).remote.address ?? socket;
8592
+ } catch {
8593
+ }
8594
+ const forwarded = (c.req.header("x-forwarded-for") ?? "").split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
8595
+ const chain = [socket, ...forwarded.toReversed()];
8596
+ return chain[Math.min(trustedProxies, chain.length - 1)] ?? socket;
8597
+ }
8598
+ function groups(part) {
8599
+ return part === "" ? [] : part.split(":");
8600
+ }
8601
+ function width(parts) {
8602
+ return parts.reduce((sum, part) => sum + (part.includes(".") ? 2 : 1), 0);
8603
+ }
8604
+ function addressKey(address) {
8605
+ const unwrapped = /^\[(.+)\](?::\d+)?$/.exec(address)?.[1] ?? /^(\d+\.\d+\.\d+\.\d+):\d+$/.exec(address)?.[1] ?? address;
8606
+ const bare = unwrapped.split("%")[0] ?? unwrapped;
8607
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(bare)?.[1];
8608
+ if (mapped !== void 0) {
8609
+ return mapped;
8610
+ }
8611
+ if (isIPv4(bare) || !isIPv6(bare)) {
8612
+ return bare.slice(0, 64);
8613
+ }
8614
+ const [head = "", tail] = bare.split("::");
8615
+ const front = groups(head);
8616
+ const back = tail === void 0 ? [] : groups(tail);
8617
+ const filled = tail === void 0 ? front : [...front, ...Array.from({ length: 8 - width(front) - width(back) }, () => "0"), ...back];
8618
+ const prefix = filled.slice(0, 4).map((group) => Number.parseInt(group, 16).toString(16));
8619
+ return `${prefix.join(":")}::/64`;
8620
+ }
8621
+ function rateLimit(options) {
8622
+ return async (c, next) => {
8623
+ const address = clientAddress(c, options.trustedProxies);
8624
+ const verdict = options.limiter.take(addressKey(address));
8625
+ if (!verdict.ok) {
8626
+ const seconds = Math.max(1, Math.ceil(verdict.retryAfterMs / 1e3));
8627
+ if (verdict.first) {
8628
+ options.log?.warn({ address, retryAfterSeconds: seconds }, `rate limited ${options.what}`);
8629
+ }
8630
+ c.header("retry-after", String(seconds));
8631
+ return c.json({
8632
+ error: "RATE_LIMITED",
8633
+ message: `too many ${options.what} from this address; try again in ${seconds} seconds`
8634
+ }, 429);
8635
+ }
8636
+ await next();
8637
+ return void 0;
8638
+ };
8639
+ }
8289
8640
 
8290
8641
  // apps/server/dist/runner-routes.js
8291
8642
  import path7 from "node:path";
8292
- import { Hono } from "hono";
8643
+ import { Hono as Hono2 } from "hono";
8293
8644
  import { streamSSE } from "hono/streaming";
8294
8645
 
8295
8646
  // apps/server/dist/home-git.js
@@ -8385,8 +8736,16 @@ function bearer(c) {
8385
8736
  const match = /^Bearer\s+(.+)$/i.exec(c.req.header("authorization") ?? "");
8386
8737
  return match?.[1]?.trim() ?? null;
8387
8738
  }
8388
- function runnerRoutes(board2, hub) {
8389
- const routes = new Hono();
8739
+ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
8740
+ const routes = new Hono2();
8741
+ if (enrollments !== void 0) {
8742
+ routes.post("/enroll", enrollLimit ?? ((_c, next) => next()), async (c) => c.json(enrollments.open(RunnerEnrollRequestSchema.parse(await c.req.json()))));
8743
+ routes.post("/enroll/poll", async (c) => {
8744
+ const { deviceCode } = RunnerEnrollPollSchema.parse(await c.req.json());
8745
+ const status = enrollments.poll(deviceCode);
8746
+ return status === null ? c.json({ error: "NOT_FOUND", message: "no such enrollment; it may have expired" }, 404) : c.json(status);
8747
+ });
8748
+ }
8390
8749
  routes.use("*", async (c, next) => {
8391
8750
  const token = bearer(c);
8392
8751
  const runner = token === null ? null : board2.resolveRunnerToken(token);
@@ -8450,7 +8809,7 @@ function runnerRoutes(board2, hub) {
8450
8809
  });
8451
8810
  routes.get("/board/manifest", async (c) => c.json({ files: await board2.boardManifest() }));
8452
8811
  routes.post("/board/read", async (c) => c.json({ files: await board2.readBoardFiles(FileReadSchema.parse(await c.req.json()).paths) }));
8453
- const home = new Hono();
8812
+ const home = new Hono2();
8454
8813
  home.use("*", async (c, next) => {
8455
8814
  const agent = NameSchema.parse(c.req.param("agent"));
8456
8815
  if (!hub.mayTouchHome(c.get("runner"), agent)) {
@@ -8550,15 +8909,16 @@ var ERROR_STATUS = {
8550
8909
  INVALID_TRANSITION: 409,
8551
8910
  INVALID_STATE: 409
8552
8911
  };
8553
- var RetireBodySchema = z14.object({ reason: z14.string().min(1) });
8554
- var RunnerBodySchema = z14.object({ name: NameSchema });
8555
- var HomeRunnerBodySchema = z14.object({ runner: NameSchema.nullable() });
8556
- var ModelBodySchema = z14.object({ model: ModelNameSchema.nullable() });
8557
- var HistoryLimitSchema = z14.coerce.number().int().min(1).max(500);
8558
- var ChannelBodySchema = z14.object({
8912
+ var RetireBodySchema = z15.object({ reason: z15.string().min(1) });
8913
+ var RunnerBodySchema = z15.object({ name: NameSchema });
8914
+ var EnrollmentApprovalSchema = z15.object({ name: NameSchema });
8915
+ var HomeRunnerBodySchema = z15.object({ runner: NameSchema.nullable() });
8916
+ var ModelBodySchema = z15.object({ model: ModelNameSchema.nullable() });
8917
+ var HistoryLimitSchema = z15.coerce.number().int().min(1).max(500);
8918
+ var ChannelBodySchema = z15.object({
8559
8919
  project: NameSchema.nullable().default(null),
8560
8920
  name: NameSchema,
8561
- purpose: z14.string().min(1)
8921
+ purpose: z15.string().min(1)
8562
8922
  });
8563
8923
  function bearer2(c) {
8564
8924
  const header = c.req.header("authorization") ?? "";
@@ -8567,7 +8927,7 @@ function bearer2(c) {
8567
8927
  }
8568
8928
  function createApp(deps) {
8569
8929
  const { board: board2, version, turns: turns2, scheduler: scheduler2, models } = deps;
8570
- const app2 = new Hono2();
8930
+ const app2 = new Hono3();
8571
8931
  app2.get("/health", (c) => c.json({ ok: true, version }));
8572
8932
  app2.onError((error, c) => {
8573
8933
  if (isBoardError(error)) {
@@ -8590,9 +8950,10 @@ function createApp(deps) {
8590
8950
  c.set("actor", actor);
8591
8951
  await next();
8592
8952
  };
8593
- const api = new Hono2();
8953
+ const api = new Hono3();
8594
8954
  api.use("*", authenticate);
8595
8955
  api.get("/me", (c) => c.json(c.get("actor")));
8956
+ api.delete("/sign-in", async (c) => c.json({ signedOut: await board2.signOut(bearer2(c) ?? "") }));
8596
8957
  api.get("/society", async (c) => c.json(await board2.society()));
8597
8958
  api.get("/agents", async (c) => c.json((await board2.listAgents()).map(({ tokenHash: _hash, ...agent }) => agent)));
8598
8959
  api.get("/members", async (c) => c.json(await board2.listMembers()));
@@ -8621,6 +8982,28 @@ function createApp(deps) {
8621
8982
  const body = RunnerBodySchema.parse(await c.req.json());
8622
8983
  return c.json(await board2.addRunner(c.get("actor"), body.name));
8623
8984
  });
8985
+ const enrollments = deps.enrollments;
8986
+ if (enrollments !== void 0) {
8987
+ const forUser = (c) => {
8988
+ if (c.get("actor").role !== USER_ROLE) {
8989
+ throw new BoardError("FORBIDDEN", "only the user enrolls runners");
8990
+ }
8991
+ };
8992
+ api.get("/enrollments", (c) => {
8993
+ forUser(c);
8994
+ return c.json(enrollments.waiting());
8995
+ });
8996
+ api.post("/enrollments/:code/approve", async (c) => {
8997
+ forUser(c);
8998
+ const body = EnrollmentApprovalSchema.parse(await c.req.json());
8999
+ return c.json(await enrollments.approve(c.get("actor"), c.req.param("code"), body.name));
9000
+ });
9001
+ api.post("/enrollments/:code/deny", (c) => {
9002
+ forUser(c);
9003
+ enrollments.deny(c.req.param("code"));
9004
+ return c.json({ denied: true });
9005
+ });
9006
+ }
8624
9007
  api.get("/proposals", async (c) => c.json(await board2.listProposals()));
8625
9008
  api.get("/proposals/:id", async (c) => c.json(await board2.readProposal(c.req.param("id"))));
8626
9009
  api.get("/signals", async (c) => c.json(await board2.listSignals(Number(c.req.query("limit") ?? "100"))));
@@ -8654,7 +9037,7 @@ function createApp(deps) {
8654
9037
  });
8655
9038
  api.put("/roles/:name", async (c) => {
8656
9039
  const charter = RoleCharterSchema.parse({
8657
- ...z14.record(z14.string(), z14.unknown()).parse(await c.req.json()),
9040
+ ...z15.record(z15.string(), z15.unknown()).parse(await c.req.json()),
8658
9041
  name: c.req.param("name")
8659
9042
  });
8660
9043
  return c.json(await board2.setRoleCharter(c.get("actor"), charter));
@@ -8769,8 +9152,16 @@ function createApp(deps) {
8769
9152
  return c.json(result ?? null);
8770
9153
  });
8771
9154
  app2.route("/api", api);
9155
+ const trustedProxies = deps.trustedProxies ?? 0;
9156
+ const limited = (limit2, what) => rateLimit({ limiter: new RateLimiter(limit2), trustedProxies, what, log: deps.log });
9157
+ app2.route("/auth", githubAuthRoutes({
9158
+ board: board2,
9159
+ github: null,
9160
+ ...deps.signIn,
9161
+ limit: limited(SIGN_IN_LIMIT, "sign-ins")
9162
+ }));
8772
9163
  if (deps.runners !== void 0) {
8773
- app2.route("/runner", runnerRoutes(board2, deps.runners));
9164
+ app2.route("/runner", runnerRoutes(board2, deps.runners, deps.enrollments, limited(ENROLL_LIMIT, "enrollment requests")));
8774
9165
  }
8775
9166
  app2.all("/mcp", async (c) => {
8776
9167
  const token = bearer2(c);
@@ -8786,6 +9177,113 @@ function createApp(deps) {
8786
9177
  return app2;
8787
9178
  }
8788
9179
 
9180
+ // apps/server/dist/enrollment.js
9181
+ import { randomBytes as randomBytes3, randomInt } from "node:crypto";
9182
+ var ENROLLMENT_TTL_MS = 10 * 6e4;
9183
+ var POLL_INTERVAL_MS = 3e3;
9184
+ var MAX_WAITING = 100;
9185
+ var CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ23456789";
9186
+ function codeHalf() {
9187
+ return Array.from({ length: 4 }, () => CODE_ALPHABET.charAt(randomInt(CODE_ALPHABET.length))).join("");
9188
+ }
9189
+ function userCode() {
9190
+ return `${codeHalf()}-${codeHalf()}`;
9191
+ }
9192
+ function normalized(code) {
9193
+ return code.toUpperCase().replaceAll(/[^A-Z0-9]/g, "");
9194
+ }
9195
+ var EnrollmentDesk = class {
9196
+ board;
9197
+ enrollments = /* @__PURE__ */ new Map();
9198
+ now;
9199
+ pollIntervalMs;
9200
+ constructor(board2, options = {}) {
9201
+ this.board = board2;
9202
+ this.now = options.now ?? Date.now;
9203
+ this.pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
9204
+ }
9205
+ open(request) {
9206
+ this.prune();
9207
+ const waiting = [...this.enrollments.values()].filter((each) => each.status.status === "pending");
9208
+ if (waiting.length >= MAX_WAITING) {
9209
+ throw new BoardError("INVALID_STATE", `${MAX_WAITING} runners already wait for approval; try again once some have expired`);
9210
+ }
9211
+ const enrollment = {
9212
+ deviceCode: randomBytes3(32).toString("base64url"),
9213
+ userCode: userCode(),
9214
+ request,
9215
+ requestedAt: this.now(),
9216
+ expiresAt: this.now() + ENROLLMENT_TTL_MS,
9217
+ status: { status: "pending" }
9218
+ };
9219
+ this.enrollments.set(enrollment.deviceCode, enrollment);
9220
+ return {
9221
+ deviceCode: enrollment.deviceCode,
9222
+ userCode: enrollment.userCode,
9223
+ expiresAt: new Date(enrollment.expiresAt).toISOString(),
9224
+ intervalMs: this.pollIntervalMs
9225
+ };
9226
+ }
9227
+ /**
9228
+ * Where the runner's enrollment stands, or null once it is unknown or expired. An approval or a
9229
+ * denial is told once and then forgotten, so the token never travels twice.
9230
+ */
9231
+ poll(deviceCode) {
9232
+ this.prune();
9233
+ const enrollment = this.enrollments.get(deviceCode);
9234
+ if (enrollment === void 0) {
9235
+ return null;
9236
+ }
9237
+ if (enrollment.status.status !== "pending") {
9238
+ this.enrollments.delete(deviceCode);
9239
+ }
9240
+ return enrollment.status;
9241
+ }
9242
+ /** The enrollments waiting for the user, newest first. */
9243
+ waiting() {
9244
+ this.prune();
9245
+ return [...this.enrollments.values()].filter((each) => each.status.status === "pending").toSorted((a, b) => b.requestedAt - a.requestedAt).map((each) => ({
9246
+ userCode: each.userCode,
9247
+ hostname: each.request.hostname,
9248
+ os: each.request.os,
9249
+ clis: each.request.clis,
9250
+ capabilities: each.request.capabilities,
9251
+ version: each.request.version,
9252
+ requestedAt: new Date(each.requestedAt).toISOString(),
9253
+ expiresAt: new Date(each.expiresAt).toISOString()
9254
+ }));
9255
+ }
9256
+ /** Registers the runner a waiting enrollment asked for, under `name`, for its next poll. */
9257
+ async approve(actor, code, name) {
9258
+ const enrollment = this.waitingFor(code);
9259
+ const { runner, token } = await this.board.addRunner(actor, name, {
9260
+ hostname: enrollment.request.hostname
9261
+ });
9262
+ enrollment.status = { status: "approved", name: runner.name, token };
9263
+ return runner;
9264
+ }
9265
+ deny(code) {
9266
+ this.waitingFor(code).status = { status: "denied" };
9267
+ }
9268
+ waitingFor(code) {
9269
+ this.prune();
9270
+ const wanted = normalized(code);
9271
+ for (const enrollment of this.enrollments.values()) {
9272
+ if (enrollment.status.status === "pending" && normalized(enrollment.userCode) === wanted) {
9273
+ return enrollment;
9274
+ }
9275
+ }
9276
+ throw new BoardError("NOT_FOUND", `no runner waits for approval with the code ${code}`);
9277
+ }
9278
+ prune() {
9279
+ for (const [deviceCode, enrollment] of this.enrollments) {
9280
+ if (enrollment.expiresAt <= this.now()) {
9281
+ this.enrollments.delete(deviceCode);
9282
+ }
9283
+ }
9284
+ }
9285
+ };
9286
+
8789
9287
  // apps/server/dist/models.js
8790
9288
  var HOUR = 60 * 6e4;
8791
9289
  var ModelCatalog = class {
@@ -8918,10 +9416,23 @@ var app = createApp({
8918
9416
  scheduler: view,
8919
9417
  models: new ModelCatalog((cli) => runners.models(cli)),
8920
9418
  runners,
8921
- webDir
9419
+ webDir,
9420
+ signIn: { github: config.github, log },
9421
+ enrollments: new EnrollmentDesk(board),
9422
+ trustedProxies: config.trustedProxies,
9423
+ log
8922
9424
  });
8923
9425
  var server = serve({ fetch: app.fetch, port: config.port, hostname: config.host }, (info) => {
8924
- log.info({ host: info.address, port: info.port, dataDir: config.dataDir, mcpUrl, webDir, webBuilt }, "board server listening");
9426
+ log.info({
9427
+ host: info.address,
9428
+ port: info.port,
9429
+ dataDir: config.dataDir,
9430
+ mcpUrl,
9431
+ webDir,
9432
+ webBuilt,
9433
+ signIn: config.github === null ? "token" : `github (${config.github.users.join(", ")})`,
9434
+ trustedProxies: config.trustedProxies
9435
+ }, "board server listening");
8925
9436
  });
8926
9437
  await scheduler.start();
8927
9438
  log.info({