chapterhouse 0.9.2 → 0.11.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.
- package/README.md +1 -1
- package/dist/api/auth.js +11 -1
- package/dist/api/auth.test.js +29 -0
- package/dist/api/errors.js +23 -0
- package/dist/api/route-coverage.test.js +61 -21
- package/dist/api/routes/agents.js +472 -0
- package/dist/api/routes/memory.js +299 -0
- package/dist/api/routes/projects.js +170 -0
- package/dist/api/routes/sessions.js +347 -0
- package/dist/api/routes/system.js +82 -0
- package/dist/api/routes/wiki.js +455 -0
- package/dist/api/routes/wiki.test.js +49 -0
- package/dist/api/send-json.js +16 -0
- package/dist/api/send-json.test.js +18 -0
- package/dist/api/server-runtime.js +45 -3
- package/dist/api/server.js +34 -1764
- package/dist/api/server.test.js +239 -8
- package/dist/api/sse-hub.js +37 -0
- package/dist/cli.js +1 -1
- package/dist/config.js +151 -58
- package/dist/config.test.js +29 -0
- package/dist/copilot/okr-mapper.js +2 -11
- package/dist/copilot/orchestrator.js +358 -352
- package/dist/copilot/orchestrator.test.js +139 -4
- package/dist/copilot/prompt-date.js +2 -1
- package/dist/copilot/session-manager.js +25 -23
- package/dist/copilot/session-manager.test.js +35 -1
- package/dist/copilot/standup.js +2 -2
- package/dist/copilot/task-event-log.js +7 -1
- package/dist/copilot/task-event-log.test.js +13 -0
- package/dist/copilot/tools/agent.js +608 -0
- package/dist/copilot/tools/index.js +19 -0
- package/dist/copilot/tools/memory.js +678 -0
- package/dist/copilot/tools/models.js +2 -0
- package/dist/copilot/tools/okr.js +171 -0
- package/dist/copilot/tools/wiki.js +333 -0
- package/dist/copilot/tools-deps.js +4 -0
- package/dist/copilot/tools.agent.test.js +10 -8
- package/dist/copilot/tools.inventory.test.js +76 -0
- package/dist/copilot/tools.js +1 -1780
- package/dist/copilot/tools.okr.test.js +31 -0
- package/dist/copilot/tools.wiki.test.js +6 -3
- package/dist/copilot/turn-event-log.js +31 -4
- package/dist/copilot/turn-event-log.test.js +24 -2
- package/dist/copilot/workiq-installer.test.js +2 -2
- package/dist/daemon-install.js +3 -2
- package/dist/daemon.js +9 -17
- package/dist/integrations/ado-client.js +90 -9
- package/dist/integrations/ado-client.test.js +56 -0
- package/dist/integrations/team-push.js +1 -0
- package/dist/integrations/team-push.test.js +6 -0
- package/dist/integrations/teams-notify.js +1 -0
- package/dist/integrations/teams-notify.test.js +5 -0
- package/dist/memory/active-scope.test.js +0 -1
- package/dist/memory/checkpoint.js +89 -72
- package/dist/memory/checkpoint.test.js +23 -3
- package/dist/memory/eot.js +87 -85
- package/dist/memory/eot.test.js +71 -3
- package/dist/memory/hooks.js +2 -4
- package/dist/memory/housekeeping-scheduler.js +1 -1
- package/dist/memory/housekeeping-scheduler.test.js +1 -2
- package/dist/memory/housekeeping.js +100 -3
- package/dist/memory/housekeeping.test.js +33 -2
- package/dist/memory/reflect.test.js +2 -0
- package/dist/memory/scope-lock.js +26 -0
- package/dist/memory/scope-lock.test.js +118 -0
- package/dist/memory/scopes.test.js +0 -1
- package/dist/mode-context.js +58 -5
- package/dist/mode-context.test.js +68 -0
- package/dist/paths.js +1 -0
- package/dist/setup.js +3 -2
- package/dist/shared/api-schemas.js +48 -5
- package/dist/store/connection.js +96 -0
- package/dist/store/db.js +5 -1498
- package/dist/store/db.test.js +182 -1
- package/dist/store/migrations.js +460 -0
- package/dist/store/repositories/memory.js +281 -0
- package/dist/store/repositories/okr.js +3 -0
- package/dist/store/repositories/projects.js +5 -0
- package/dist/store/repositories/sessions.js +284 -0
- package/dist/store/repositories/wiki.js +60 -0
- package/dist/store/schema.js +501 -0
- package/dist/util/logger.js +3 -2
- package/dist/wiki/consolidation.js +50 -9
- package/dist/wiki/consolidation.test.js +45 -0
- package/dist/wiki/frontmatter.js +43 -13
- package/dist/wiki/frontmatter.test.js +24 -0
- package/dist/wiki/fs.js +16 -4
- package/dist/wiki/fs.test.js +84 -0
- package/dist/wiki/index-manager.js +30 -2
- package/dist/wiki/index-manager.test.js +43 -12
- package/dist/wiki/ingest.js +1 -1
- package/dist/wiki/lock.js +11 -1
- package/dist/wiki/log-manager.js +2 -7
- package/dist/wiki/migrate.js +44 -17
- package/dist/wiki/project-registry.js +10 -5
- package/dist/wiki/project-registry.test.js +14 -0
- package/dist/wiki/scheduler.js +1 -1
- package/dist/wiki/seed-team-wiki.js +2 -1
- package/dist/wiki/team-sync.js +31 -6
- package/dist/wiki/team-sync.test.js +81 -0
- package/package.json +1 -1
- package/web/dist/assets/WikiEdit-EBVoY1Pk.js +30 -0
- package/web/dist/assets/WikiEdit-EBVoY1Pk.js.map +1 -0
- package/web/dist/assets/WikiGraph-BUbbABq-.js +2 -0
- package/web/dist/assets/WikiGraph-BUbbABq-.js.map +1 -0
- package/web/dist/assets/icon-acolyte-cream.svg +10 -0
- package/web/dist/assets/icon-acolyte-dark.svg +10 -0
- package/web/dist/assets/icon-acolyte-gold.svg +10 -0
- package/web/dist/assets/icon-acolyte-ibad.svg +10 -0
- package/web/dist/assets/icon-acolyte-lit.svg +10 -0
- package/web/dist/assets/icon-acolyte-mono.svg +10 -0
- package/web/dist/assets/icon-acolyte.png +0 -0
- package/web/dist/assets/icon-acolyte.svg +10 -0
- package/web/dist/assets/index-BGLL9pgM.css +10 -0
- package/web/dist/assets/index-KFX8UmOb.js +250 -0
- package/web/dist/assets/index-KFX8UmOb.js.map +1 -0
- package/web/dist/index.html +6 -4
- package/web/dist/assets/index-5kz9aRU9.css +0 -10
- package/web/dist/assets/index-iQrv3lQN.js +0 -286
- package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
package/README.md
CHANGED
package/dist/api/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { timingSafeEqual } from "crypto";
|
|
1
2
|
import jwt from "jsonwebtoken";
|
|
2
3
|
import jwksClient from "jwks-rsa";
|
|
3
4
|
import { childLogger } from "../util/logger.js";
|
|
@@ -39,6 +40,15 @@ function buildAuthenticatedUser(claims, teamLeadId) {
|
|
|
39
40
|
function unauthorized(res, message) {
|
|
40
41
|
res.status(401).json({ error: message });
|
|
41
42
|
}
|
|
43
|
+
export function timingSafeTokenEqual(provided, expected) {
|
|
44
|
+
if (!provided || !expected)
|
|
45
|
+
return false;
|
|
46
|
+
const a = Buffer.from(provided);
|
|
47
|
+
const b = Buffer.from(expected);
|
|
48
|
+
if (a.length !== b.length)
|
|
49
|
+
return false;
|
|
50
|
+
return timingSafeEqual(a, b);
|
|
51
|
+
}
|
|
42
52
|
// Module-level cache of JWKS clients keyed by tenant ID.
|
|
43
53
|
// Creating a new client per request discards the in-process key cache; keeping
|
|
44
54
|
// one client per tenant lets jwks-rsa honour its cacheMaxAge and avoids an
|
|
@@ -124,7 +134,7 @@ export function createAuthMiddleware(options) {
|
|
|
124
134
|
}
|
|
125
135
|
if (!options.config.entraAuthEnabled) {
|
|
126
136
|
const token = getBearerToken(req);
|
|
127
|
-
if (!
|
|
137
|
+
if (!timingSafeTokenEqual(token, options.apiToken)) {
|
|
128
138
|
unauthorized(res, "Unauthorized");
|
|
129
139
|
return;
|
|
130
140
|
}
|
package/dist/api/auth.test.js
CHANGED
|
@@ -448,4 +448,33 @@ test("JWKS client is reused across calls for the same tenant (module-level cache
|
|
|
448
448
|
assert.doesNotMatch(String(err2.message), /ENTRA_TENANT_ID or ENTRA_CLIENT_ID is missing/, "second error must come from JWT processing — client reused from module cache");
|
|
449
449
|
auth._resetJwksClientCache();
|
|
450
450
|
});
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
// timingSafeTokenEqual unit tests
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
test("timingSafeTokenEqual returns true for matching tokens", async () => {
|
|
455
|
+
const auth = await loadAuthModule();
|
|
456
|
+
assert.ok(auth, "auth module should exist");
|
|
457
|
+
assert.equal(typeof auth.timingSafeTokenEqual, "function", "timingSafeTokenEqual should be exported");
|
|
458
|
+
assert.equal(auth.timingSafeTokenEqual("secret-token", "secret-token"), true);
|
|
459
|
+
});
|
|
460
|
+
test("timingSafeTokenEqual returns false for wrong token", async () => {
|
|
461
|
+
const auth = await loadAuthModule();
|
|
462
|
+
assert.ok(auth, "auth module should exist");
|
|
463
|
+
assert.equal(auth.timingSafeTokenEqual("wrong-token", "secret-token"), false);
|
|
464
|
+
});
|
|
465
|
+
test("timingSafeTokenEqual returns false when provided token is null", async () => {
|
|
466
|
+
const auth = await loadAuthModule();
|
|
467
|
+
assert.ok(auth, "auth module should exist");
|
|
468
|
+
assert.equal(auth.timingSafeTokenEqual(null, "secret-token"), false);
|
|
469
|
+
});
|
|
470
|
+
test("timingSafeTokenEqual returns false when expected token is null", async () => {
|
|
471
|
+
const auth = await loadAuthModule();
|
|
472
|
+
assert.ok(auth, "auth module should exist");
|
|
473
|
+
assert.equal(auth.timingSafeTokenEqual("secret-token", null), false);
|
|
474
|
+
});
|
|
475
|
+
test("timingSafeTokenEqual returns false for different-length tokens", async () => {
|
|
476
|
+
const auth = await loadAuthModule();
|
|
477
|
+
assert.ok(auth, "auth module should exist");
|
|
478
|
+
assert.equal(auth.timingSafeTokenEqual("short", "much-longer-token"), false);
|
|
479
|
+
});
|
|
451
480
|
//# sourceMappingURL=auth.test.js.map
|
package/dist/api/errors.js
CHANGED
|
@@ -46,6 +46,19 @@ function isBodyParserSyntaxError(error) {
|
|
|
46
46
|
function formatZodError(error) {
|
|
47
47
|
return error.issues[0]?.message ?? "Invalid request";
|
|
48
48
|
}
|
|
49
|
+
function getStatusCodeError(error) {
|
|
50
|
+
if (!(error instanceof Error))
|
|
51
|
+
return undefined;
|
|
52
|
+
const candidate = error;
|
|
53
|
+
if (typeof candidate.statusCode !== "number" || candidate.statusCode < 400 || candidate.statusCode > 599) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
statusCode: candidate.statusCode,
|
|
58
|
+
message: error.message,
|
|
59
|
+
expose: candidate.expose === true || candidate.statusCode < 500,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
49
62
|
export function parseRequest(schema, input) {
|
|
50
63
|
const parsed = schema.safeParse(input);
|
|
51
64
|
if (!parsed.success) {
|
|
@@ -90,6 +103,16 @@ export function createApiErrorHandler() {
|
|
|
90
103
|
res.status(error.statusCode).json({ error: error.expose ? error.message : "Internal server error" });
|
|
91
104
|
return;
|
|
92
105
|
}
|
|
106
|
+
const statusCodeError = getStatusCodeError(error);
|
|
107
|
+
if (statusCodeError) {
|
|
108
|
+
if (statusCodeError.statusCode >= 500) {
|
|
109
|
+
log.error({ method: req.method, url: req.originalUrl, err: statusCodeError.message }, "API request failed");
|
|
110
|
+
}
|
|
111
|
+
res
|
|
112
|
+
.status(statusCodeError.statusCode)
|
|
113
|
+
.json({ error: statusCodeError.expose ? statusCodeError.message : "Internal server error" });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
93
116
|
log.error({ method: req.method, url: req.originalUrl, err: error instanceof Error ? error.message : error }, "API request failed (unhandled)");
|
|
94
117
|
res.status(500).json({ error: "Internal server error" });
|
|
95
118
|
};
|
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
18
18
|
import { describe, test } from "node:test";
|
|
19
19
|
import assert from "node:assert/strict";
|
|
20
|
-
import { readFileSync } from "node:fs";
|
|
20
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
23
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
24
24
|
const ROOT = join(__dirname, "..", "..");
|
|
25
25
|
const FRONTEND_API_PATH = join(ROOT, "web", "src", "api.ts");
|
|
26
26
|
const SERVER_TS_PATH = join(ROOT, "src", "api", "server.ts");
|
|
27
|
+
const ROUTES_DIR = join(ROOT, "src", "api", "routes");
|
|
27
28
|
const WEB_SCHEMAS_PATH = join(ROOT, "src", "shared", "api-schemas.ts");
|
|
28
29
|
/**
|
|
29
30
|
* Normalise a URL path for comparison:
|
|
@@ -59,20 +60,42 @@ function extractFrontendPaths(src) {
|
|
|
59
60
|
return paths;
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
63
|
+
* Read server.ts plus extracted route modules so static route coverage stays
|
|
64
|
+
* effective after handlers move behind Express Router instances.
|
|
64
65
|
*/
|
|
65
|
-
function
|
|
66
|
-
const
|
|
66
|
+
function readServerRouteSources() {
|
|
67
|
+
const sources = [readFileSync(SERVER_TS_PATH, "utf8")];
|
|
68
|
+
if (!existsSync(ROUTES_DIR)) {
|
|
69
|
+
return sources;
|
|
70
|
+
}
|
|
71
|
+
const routeFiles = readdirSync(ROUTES_DIR)
|
|
72
|
+
.filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
|
|
73
|
+
.sort();
|
|
74
|
+
for (const file of routeFiles) {
|
|
75
|
+
sources.push(readFileSync(join(ROUTES_DIR, file), "utf8"));
|
|
76
|
+
}
|
|
77
|
+
return sources;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Extract all unique normalised API paths registered via app.METHOD() or
|
|
81
|
+
* router.METHOD(). Only /api/ prefixed paths are considered.
|
|
82
|
+
*/
|
|
83
|
+
function extractServerPaths(sources) {
|
|
84
|
+
const re = /\b(?:app|router)\.(get|post|put|delete|patch)\(["'`](\/api\/[^"'`?]+)/g;
|
|
67
85
|
const paths = new Set();
|
|
68
|
-
for (const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
86
|
+
for (const src of sources) {
|
|
87
|
+
for (const m of src.matchAll(re)) {
|
|
88
|
+
const normalised = normalizePath(m[2]);
|
|
89
|
+
if (normalised) {
|
|
90
|
+
paths.add(normalised);
|
|
91
|
+
}
|
|
72
92
|
}
|
|
73
93
|
}
|
|
74
94
|
return paths;
|
|
75
95
|
}
|
|
96
|
+
function readCombinedServerRouteSource() {
|
|
97
|
+
return readServerRouteSources().join("\n");
|
|
98
|
+
}
|
|
76
99
|
/**
|
|
77
100
|
* Extract SSE type literals emitted inline via formatSseData({ type: "X", ... })
|
|
78
101
|
* in server.ts. Pass-through calls like formatSseData(event) are not detected.
|
|
@@ -128,9 +151,9 @@ function extractStreamEventSchemaTypes(src) {
|
|
|
128
151
|
describe("route coverage — static analysis", () => {
|
|
129
152
|
test("all frontend authedFetch paths have a matching server route registration", () => {
|
|
130
153
|
const frontendSrc = readFileSync(FRONTEND_API_PATH, "utf8");
|
|
131
|
-
const
|
|
154
|
+
const serverSources = readServerRouteSources();
|
|
132
155
|
const frontendPaths = extractFrontendPaths(frontendSrc);
|
|
133
|
-
const serverPaths = extractServerPaths(
|
|
156
|
+
const serverPaths = extractServerPaths(serverSources);
|
|
134
157
|
const missing = [];
|
|
135
158
|
for (const fp of [...frontendPaths].sort()) {
|
|
136
159
|
if (!serverPaths.has(fp)) {
|
|
@@ -139,20 +162,37 @@ describe("route coverage — static analysis", () => {
|
|
|
139
162
|
}
|
|
140
163
|
assert.ok(missing.length === 0, `Frontend calls ${missing.length} route(s) with no matching server registration:\n` +
|
|
141
164
|
missing.map((p) => ` MISSING: ${p}`).join("\n") +
|
|
142
|
-
"\n\nAdd the missing route(s) to src/api/server.ts");
|
|
165
|
+
"\n\nAdd the missing route(s) to src/api/server.ts or src/api/routes/*.ts");
|
|
166
|
+
});
|
|
167
|
+
test("server route coverage scans extracted route modules", () => {
|
|
168
|
+
assert.ok(existsSync(ROUTES_DIR), "Expected src/api/routes/ to exist so route coverage scans extracted routers.");
|
|
169
|
+
const routeFiles = readdirSync(ROUTES_DIR)
|
|
170
|
+
.filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
|
|
171
|
+
.sort();
|
|
172
|
+
assert.deepEqual(routeFiles, ["agents.ts", "memory.ts", "projects.ts", "sessions.ts", "system.ts", "wiki.ts"]);
|
|
143
173
|
});
|
|
144
174
|
test("server routes cover every unique normalised path (no obvious duplicates leaked)", () => {
|
|
145
|
-
const
|
|
146
|
-
const serverPaths = extractServerPaths(serverSrc);
|
|
175
|
+
const serverPaths = extractServerPaths(readServerRouteSources());
|
|
147
176
|
// Sanity: the server must expose at least 30 /api/ routes.
|
|
148
177
|
assert.ok(serverPaths.size >= 30, `Expected ≥30 server routes, found ${serverPaths.size}. Check extractServerPaths regex.`);
|
|
149
178
|
});
|
|
150
179
|
});
|
|
151
|
-
describe("
|
|
152
|
-
test("
|
|
153
|
-
const serverSrc =
|
|
154
|
-
assert.
|
|
155
|
-
|
|
180
|
+
describe("shared API schema enforcement — static analysis", () => {
|
|
181
|
+
test("server route success responses go through sendJson schema validation", () => {
|
|
182
|
+
const serverSrc = readCombinedServerRouteSource();
|
|
183
|
+
assert.doesNotMatch(serverSrc, /\bres\.json\(/, "Do not send successful route responses with res.json() directly; use sendJson(res, SharedSchema, payload).");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe("explicit auth route safeguards — static analysis", () => {
|
|
187
|
+
test("sensitive wiki and memory routes declare authMiddleware explicitly", () => {
|
|
188
|
+
const serverSrc = readCombinedServerRouteSource();
|
|
189
|
+
assert.match(serverSrc, /\b(?:app|router)\.post\("\/api\/wiki\/update",\s*authMiddleware,\s*async \(req: Request, res: Response\) => \{/);
|
|
190
|
+
assert.match(serverSrc, /\b(?:app|router)\.post\("\/api\/wiki\/page\/pin",\s*authMiddleware,\s*async \(req: Request, res: Response\) => \{/);
|
|
191
|
+
assert.match(serverSrc, /\b(?:app|router)\.get\("\/api\/wiki\/korg\/sessions",\s*authMiddleware,/);
|
|
192
|
+
assert.match(serverSrc, /\b(?:app|router)\.post\("\/api\/wiki\/ingest",\s*authMiddleware,\s*async \(req: Request, res: Response\) => \{/);
|
|
193
|
+
assert.match(serverSrc, /\b(?:app|router)\.get\("\/api\/wiki\/search",\s*authMiddleware,\s*async \(req: Request, res: Response\) => \{/);
|
|
194
|
+
assert.match(serverSrc, /\b(?:app|router)\.post\("\/api\/memory\/hooks\/git-commit",\s*authMiddleware,/);
|
|
195
|
+
assert.match(serverSrc, /\b(?:app|router)\.post\("\/api\/memory\/hooks\/pr-merge",\s*authMiddleware,/);
|
|
156
196
|
});
|
|
157
197
|
});
|
|
158
198
|
describe("SSE exhaustiveness — static analysis", () => {
|
|
@@ -169,7 +209,7 @@ describe("SSE exhaustiveness — static analysis", () => {
|
|
|
169
209
|
// type in StreamEventSchema, this test FAILS, preventing the regression.
|
|
170
210
|
// ───────────────────────────────────────────────────────────────────────────
|
|
171
211
|
test("formatSseEvent is not used with names that belong in the StreamEventSchema data channel", () => {
|
|
172
|
-
const serverSrc =
|
|
212
|
+
const serverSrc = readCombinedServerRouteSource();
|
|
173
213
|
const schemaSrc = readFileSync(WEB_SCHEMAS_PATH, "utf8");
|
|
174
214
|
const namedEventNames = extractSseEventNames(serverSrc);
|
|
175
215
|
const schemaTypes = extractStreamEventSchemaTypes(schemaSrc);
|
|
@@ -184,7 +224,7 @@ describe("SSE exhaustiveness — static analysis", () => {
|
|
|
184
224
|
"\n\nNamed SSE events are silently dropped by the frontend's default 'message' handler (issues #367/#368).");
|
|
185
225
|
});
|
|
186
226
|
test("all inline formatSseData type literals are known StreamEventSchema types or agent-stream-only types", () => {
|
|
187
|
-
const serverSrc =
|
|
227
|
+
const serverSrc = readCombinedServerRouteSource();
|
|
188
228
|
const schemaSrc = readFileSync(WEB_SCHEMAS_PATH, "utf8");
|
|
189
229
|
const emittedTypes = extractSseDataTypes(serverSrc);
|
|
190
230
|
const schemaTypes = extractStreamEventSchemaTypes(schemaSrc);
|