@krimto-labs/krimto 0.2.36 → 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 +12 -4
- package/package.json +1 -1
- package/src/access/membership.ts +15 -0
- package/src/cli/teamInit.ts +81 -94
- package/src/index/db.ts +26 -0
- package/src/index/schema.ts +5 -2
- package/src/server/http.ts +37 -9
- package/src/server/index.ts +33 -9
- package/src/server/membershipWatcher.ts +55 -0
- package/src/server/tools.ts +61 -1
- package/src/web/router.ts +8 -6
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.
|
|
13
|
-
> shipped end-to-end, plus
|
|
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.
|
|
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.
|
|
@@ -58,6 +58,14 @@ timestamp, reviewer).
|
|
|
58
58
|
> service into team mode itself (no copy-paste recipe, no lock conflict), saves invite keys
|
|
59
59
|
> to a 0600 backup file, and validates the git remote URL at the prompt. `krimto notes` now
|
|
60
60
|
> works from any terminal (identity falls back to `git config user.email`).
|
|
61
|
+
> - **Write-time duplicate backstop (v0.2.37)** — `krimto_write` flags a near-duplicate fact in
|
|
62
|
+
> the same scope (a `related` list + a hint to `krimto_supersede`), so memory doesn't silently
|
|
63
|
+
> accumulate two facts about the same thing even when the agent skips `krimto_recall`. Backed
|
|
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.
|
|
61
69
|
>
|
|
62
70
|
> See [ROADMAP.md](ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), and the proposal-vs-reality
|
|
63
71
|
> diff in [docs/krimto-v0.2.17-maria-journey.html §09](docs/krimto-v0.2.17-maria-journey.html)
|
|
@@ -503,7 +511,7 @@ Cline — is table stakes today, so Krimto ships it but doesn't lead with it.
|
|
|
503
511
|
## Roadmap
|
|
504
512
|
|
|
505
513
|
`v0.2` (teams, v0.2.5) → `v0.2.18` (v0.2.17 wizard redesign — published as one SemVer-clean
|
|
506
|
-
release) → `v0.2.
|
|
514
|
+
release) → `v0.2.38` (correctness + agent-friendly polish — current) → `v0.3` (OAuth + PR approval
|
|
507
515
|
flow) → `v1.0` (Krimto Cloud). See [ROADMAP.md](ROADMAP.md) for the per-release breakdown.
|
|
508
516
|
|
|
509
517
|
## License
|
package/package.json
CHANGED
package/src/access/membership.ts
CHANGED
|
@@ -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;
|
package/src/cli/teamInit.ts
CHANGED
|
@@ -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 {
|
|
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() —
|
|
79
|
+
/** Override os.homedir() — forwarded to the admin-reconnect `applyJoin` for editor detection. */
|
|
81
80
|
homeDir?: string;
|
|
82
|
-
/** When true, the
|
|
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
|
-
/**
|
|
87
|
-
|
|
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
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
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,
|
|
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
|
-
// ===
|
|
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
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
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
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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(
|
|
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
|
|
492
|
-
//
|
|
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 (
|
|
496
|
-
io.out(` 🟢 Team mode is live on http
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
io.out(`
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
io.out(`
|
|
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
|
-
// "
|
|
507
|
-
io.out(
|
|
508
|
-
io.out(`
|
|
509
|
-
io.out(
|
|
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=?");
|
package/src/index/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
-
/**
|
|
42
|
-
|
|
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.
|
|
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
|
-
|
|
120
|
-
|
|
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",
|
|
136
|
+
app.get("/mcp", maybeAuth, rateLimit, (req, res) => {
|
|
124
137
|
void handleMcp(req, res);
|
|
125
138
|
});
|
|
126
139
|
|
|
127
|
-
|
|
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.
|
|
137
|
-
|
|
163
|
+
admin: deps.admin,
|
|
164
|
+
teamModeActive: deps.teamModeActive,
|
|
165
|
+
localIdentity: deps.ctx.requester.identity,
|
|
138
166
|
status: deps.status,
|
|
139
167
|
}),
|
|
140
168
|
);
|
package/src/server/index.ts
CHANGED
|
@@ -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.
|
|
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");
|
|
@@ -121,7 +122,9 @@ export function buildServer(ctx: ToolContext, resolveRequester?: RequesterResolv
|
|
|
121
122
|
"durable fact, or when correcting a mistake you should not repeat. For the user's personal " +
|
|
122
123
|
"scope use `user/me` (the server resolves it to their identity) — do not guess an email. The " +
|
|
123
124
|
"write is rejected (with the list of scopes you may write to) if you target a scope you couldn't " +
|
|
124
|
-
"read back. Call krimto_recall first to avoid duplicates
|
|
125
|
+
"read back. Call krimto_recall first to avoid duplicates — and if the write response includes a " +
|
|
126
|
+
"`related` list, those are near-duplicates already in this scope: prefer krimto_supersede on one " +
|
|
127
|
+
"of them over leaving a second copy.",
|
|
125
128
|
inputSchema: {
|
|
126
129
|
scope: z
|
|
127
130
|
.string()
|
|
@@ -344,13 +347,29 @@ export async function main(): Promise<void> {
|
|
|
344
347
|
process.stderr.write(`Krimto embeddings: ${embeddingProvider.name} (${embeddingProvider.dimensions}d)\n`);
|
|
345
348
|
}
|
|
346
349
|
|
|
350
|
+
const membersPath = path.join(dataDir, ".krimto", "members.yaml");
|
|
347
351
|
const reloadMembership = async (): Promise<void> => {
|
|
348
|
-
|
|
349
|
-
|
|
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;
|
|
350
363
|
};
|
|
351
364
|
|
|
352
365
|
batcher.start((fn) => ctx.writeQueue.run(fn));
|
|
353
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
|
+
|
|
354
373
|
const sync = new RemoteSync(
|
|
355
374
|
repo,
|
|
356
375
|
async () => {
|
|
@@ -394,6 +413,7 @@ export async function main(): Promise<void> {
|
|
|
394
413
|
shuttingDown = true;
|
|
395
414
|
telemetry.stop();
|
|
396
415
|
sync.stop();
|
|
416
|
+
memberWatch.stop();
|
|
397
417
|
batcher.stop();
|
|
398
418
|
void ctx.writeQueue
|
|
399
419
|
.run(() => batcher.flush())
|
|
@@ -408,8 +428,12 @@ export async function main(): Promise<void> {
|
|
|
408
428
|
const httpPort = process.env.KRIMTO_HTTP_PORT ? Number(process.env.KRIMTO_HTTP_PORT) : undefined;
|
|
409
429
|
if (httpPort !== undefined && Number.isInteger(httpPort) && httpPort > 0) {
|
|
410
430
|
const rlConfig = rateLimitConfigFromEnv();
|
|
411
|
-
// Team mode
|
|
412
|
-
|
|
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";
|
|
413
437
|
const app = buildHttpApp({
|
|
414
438
|
ctx,
|
|
415
439
|
keys,
|
|
@@ -423,7 +447,7 @@ export async function main(): Promise<void> {
|
|
|
423
447
|
gitRemoteStatus: () => batcher.lastPushStatus(),
|
|
424
448
|
rateLimiter: rlConfig.enabled ? new RateLimiter(rlConfig) : undefined,
|
|
425
449
|
admin,
|
|
426
|
-
|
|
450
|
+
teamModeActive,
|
|
427
451
|
// Live status for the /ui dashboard panel — built per request so it never goes stale.
|
|
428
452
|
status: () => ({
|
|
429
453
|
gitRemoteUrl: process.env.KRIMTO_GIT_REMOTE,
|
|
@@ -443,7 +467,7 @@ export async function main(): Promise<void> {
|
|
|
443
467
|
});
|
|
444
468
|
app.listen(httpPort, () => {
|
|
445
469
|
process.stderr.write(`Krimto ${KRIMTO_VERSION} HTTP server on :${httpPort} (data: ${dataDir})\n`);
|
|
446
|
-
if (!
|
|
470
|
+
if (!teamModeActive()) {
|
|
447
471
|
process.stderr.write(localModeBanner(httpPort, dataDir, identity));
|
|
448
472
|
} else {
|
|
449
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/server/tools.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { isValidScope, type Requester } from "../access/scope";
|
|
|
9
9
|
import { canRead, canWrite, isOrgAdmin, type Membership } from "../access/membership";
|
|
10
10
|
import { FactIndex } from "../index/factIndex";
|
|
11
11
|
import { Serializer } from "../index/serialize";
|
|
12
|
-
import { rankCandidates } from "../retrieval/pipeline";
|
|
12
|
+
import { lexicalSimilarity, rankCandidates } from "../retrieval/pipeline";
|
|
13
13
|
import { type CommitBatcher } from "../storage/batcher";
|
|
14
14
|
import { type ActivityLog } from "./activity";
|
|
15
15
|
import { KrimtoError } from "./errors";
|
|
@@ -47,6 +47,12 @@ export interface WriteInput {
|
|
|
47
47
|
source?: string;
|
|
48
48
|
supersedes?: string[];
|
|
49
49
|
}
|
|
50
|
+
export interface RelatedFact {
|
|
51
|
+
id: string;
|
|
52
|
+
title: string;
|
|
53
|
+
/** Hybrid-retrieval score of the existing fact against the new one's title+body. */
|
|
54
|
+
score: number;
|
|
55
|
+
}
|
|
50
56
|
export interface WriteResult {
|
|
51
57
|
id: string;
|
|
52
58
|
scope: string;
|
|
@@ -56,6 +62,12 @@ export interface WriteResult {
|
|
|
56
62
|
/** Human-readable hint for the agent to relay back. Teaches "this is just a file you can open." */
|
|
57
63
|
hint: string;
|
|
58
64
|
commit_sha: string | null;
|
|
65
|
+
/**
|
|
66
|
+
* Existing facts in the same scope that closely resemble the one just written. Surfaced so a
|
|
67
|
+
* weak agent that skipped krimto_recall still gets a chance to krimto_supersede instead of
|
|
68
|
+
* duplicating. Omitted when nothing similar was found.
|
|
69
|
+
*/
|
|
70
|
+
related?: RelatedFact[];
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
export interface RecallInput {
|
|
@@ -148,6 +160,39 @@ function writableScopesFor(ctx: ToolContext): string[] {
|
|
|
148
160
|
return scopes;
|
|
149
161
|
}
|
|
150
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Token-cosine bar above which an existing fact is "the same thing, said again" rather than
|
|
165
|
+
* merely sharing a word. Tuned from the smoke-6 cases: a near-duplicate (pizza vs pizza+sushi)
|
|
166
|
+
* scores ~0.8 and a same-topic update (pizza vs tacos) ~0.7, while two facts that only share a
|
|
167
|
+
* generic qualifier (favorite FOOD vs favorite COLOR) score ~0.38. 0.5 sits cleanly between.
|
|
168
|
+
*/
|
|
169
|
+
const DUPLICATE_SIMILARITY_THRESHOLD = 0.5;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Existing facts in `scope` that closely resemble `${title} ${body}` — the server-side backstop
|
|
173
|
+
* for the "call krimto_recall first" rule. FTS narrows the candidate set; token cosine then
|
|
174
|
+
* filters out facts that merely share a generic word. Excludes anything the new write already
|
|
175
|
+
* supersedes (no point nagging about a fact it's replacing). Top 3, most-similar first.
|
|
176
|
+
*/
|
|
177
|
+
async function findRelatedFacts(
|
|
178
|
+
ctx: ToolContext,
|
|
179
|
+
scope: string,
|
|
180
|
+
title: string,
|
|
181
|
+
body: string,
|
|
182
|
+
supersedes: string[] | undefined,
|
|
183
|
+
now: Date,
|
|
184
|
+
): Promise<RelatedFact[]> {
|
|
185
|
+
const text = `${title} ${body}`;
|
|
186
|
+
const candidates = await ctx.index.searchCandidates(text, { readableScopes: [scope], now });
|
|
187
|
+
const excluded = new Set(supersedes ?? []);
|
|
188
|
+
return candidates
|
|
189
|
+
.filter((c) => !excluded.has(c.id))
|
|
190
|
+
.map((c) => ({ id: c.id, title: c.title, score: lexicalSimilarity(text, `${c.title} ${c.body}`) }))
|
|
191
|
+
.filter((r) => r.score >= DUPLICATE_SIMILARITY_THRESHOLD)
|
|
192
|
+
.sort((a, b) => b.score - a.score)
|
|
193
|
+
.slice(0, 3);
|
|
194
|
+
}
|
|
195
|
+
|
|
151
196
|
/** Create a new fact. Author comes from the requester identity; scope is required. */
|
|
152
197
|
export async function krimtoWrite(ctx: ToolContext, input: WriteInput): Promise<WriteResult> {
|
|
153
198
|
// "user/me"/"user/self" (and bare "me"/"self") mean the caller's own personal scope. An agent
|
|
@@ -179,6 +224,14 @@ export async function krimtoWrite(ctx: ToolContext, input: WriteInput): Promise<
|
|
|
179
224
|
});
|
|
180
225
|
}
|
|
181
226
|
return ctx.writeQueue.run(async () => {
|
|
227
|
+
// Run the near-duplicate check BEFORE the new fact is indexed, so it can't match itself.
|
|
228
|
+
// Best-effort: a failure here must never block a write — the hint is observational.
|
|
229
|
+
let related: RelatedFact[] = [];
|
|
230
|
+
try {
|
|
231
|
+
related = await findRelatedFacts(ctx, scope, input.title, input.body, input.supersedes, clock(ctx));
|
|
232
|
+
} catch {
|
|
233
|
+
/* dedup hint is advisory — never let it break the write path */
|
|
234
|
+
}
|
|
182
235
|
const fact = createFact({
|
|
183
236
|
scope,
|
|
184
237
|
title: input.title,
|
|
@@ -224,6 +277,12 @@ export async function krimtoWrite(ctx: ToolContext, input: WriteInput): Promise<
|
|
|
224
277
|
`git auto-commits every 30s, run \`npx @krimto-labs/krimto --help\` for the full CLI surface, ` +
|
|
225
278
|
`or \`npx @krimto-labs/krimto storage\` for the storage model.)`;
|
|
226
279
|
}
|
|
280
|
+
if (related.length > 0) {
|
|
281
|
+
const list = related.map((r) => `"${r.title}" (${r.id})`).join(", ");
|
|
282
|
+
hint +=
|
|
283
|
+
`\n⚠ Similar existing fact${related.length > 1 ? "s" : ""} in this scope: ${list}. ` +
|
|
284
|
+
`If this updates ${related.length > 1 ? "one of them" : "it"}, call krimto_supersede instead of leaving a duplicate.`;
|
|
285
|
+
}
|
|
227
286
|
return {
|
|
228
287
|
id: fact.frontmatter.id,
|
|
229
288
|
scope: fact.frontmatter.scope,
|
|
@@ -231,6 +290,7 @@ export async function krimtoWrite(ctx: ToolContext, input: WriteInput): Promise<
|
|
|
231
290
|
absolute_path: absolutePath,
|
|
232
291
|
hint,
|
|
233
292
|
commit_sha: null,
|
|
293
|
+
...(related.length > 0 ? { related } : {}),
|
|
234
294
|
};
|
|
235
295
|
});
|
|
236
296
|
}
|
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
|
-
/**
|
|
26
|
-
|
|
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.
|
|
68
|
-
(req as WithIdentity).identity = deps.localIdentity; //
|
|
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:
|
|
89
|
+
page(res, 200, "Connect", connectPanel({ host, requireAuth: deps.teamModeActive() }), idOf(req));
|
|
88
90
|
});
|
|
89
91
|
|
|
90
92
|
router.get("/settings", (req, res) => {
|