@krimto-labs/krimto 0.2.37 → 0.2.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,8 +9,8 @@ place and reads the right slice of it — Alice's preferences override the team'
9
9
  conventions override the org's standards, and every fact carries a paper trail (author, source,
10
10
  timestamp, reviewer).
11
11
 
12
- > **Where we are:** **v0.2.37** is the current release — the v0.2.17 wizard redesign is now
13
- > shipped end-to-end, plus twenty patch releases of correctness fixes and agent-friendly
12
+ > **Where we are:** **v0.2.38** is the current release — the v0.2.17 wizard redesign is now
13
+ > shipped end-to-end, plus twenty-one patch releases of correctness fixes and agent-friendly
14
14
  > surface. The v0.2.16 architecture (markdown-in-git storage, `user → team → org` hierarchy,
15
15
  > hybrid retrieval, server-enforced access, two-way git sync, MCP over stdio + HTTP, the Docker
16
16
  > image, the web UI) is unchanged. What you get on top of v0.2.16:
@@ -41,7 +41,7 @@ timestamp, reviewer).
41
41
  > first, waits for `:8080` to accept TCP, then writes editor configs. Cursor's file
42
42
  > watcher never fires into an unbound port (the v0.2.27/28 ECONNREFUSED fix).
43
43
  >
44
- > **The agent story (v0.2.34 → v0.2.37).**
44
+ > **The agent story (v0.2.34 → v0.2.38).**
45
45
  > - **Phase B agent flags** — `editors --add cursor`, `service --always`, `search --keyword`,
46
46
  > `reset --yes`, `remote --set <url>`, `folder --to <path>`. Every command that used to
47
47
  > open an interactive prompt now has a flag form.
@@ -62,6 +62,10 @@ timestamp, reviewer).
62
62
  > the same scope (a `related` list + a hint to `krimto_supersede`), so memory doesn't silently
63
63
  > accumulate two facts about the same thing even when the agent skips `krimto_recall`. Backed
64
64
  > by a new retrieval-quality eval that asserts recall returns the right fact first.
65
+ > - **Team mode activates live (v0.2.38)** — `krimto team init` writes `members.yaml` and the
66
+ > running server flips to team mode on its own within ~2s (it polls the file) — no restart, no
67
+ > env var. The wizard verifies auth is genuinely enforced before printing "🟢 live", and
68
+ > auto-reconnects the admin's own editor with their key.
65
69
  >
66
70
  > See [ROADMAP.md](ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), and the proposal-vs-reality
67
71
  > diff in [docs/krimto-v0.2.17-maria-journey.html §09](docs/krimto-v0.2.17-maria-journey.html)
@@ -507,7 +511,7 @@ Cline — is table stakes today, so Krimto ships it but doesn't lead with it.
507
511
  ## Roadmap
508
512
 
509
513
  `v0.2` (teams, v0.2.5) → `v0.2.18` (v0.2.17 wizard redesign — published as one SemVer-clean
510
- release) → `v0.2.37` (correctness + agent-friendly polish — current) → `v0.3` (OAuth + PR approval
514
+ release) → `v0.2.38` (correctness + agent-friendly polish — current) → `v0.3` (OAuth + PR approval
511
515
  flow) → `v1.0` (Krimto Cloud). See [ROADMAP.md](ROADMAP.md) for the per-release breakdown.
512
516
 
513
517
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krimto-labs/krimto",
3
- "version": "0.2.37",
3
+ "version": "0.2.38",
4
4
  "description": "Open-source team memory layer for AI agents — markdown files in git, user/team/org hierarchy, cross-vendor MCP server.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -80,6 +80,21 @@ export function isOrgAdmin(m: Membership, email: string): boolean {
80
80
  return m.org.admins.includes(email);
81
81
  }
82
82
 
83
+ /** True when the org has at least one admin — the live signal that team mode should be enforced. */
84
+ export function hasOrgAdmin(m: Membership): boolean {
85
+ return m.org.admins.length > 0;
86
+ }
87
+
88
+ /**
89
+ * Guard for LIVE membership reloads: refuse to adopt a reload that would drop the last admin,
90
+ * because that silently turns auth OFF on a running server. A transient/mid-write read that
91
+ * momentarily parses zero admins must NOT open an auth-off window. Turning team mode off is a
92
+ * deliberate, restart-gated operator action — never an automatic side effect of a file watch.
93
+ */
94
+ export function shouldAdoptReload(current: Membership, next: Membership): boolean {
95
+ return !(hasOrgAdmin(current) && !hasOrgAdmin(next));
96
+ }
97
+
83
98
  export function isTeamLead(m: Membership, teamSlug: string, email: string): boolean {
84
99
  const team = m.teams.find((t) => t.slug === teamSlug);
85
100
  return team ? team.leads.includes(email) : false;
@@ -22,9 +22,8 @@ import { ApiKeyStore } from "../access/auth";
22
22
  import { addUser, createTeam } from "../access/membershipStore";
23
23
  import { bootstrapAdmin } from "../server/bootstrap";
24
24
  import { defaultIdentity } from "./init";
25
- import { inspectRuntime } from "./inspectRuntime";
25
+ import { applyJoin } from "./join";
26
26
  import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
27
- import { detectPlatform, installService } from "./service";
28
27
  import { looksLikeRemoteUrl, runSetupRemote } from "./setupRemote";
29
28
 
30
29
  /** Everything the wizard collects before it calls {@link applyTeamInit}. */
@@ -77,18 +76,26 @@ export interface TeamInitOptions {
77
76
  keysPath?: string;
78
77
  /** Override KRIMTO_HTTP_PORT detection. */
79
78
  port?: number;
80
- /** Override os.homedir() — passed to inspectRuntime + installService for tests. */
79
+ /** Override os.homedir() — forwarded to the admin-reconnect `applyJoin` for editor detection. */
81
80
  homeDir?: string;
82
- /** When true, the post-apply service-restart step writes files but never invokes
83
- * launchctl/systemctl/schtasks. Tests use this to assert the new env block without
84
- * mutating CI's user services. */
81
+ /** When true, the admin-reconnect `applyJoin` writes nothing to real editor configs. Tests use this. */
85
82
  dryRun?: boolean;
86
- /** Suppress the post-apply service-restart probe entirely (tests that don't exercise it). */
87
- skipServiceRestart?: boolean;
83
+ /** Inject the live-team-mode probe (tests). Defaults to the real {@link confirmTeamModeLive}
84
+ * which polls the running server's /mcp for a 401. */
85
+ confirmLive?: (host: string) => Promise<LiveOutcome>;
88
86
  /** Skip `runSetupRemote` even when a URL was given. Tests use this. */
89
87
  skipRemoteSetup?: boolean;
90
88
  }
91
89
 
90
+ /**
91
+ * Result of verifying that the running server is ACTUALLY enforcing team mode (not just that a
92
+ * port is open). The old wizard printed "🟢 live" off a TCP probe and lied; this is the honest signal.
93
+ * - "live" — the server returned 401 on /mcp ⇒ bearer required ⇒ team mode enforced.
94
+ * - "no-server" — nothing reachable at the host (start one with `krimto serve`).
95
+ * - "timeout" — a server responded but never 401 within the window (it polls members.yaml ~2s).
96
+ */
97
+ export type LiveOutcome = "live" | "no-server" | "timeout";
98
+
92
99
  const SLUG_RE = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/;
93
100
  const EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
94
101
 
@@ -251,13 +258,31 @@ export async function runTeamInit(opts: TeamInitOptions = {}): Promise<TeamInitR
251
258
  opts,
252
259
  );
253
260
 
254
- // Probe runtime + offer to restart the always-running service so team-mode auth takes
255
- // effect on the SAME machine, in the SAME wizard run. The smoke-6 transcript ended with
256
- // a copy-paste "Next" recipe (`KRIMTO_BOOTSTRAP_ADMIN=... npx serve`) the user couldn't
257
- // run because their existing service held the data-dir lock. This closes that gap.
258
- const restartOutcome = await maybeRestartServiceForTeamMode(result, opts);
261
+ // No restart, no env var: writing members.yaml IS the switch. The running server polls the
262
+ // file (~2s) and flips to team mode on its own. We just VERIFY it actually happened — poll
263
+ // /mcp for a 401 instead of the old TCP probe that falsely printed "🟢 live".
264
+ const live = await (opts.confirmLive ?? confirmTeamModeLive)(result.serverHost);
265
+
266
+ // When team mode is confirmed live, reconnect the admin's OWN editors in team mode (their
267
+ // solo/no-key connection would otherwise start getting 401s). Reuses the teammate join path.
268
+ let reconnected = false;
269
+ if (live === "live" && result.adminKey) {
270
+ try {
271
+ const join = await applyJoin(
272
+ { server: `http://${result.serverHost}`, key: result.adminKey },
273
+ {
274
+ cwd: process.cwd(),
275
+ ...(opts.homeDir ? { homeDir: opts.homeDir } : {}),
276
+ ...(opts.dryRun ? { dryRun: opts.dryRun } : {}),
277
+ },
278
+ );
279
+ reconnected = join.editorOutcomes.some((o) => o.mcpAction !== "manual");
280
+ } catch {
281
+ /* best-effort — the admin can run `krimto join` manually if this fails */
282
+ }
283
+ }
259
284
 
260
- printApplyResult(result, io, restartOutcome);
285
+ printApplyResult(result, io, { live, reconnected });
261
286
  return result;
262
287
  } catch (e) {
263
288
  if (isExitPrompt(e)) {
@@ -352,74 +377,33 @@ async function askTeammates(): Promise<string[]> {
352
377
  return list;
353
378
  }
354
379
 
355
- // === Post-apply service restart =============================================
356
-
357
- /**
358
- * What happened when the wizard tried to flip the running service into team mode after apply.
359
- * Drives the "Next" block in {@link printApplyResult}: when team mode is live, we don't print
360
- * the copy-paste `KRIMTO_BOOTSTRAP_ADMIN=... npx serve` recipe.
361
- */
362
- export type RestartOutcome =
363
- | { kind: "no-service" }
364
- | { kind: "declined" }
365
- | { kind: "restarted"; portReady: boolean }
366
- | { kind: "failed"; error: string };
380
+ // === Verify team mode is actually live ======================================
367
381
 
368
382
  /**
369
- * If the running Krimto IS the always-running service we installed, offer to restart it with
370
- * `KRIMTO_BOOTSTRAP_ADMIN` baked in so team-mode auth takes effect immediately. Returns the
371
- * outcome for the print step to render. Skipped silently when {@link TeamInitOptions.skipServiceRestart}
372
- * is set (tests that don't want to exercise this path).
383
+ * Poll the running server's `/mcp` until it returns 401 the precise signal that bearer auth is
384
+ * being enforced (team mode is genuinely ON). Writing members.yaml flips the server within its
385
+ * ~2s membership-watch tick, so we give it a short window. Returns "live" on 401, "no-server"
386
+ * when nothing was ever reachable, "timeout" when a server responded but never 401 in time.
387
+ *
388
+ * This replaces the v0.2.36 restart dance: there's nothing to restart anymore — the file is the
389
+ * switch. We only confirm it took effect (the old code printed "🟢 live" off a TCP probe and lied).
373
390
  */
374
- async function maybeRestartServiceForTeamMode(
375
- result: TeamInitResult,
376
- opts: TeamInitOptions,
377
- ): Promise<RestartOutcome> {
378
- if (opts.skipServiceRestart) return { kind: "no-service" };
379
- const io = opts.io ?? defaultIO;
380
-
381
- const runtime = await inspectRuntime(
382
- result.dataDir,
383
- opts.homeDir ? { homeDir: opts.homeDir } : {},
384
- );
385
- if (!runtime.service.loaded || runtime.effectiveLaunchedBy !== "service") {
386
- return { kind: "no-service" };
387
- }
388
-
389
- const wantRestart = await confirm({
390
- message: "Restart the running Krimto service so team mode is enforced now? (~3s of downtime)",
391
- default: true,
392
- });
393
- if (!wantRestart) return { kind: "declined" };
394
-
395
- io.out("\n Restarting service in team mode...\n");
396
- try {
397
- const install = await installService(
398
- {
399
- binPath: process.execPath,
400
- args: [process.argv[1] ?? "krimto", "serve"],
401
- env: {
402
- KRIMTO_IDENTITY: result.adminEmail,
403
- KRIMTO_DATA: result.dataDir,
404
- KRIMTO_HTTP_PORT: "8080",
405
- KRIMTO_BOOTSTRAP_ADMIN: result.adminEmail,
406
- },
407
- ...(opts.homeDir ? { homeDir: opts.homeDir } : {}),
408
- },
409
- {
410
- platform: detectPlatform(),
411
- ...(opts.dryRun ? { dryRun: opts.dryRun } : {}),
412
- },
413
- );
414
- return { kind: "restarted", portReady: install.portReady !== false };
415
- } catch (e) {
416
- return { kind: "failed", error: e instanceof Error ? e.message : String(e) };
391
+ export async function confirmTeamModeLive(host: string, timeoutMs = 8000): Promise<LiveOutcome> {
392
+ const deadline = Date.now() + timeoutMs;
393
+ let reached = false;
394
+ while (Date.now() < deadline) {
395
+ try {
396
+ const res = await fetch(`http://${host}/mcp`, { method: "GET" });
397
+ reached = true;
398
+ if (res.status === 401) return "live";
399
+ } catch {
400
+ /* not reachable yet */
401
+ }
402
+ await new Promise((r) => setTimeout(r, 500));
417
403
  }
404
+ return reached ? "timeout" : "no-server";
418
405
  }
419
406
 
420
- // Exposed for the integration test that asserts on the env block written to the plist.
421
- export { maybeRestartServiceForTeamMode };
422
-
423
407
  // === Pretty printing ========================================================
424
408
 
425
409
  function printPreamble(dataDir: string, io: WizardIO): void {
@@ -439,7 +423,11 @@ function printSummary(a: TeamInitAnswers, io: WizardIO): void {
439
423
  io.out(` Teammates: ${a.teammates.length === 0 ? "(none yet)" : a.teammates.join(", ")}\n\n`);
440
424
  }
441
425
 
442
- function printApplyResult(res: TeamInitResult, io: WizardIO, restart: RestartOutcome): void {
426
+ function printApplyResult(
427
+ res: TeamInitResult,
428
+ io: WizardIO,
429
+ status: { live: LiveOutcome; reconnected: boolean },
430
+ ): void {
443
431
  io.out(" ✓ Admin promoted + members.yaml updated\n");
444
432
  io.out(` ✓ Team "${res.teamSlug}" created\n`);
445
433
  if (res.invites.length > 0) {
@@ -488,25 +476,24 @@ function printApplyResult(res: TeamInitResult, io: WizardIO, restart: RestartOut
488
476
  io.out(` (mode 0600 — read by you only. Delete it once teammates have their keys.)\n\n`);
489
477
  }
490
478
 
491
- // The "Next" block depends on whether the running service was just flipped into team mode.
492
- // When it was, the user's already done just hand them the dashboard URL. When it wasn't
493
- // (no service, declined, or install failed), fall back to the original copy-paste recipe.
479
+ // The "Next" block reflects the VERIFIED state of the running server (members.yaml is the
480
+ // switch no restart). "🟢 live" is only printed when /mcp actually returned 401.
494
481
  io.out("━━ Next ━━\n\n");
495
- if (restart.kind === "restarted" && restart.portReady) {
496
- io.out(` 🟢 Team mode is live on http://localhost:8080\n`);
497
- io.out(` • View the dashboard at http://localhost:8080/ui/admin\n`);
498
- } else if (restart.kind === "restarted" && !restart.portReady) {
499
- io.out(` ⚠ Service restarted in team mode, but the port didn't come up in 10s.\n`);
500
- io.out(` Check /tmp/com.krimto.server.err.log (macOS) or \`journalctl --user -u krimto\` (Linux).\n`);
501
- io.out(` View the dashboard at http://localhost:8080/ui/admin (once the port is up)\n`);
502
- } else if (restart.kind === "failed") {
503
- io.out(` Service restart failed: ${restart.error}\n`);
504
- io.out(` Start it yourself: KRIMTO_BOOTSTRAP_ADMIN=${res.adminEmail} npx @krimto-labs/krimto serve\n`);
482
+ if (status.live === "live") {
483
+ io.out(` 🟢 Team mode is live on http://${res.serverHost}\n`);
484
+ if (status.reconnected) {
485
+ io.out(` Reconnected your editor in team mode — restart it once to pick up the key.\n`);
486
+ }
487
+ io.out(` View the dashboard at http://${res.serverHost}/ui/admin (sign in with your admin key)\n`);
488
+ } else if (status.live === "timeout") {
489
+ io.out(` A server is running at ${res.serverHost} but team mode hasn't taken effect yet.\n`);
490
+ io.out(` It re-reads members.yaml every ~2s — give it a moment, then reload /ui/admin.\n`);
505
491
  } else {
506
- // "declined" or "no-service" — print the original recipe (still without the literal `$`).
507
- io.out("Start the server in team mode (if not already):\n");
508
- io.out(` KRIMTO_BOOTSTRAP_ADMIN=${res.adminEmail} npx @krimto-labs/krimto serve\n`);
509
- io.out(" • View the dashboard at http://localhost:8080/ui/admin\n");
492
+ // "no-server" — nothing reachable. With members.yaml written, a plain serve starts in team mode.
493
+ io.out(`No running server found at ${res.serverHost}. Start one (it reads members.yaml and\n`);
494
+ io.out(` comes up in team mode — no env var needed):\n`);
495
+ io.out(` npx @krimto-labs/krimto serve\n`);
496
+ io.out(` • Then open http://${res.serverHost}/ui/admin and sign in with your admin key.\n`);
510
497
  }
511
498
  io.out(" • Step back to solo with `krimto team disband` (data preserved)\n\n");
512
499
  }
package/src/index/db.ts CHANGED
@@ -21,7 +21,21 @@ export function openIndexDb(path: string, config: IndexConfig): Db {
21
21
  if (path !== ":memory:") db.pragma("journal_mode = WAL");
22
22
  db.pragma("foreign_keys = ON");
23
23
  db.transaction(() => {
24
+ // A virtual table's tokenizer is fixed at CREATE; `CREATE ... IF NOT EXISTS` won't change an
25
+ // existing facts_fts. So when an older index is opened, drop facts_fts + its sync triggers,
26
+ // let SCHEMA_SQL recreate them with the current tokenizer, then rebuild the FTS index from the
27
+ // (untouched) content table. The `facts` rows survive — only the derived FTS index is rebuilt.
28
+ const prior = readStoredSchemaVersion(db);
29
+ const ftsMigration = prior !== null && prior < SCHEMA_VERSION;
30
+ if (ftsMigration) {
31
+ db.exec(
32
+ "DROP TRIGGER IF EXISTS facts_ai; DROP TRIGGER IF EXISTS facts_ad; DROP TRIGGER IF EXISTS facts_au; DROP TABLE IF EXISTS facts_fts;",
33
+ );
34
+ }
24
35
  db.exec(SCHEMA_SQL);
36
+ if (ftsMigration) {
37
+ db.exec("INSERT INTO facts_fts(facts_fts) VALUES('rebuild');");
38
+ }
25
39
  if (config.provider !== "none" && config.dimensions > 0) {
26
40
  db.exec(vecTableSql(config.dimensions));
27
41
  }
@@ -35,6 +49,18 @@ export function openIndexDb(path: string, config: IndexConfig): Db {
35
49
  return db;
36
50
  }
37
51
 
52
+ /** The schema_version stored in a pre-existing index, or null when the table isn't there yet. */
53
+ function readStoredSchemaVersion(db: Db): number | null {
54
+ try {
55
+ const row = db.prepare("SELECT value FROM schema_meta WHERE key='schema_version'").get() as
56
+ | { value: string }
57
+ | undefined;
58
+ return row ? Number(row.value) : null;
59
+ } catch {
60
+ return null; // schema_meta doesn't exist yet (fresh database)
61
+ }
62
+ }
63
+
38
64
  /** True when the stored embedding space differs from the configured one (forces rebuild). */
39
65
  export function embeddingSpaceChanged(db: Db, config: IndexConfig): boolean {
40
66
  const get = db.prepare("select value from schema_meta where key=?");
@@ -2,7 +2,10 @@
2
2
  // (divergence: spec hardcodes float[1536]); FTS5 is an external-content table kept in
3
3
  // sync by triggers (canonical sqlite.org pattern).
4
4
 
5
- export const SCHEMA_VERSION = 1;
5
+ // Bumped to 2 in v0.2.38: facts_fts gains the Porter stemmer so a singular query ("favorite
6
+ // color") matches a plural-titled fact ("Favorite colors"). openIndexDb migrates older indexes
7
+ // by dropping + recreating facts_fts and rebuilding it from the content table.
8
+ export const SCHEMA_VERSION = 2;
6
9
 
7
10
  /** Static DDL (everything except the dimension-parameterized vec table). */
8
11
  export const SCHEMA_SQL = `
@@ -22,7 +25,7 @@ CREATE TABLE IF NOT EXISTS facts (
22
25
  );
23
26
 
24
27
  CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
25
- title, body, tags, content='facts', content_rowid='rowid'
28
+ title, body, tags, content='facts', content_rowid='rowid', tokenize='porter unicode61'
26
29
  );
27
30
 
28
31
  CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
@@ -38,8 +38,12 @@ export interface HttpAppDeps {
38
38
  rateLimiter?: RateLimiter;
39
39
  /** When set, mounts the admin-only membership/key API at /admin and enables /ui/admin. */
40
40
  admin?: AdminContext;
41
- /** True = team mode (auth on /mcp + /ui login + /admin). False = local mode (no auth). */
42
- requireAuth: boolean;
41
+ /**
42
+ * Live predicate for team mode (auth on /mcp + /ui login + /admin). Evaluated PER REQUEST, not
43
+ * captured once — so when `members.yaml` gains an admin the running server flips to team mode
44
+ * without a restart. True ⇒ enforce auth; false ⇒ local/solo mode (no auth).
45
+ */
46
+ teamModeActive: () => boolean;
43
47
  /** Live status snapshot for the /ui dashboard status panel. */
44
48
  status?: () => StatusPanelOpts;
45
49
  /** Called once, on the first request to /mcp (any verb). Powers the "🟢 client connected" boot hint. */
@@ -86,7 +90,7 @@ export function buildHttpApp(deps: HttpAppDeps): Express {
86
90
  // call. The resolver enriches the Requester with `source` so krimtoWrite can stamp facts
87
91
  // with "cursor" / "claude-code" / etc. when the caller didn't pass `source` explicitly.
88
92
  const source = userAgentToSource(req.get("User-Agent"));
89
- const baseResolver = deps.requireAuth
93
+ const baseResolver = deps.teamModeActive()
90
94
  ? (extra: { authInfo?: AuthInfo }) => requesterFromAuth(extra.authInfo)
91
95
  : (() => deps.ctx.requester) as (extra: { authInfo?: AuthInfo }) => Requester;
92
96
  const resolver: (extra: { authInfo?: AuthInfo }) => Requester = source
@@ -116,15 +120,38 @@ export function buildHttpApp(deps: HttpAppDeps): Express {
116
120
  }
117
121
  next();
118
122
  };
119
- const mcpChain: RequestHandler[] = deps.requireAuth ? [auth, rateLimit] : [rateLimit];
120
- app.post("/mcp", ...mcpChain, (req, res) => {
123
+ // Per-request gate: run bearer auth only when team mode is active right now. In solo mode the
124
+ // request falls straight through to rateLimit + handler (req.auth stays unset; rateLimit keys
125
+ // on "anonymous"). Mounted always so a live flip to team mode takes effect with no rebuild.
126
+ const maybeAuth: RequestHandler = (req, res, next) => {
127
+ if (deps.teamModeActive()) {
128
+ auth(req, res, next);
129
+ } else {
130
+ next();
131
+ }
132
+ };
133
+ app.post("/mcp", maybeAuth, rateLimit, (req, res) => {
121
134
  void handleMcp(req, res);
122
135
  });
123
- app.get("/mcp", ...mcpChain, (req, res) => {
136
+ app.get("/mcp", maybeAuth, rateLimit, (req, res) => {
124
137
  void handleMcp(req, res);
125
138
  });
126
139
 
127
- if (deps.requireAuth && deps.admin) app.use("/admin", auth, buildAdminRouter(deps.admin));
140
+ // /admin is mounted whenever an admin context exists, but each request is gated on live team
141
+ // mode: in solo mode it 404s (invisible), in team mode it requires the admin bearer key.
142
+ if (deps.admin) {
143
+ app.use(
144
+ "/admin",
145
+ (req, res, next) => {
146
+ if (deps.teamModeActive()) {
147
+ auth(req, res, next);
148
+ } else {
149
+ res.sendStatus(404);
150
+ }
151
+ },
152
+ buildAdminRouter(deps.admin),
153
+ );
154
+ }
128
155
 
129
156
  app.use(
130
157
  "/ui",
@@ -133,8 +160,9 @@ export function buildHttpApp(deps: HttpAppDeps): Express {
133
160
  keys: deps.keys,
134
161
  membership: deps.membership,
135
162
  sessionSecret: sessionConfigFromEnv().secret,
136
- admin: deps.requireAuth ? deps.admin : undefined,
137
- localIdentity: deps.requireAuth ? undefined : deps.ctx.requester.identity,
163
+ admin: deps.admin,
164
+ teamModeActive: deps.teamModeActive,
165
+ localIdentity: deps.ctx.requester.identity,
138
166
  status: deps.status,
139
167
  }),
140
168
  );
@@ -34,12 +34,13 @@ import { z } from "zod";
34
34
  import { FactStore } from "../storage/store";
35
35
  import { GitRepo } from "../storage/git";
36
36
  import { CommitBatcher, batcherConfigFromEnv } from "../storage/batcher";
37
- import { loadMembership, requesterFor } from "../access/membership";
37
+ import { hasOrgAdmin, loadMembership, parseMembership, requesterFor, shouldAdoptReload } from "../access/membership";
38
38
  import { createEmbeddingProvider, embeddingConfigFromEnv } from "../index/providers";
39
39
  import { openIndexDb, embeddingSpaceChanged, type IndexConfig } from "../index/db";
40
40
  import { FactIndex } from "../index/factIndex";
41
41
  import { Serializer } from "../index/serialize";
42
42
  import { RemoteSync, syncConfigFromEnv } from "../storage/sync";
43
+ import { MembershipWatcher } from "./membershipWatcher";
43
44
  import { KrimtoError } from "./errors";
44
45
  import {
45
46
  krimtoListScopes,
@@ -54,7 +55,7 @@ import { type Requester } from "../access/scope";
54
55
 
55
56
  export type RequesterResolver = (extra: { authInfo?: AuthInfo }) => Requester;
56
57
 
57
- export const KRIMTO_VERSION = "0.2.37";
58
+ export const KRIMTO_VERSION = "0.2.38";
58
59
 
59
60
  export function resolveDataDir(): string {
60
61
  return process.env.KRIMTO_DATA ?? path.join(homedir(), ".krimto");
@@ -346,13 +347,29 @@ export async function main(): Promise<void> {
346
347
  process.stderr.write(`Krimto embeddings: ${embeddingProvider.name} (${embeddingProvider.dimensions}d)\n`);
347
348
  }
348
349
 
350
+ const membersPath = path.join(dataDir, ".krimto", "members.yaml");
349
351
  const reloadMembership = async (): Promise<void> => {
350
- membership = await loadMembership(dataDir);
351
- ctx.membership = membership;
352
+ let next: ReturnType<typeof parseMembership>;
353
+ try {
354
+ next = parseMembership(await fs.readFile(membersPath, "utf8"));
355
+ } catch {
356
+ return; // read/parse failure — keep the current membership; never blank out auth
357
+ }
358
+ // Safety: never flip team→solo on a (possibly transient, mid-write) zero-admin read — that
359
+ // would open an auth-off window. See shouldAdoptReload for the rationale.
360
+ if (!shouldAdoptReload(membership, next)) return;
361
+ membership = next;
362
+ ctx.membership = next;
352
363
  };
353
364
 
354
365
  batcher.start((fn) => ctx.writeQueue.run(fn));
355
366
 
367
+ // Live team-mode trigger: poll members.yaml so a `krimto team init` (a separate CLI process)
368
+ // flips this running server into team mode within ~2s — no restart. Runs unconditionally so a
369
+ // solo→team transition is picked up. Reload goes through the write serializer.
370
+ const memberWatch = new MembershipWatcher(membersPath, reloadMembership);
371
+ memberWatch.start((fn) => ctx.writeQueue.run(fn));
372
+
356
373
  const sync = new RemoteSync(
357
374
  repo,
358
375
  async () => {
@@ -396,6 +413,7 @@ export async function main(): Promise<void> {
396
413
  shuttingDown = true;
397
414
  telemetry.stop();
398
415
  sync.stop();
416
+ memberWatch.stop();
399
417
  batcher.stop();
400
418
  void ctx.writeQueue
401
419
  .run(() => batcher.flush())
@@ -410,8 +428,12 @@ export async function main(): Promise<void> {
410
428
  const httpPort = process.env.KRIMTO_HTTP_PORT ? Number(process.env.KRIMTO_HTTP_PORT) : undefined;
411
429
  if (httpPort !== undefined && Number.isInteger(httpPort) && httpPort > 0) {
412
430
  const rlConfig = rateLimitConfigFromEnv();
413
- // Team mode (auth) when an admin is bootstrapped or auth is explicitly required; else local mode.
414
- const requireAuth = Boolean(process.env.KRIMTO_BOOTSTRAP_ADMIN || process.env.KRIMTO_REQUIRE_AUTH === "1");
431
+ // Team mode is derived LIVE from membership: any org admin enforce auth. KRIMTO_BOOTSTRAP_ADMIN
432
+ // still works because it seeds an admin into members.yaml before this point; the explicit
433
+ // KRIMTO_REQUIRE_AUTH=1 override remains. Evaluated per request (via the getter), so a later
434
+ // `members.yaml` edit flips the running server with no restart.
435
+ const teamModeActive = (): boolean =>
436
+ hasOrgAdmin(membership) || process.env.KRIMTO_REQUIRE_AUTH === "1";
415
437
  const app = buildHttpApp({
416
438
  ctx,
417
439
  keys,
@@ -425,7 +447,7 @@ export async function main(): Promise<void> {
425
447
  gitRemoteStatus: () => batcher.lastPushStatus(),
426
448
  rateLimiter: rlConfig.enabled ? new RateLimiter(rlConfig) : undefined,
427
449
  admin,
428
- requireAuth,
450
+ teamModeActive,
429
451
  // Live status for the /ui dashboard panel — built per request so it never goes stale.
430
452
  status: () => ({
431
453
  gitRemoteUrl: process.env.KRIMTO_GIT_REMOTE,
@@ -445,7 +467,7 @@ export async function main(): Promise<void> {
445
467
  });
446
468
  app.listen(httpPort, () => {
447
469
  process.stderr.write(`Krimto ${KRIMTO_VERSION} HTTP server on :${httpPort} (data: ${dataDir})\n`);
448
- if (!requireAuth) {
470
+ if (!teamModeActive()) {
449
471
  process.stderr.write(localModeBanner(httpPort, dataDir, identity));
450
472
  } else {
451
473
  process.stderr.write(teamModeBanner({ host: `localhost:${httpPort}`, key: bootstrapKey, dataDir }));
@@ -0,0 +1,55 @@
1
+ // Live team-mode trigger. `krimto team init` writes members.yaml from a SEPARATE CLI process, so
2
+ // the running server never learns about the new admin on its own. This watcher polls the file's
3
+ // mtime (~2s) and fires a reload when it changes — flipping the server into team mode without a
4
+ // restart. An mtime poll (not fs.watch) is deliberate: fs.watch is flaky with atomic-rename writes
5
+ // and varies across platforms; a poll matches the existing setInterval pattern (batcher/sync).
6
+
7
+ import { promises as fs } from "node:fs";
8
+
9
+ /** Runs a task with exclusive access (the write serializer in production). Mirrors RemoteSync. */
10
+ export type RunExclusive = (task: () => Promise<unknown>) => Promise<unknown>;
11
+
12
+ export const DEFAULT_MEMBERSHIP_WATCH_MS = 2000;
13
+
14
+ export class MembershipWatcher {
15
+ private timer: ReturnType<typeof setInterval> | undefined;
16
+ private lastMtime = 0;
17
+
18
+ constructor(
19
+ private readonly file: string,
20
+ private readonly onChange: () => Promise<void>,
21
+ private readonly intervalMs: number = DEFAULT_MEMBERSHIP_WATCH_MS,
22
+ ) {}
23
+
24
+ /**
25
+ * One poll: fire `onChange` iff the file's mtime differs from the last seen value. An absent or
26
+ * unreadable file is a no-op (solo stays solo). Exposed so tests can drive it deterministically.
27
+ */
28
+ async checkOnce(): Promise<void> {
29
+ let mtime: number;
30
+ try {
31
+ mtime = (await fs.stat(this.file)).mtimeMs;
32
+ } catch {
33
+ return; // file not there yet — nothing to flip
34
+ }
35
+ if (mtime === this.lastMtime) return; // debounce: only react to a real change
36
+ this.lastMtime = mtime;
37
+ await this.onChange();
38
+ }
39
+
40
+ /** Begin polling. `runExclusive` serializes each reload against the batcher/sync write path. */
41
+ start(runExclusive: RunExclusive): void {
42
+ if (this.timer) return;
43
+ this.timer = setInterval(() => {
44
+ void runExclusive(() => this.checkOnce());
45
+ }, this.intervalMs);
46
+ if (typeof this.timer.unref === "function") this.timer.unref();
47
+ }
48
+
49
+ stop(): void {
50
+ if (this.timer) {
51
+ clearInterval(this.timer);
52
+ this.timer = undefined;
53
+ }
54
+ }
55
+ }
package/src/web/router.ts CHANGED
@@ -20,10 +20,12 @@ export interface WebRouterDeps {
20
20
  keys: ApiKeyStore;
21
21
  membership: () => Membership;
22
22
  sessionSecret: string;
23
- /** When set, enables the admin-only /ui/admin page. */
23
+ /** When set, enables the admin-only /ui/admin page (only reachable in team mode). */
24
24
  admin?: AdminContext;
25
- /** When set (local mode), skip login and use this identity for every request. */
26
- localIdentity?: string;
25
+ /** Live team-mode predicate. Evaluated per request: team require login; solo use localIdentity. */
26
+ teamModeActive: () => boolean;
27
+ /** The local/solo identity — used (no login) whenever team mode is NOT active. Always provided. */
28
+ localIdentity: string;
27
29
  /** Live status snapshot for the /ui/facts status panel. Called per request so it's never stale. */
28
30
  status?: () => StatusPanelOpts;
29
31
  }
@@ -64,8 +66,8 @@ export function buildWebRouter(deps: WebRouterDeps): Router {
64
66
  });
65
67
 
66
68
  router.use((req, res, next) => {
67
- if (deps.localIdentity) {
68
- (req as WithIdentity).identity = deps.localIdentity; // local mode: no login
69
+ if (!deps.teamModeActive()) {
70
+ (req as WithIdentity).identity = deps.localIdentity; // solo mode: no login
69
71
  next();
70
72
  return;
71
73
  }
@@ -84,7 +86,7 @@ export function buildWebRouter(deps: WebRouterDeps): Router {
84
86
 
85
87
  router.get("/connect", (req, res) => {
86
88
  const host = typeof req.headers.host === "string" ? req.headers.host : "localhost:8080";
87
- page(res, 200, "Connect", connectPanel({ host, requireAuth: !deps.localIdentity }), idOf(req));
89
+ page(res, 200, "Connect", connectPanel({ host, requireAuth: deps.teamModeActive() }), idOf(req));
88
90
  });
89
91
 
90
92
  router.get("/settings", (req, res) => {