@mandujs/core 0.21.0 → 0.22.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/package.json +94 -69
- package/src/auth/__tests__/login.test.ts +419 -0
- package/src/auth/__tests__/password.test.ts +122 -0
- package/src/auth/__tests__/reset.test.ts +296 -0
- package/src/auth/__tests__/tokens.test.ts +274 -0
- package/src/auth/__tests__/verification.test.ts +274 -0
- package/src/auth/index.ts +76 -0
- package/src/auth/login.ts +225 -0
- package/src/auth/password.ts +120 -0
- package/src/auth/reset.ts +243 -0
- package/src/auth/tokens.ts +612 -0
- package/src/auth/verification.ts +253 -0
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
- package/src/bundler/__tests__/cold-start.test.ts +504 -0
- package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
- package/src/bundler/__tests__/extended-watch.test.ts +710 -0
- package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
- package/src/bundler/__tests__/hdr.test.ts +353 -0
- package/src/bundler/__tests__/hmr-client.test.ts +532 -0
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
- package/src/bundler/build.test.ts +8 -1
- package/src/bundler/build.ts +310 -18
- package/src/bundler/css.ts +326 -323
- package/src/bundler/dev.ts +1611 -59
- package/src/bundler/fast-refresh-plugin.ts +307 -0
- package/src/bundler/hmr-types.ts +252 -0
- package/src/bundler/manifest-schema.ts +301 -0
- package/src/bundler/safe-build.test.ts +128 -0
- package/src/bundler/safe-build.ts +77 -0
- package/src/bundler/scenario-matrix.ts +229 -0
- package/src/bundler/types.ts +11 -0
- package/src/bundler/vendor-cache-types.ts +130 -0
- package/src/bundler/vendor-cache.ts +526 -0
- package/src/client/router.ts +214 -56
- package/src/db/__tests__/db.test.ts +485 -0
- package/src/db/index.ts +513 -0
- package/src/db/migrations/__tests__/runner.test.ts +661 -0
- package/src/db/migrations/history-table.ts +345 -0
- package/src/db/migrations/lock.ts +269 -0
- package/src/db/migrations/runner.ts +633 -0
- package/src/desktop/__tests__/smoke.test.ts +100 -0
- package/src/desktop/__tests__/window.test.ts +172 -0
- package/src/desktop/__tests__/worker.test.ts +266 -0
- package/src/desktop/index.ts +43 -0
- package/src/desktop/types.ts +158 -0
- package/src/desktop/window.ts +492 -0
- package/src/desktop/worker.ts +180 -0
- package/src/email/__tests__/email.test.ts +355 -0
- package/src/email/index.ts +282 -0
- package/src/email/resend.ts +163 -0
- package/src/email/smtp.ts +64 -0
- package/src/filling/__tests__/session-sqlite.test.ts +454 -0
- package/src/filling/context.ts +72 -78
- package/src/filling/cookie-codec.ts +299 -0
- package/src/filling/deps.ts +25 -1
- package/src/filling/filling.ts +28 -3
- package/src/filling/session-sqlite.ts +617 -0
- package/src/filling/session.ts +265 -216
- package/src/guard/decision-memory.test.ts +52 -22
- package/src/id/__tests__/id.test.ts +120 -0
- package/src/id/index.ts +105 -0
- package/src/kitchen/index.ts +2 -2
- package/src/kitchen/kitchen-handler.ts +86 -0
- package/src/kitchen/stream/activity-sse.ts +2 -1
- package/src/middleware/csrf.ts +328 -0
- package/src/middleware/index.ts +40 -0
- package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
- package/src/middleware/oauth/index.ts +505 -0
- package/src/middleware/oauth/providers.ts +115 -0
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
- package/src/middleware/rate-limit/index.ts +522 -0
- package/src/middleware/rate-limit/sqlite-store.ts +382 -0
- package/src/middleware/secure/__tests__/secure.test.ts +360 -0
- package/src/middleware/secure/csp.ts +193 -0
- package/src/middleware/secure/index.ts +417 -0
- package/src/middleware/session.ts +174 -0
- package/src/observability/event-bus.ts +81 -79
- package/src/paths.ts +37 -0
- package/src/perf/hmr-markers.ts +215 -0
- package/src/perf/index.ts +104 -0
- package/src/resource/__tests__/generator.test.ts +603 -2
- package/src/resource/ddl/__tests__/diff.test.ts +639 -0
- package/src/resource/ddl/__tests__/emit.test.ts +799 -0
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
- package/src/resource/ddl/diff.ts +392 -0
- package/src/resource/ddl/emit.ts +548 -0
- package/src/resource/ddl/persistence-types.ts +218 -0
- package/src/resource/ddl/snapshot.ts +447 -0
- package/src/resource/ddl/type-map.ts +223 -0
- package/src/resource/ddl/types.ts +232 -0
- package/src/resource/generator-repo.ts +610 -0
- package/src/resource/generator-schema.ts +476 -0
- package/src/resource/generator.ts +117 -1
- package/src/resource/index.ts +17 -1
- package/src/resource/schema.ts +30 -0
- package/src/router/fs-scanner.ts +3 -0
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
- package/src/runtime/__tests__/hdr-client.test.ts +223 -0
- package/src/runtime/__tests__/http-errors.test.ts +117 -0
- package/src/runtime/__tests__/not-found.test.ts +152 -0
- package/src/runtime/boundary.tsx +21 -1
- package/src/runtime/fast-refresh-runtime.ts +322 -0
- package/src/runtime/fast-refresh-types.ts +128 -0
- package/src/runtime/hmr-client.ts +409 -0
- package/src/runtime/http-errors.ts +113 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/logger.ts +678 -677
- package/src/runtime/not-found.ts +93 -0
- package/src/runtime/redirect.ts +133 -0
- package/src/runtime/server.ts +518 -20
- package/src/runtime/ssr.ts +340 -10
- package/src/runtime/streaming-ssr.ts +222 -19
- package/src/scheduler/__tests__/scheduler.test.ts +514 -0
- package/src/scheduler/index.ts +343 -0
- package/src/storage/s3/__tests__/s3.test.ts +479 -0
- package/src/storage/s3/index.ts +412 -0
- package/src/testing/index.ts +58 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { newId, newShortId, _resolveGenerator } from "../index";
|
|
3
|
+
|
|
4
|
+
const UUID_REGEX =
|
|
5
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
const HEX8_REGEX = /^[0-9a-f]{8}$/i;
|
|
7
|
+
|
|
8
|
+
interface CryptoLike {
|
|
9
|
+
randomUUIDv7?: () => string;
|
|
10
|
+
randomUUID: () => string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("@mandujs/core/id — newId", () => {
|
|
14
|
+
it("returns a canonical UUID string", () => {
|
|
15
|
+
const id = newId();
|
|
16
|
+
expect(id).toMatch(UUID_REGEX);
|
|
17
|
+
expect(id.length).toBe(36);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns distinct values across calls", () => {
|
|
21
|
+
const seen = new Set<string>();
|
|
22
|
+
for (let i = 0; i < 1000; i++) {
|
|
23
|
+
seen.add(newId());
|
|
24
|
+
}
|
|
25
|
+
expect(seen.size).toBe(1000);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("is monotonic: lexicographic sort preserves generation order (v7 property)", async () => {
|
|
29
|
+
// Skip this invariant if neither global exposes v7 — the v4 fallback is
|
|
30
|
+
// correctly non-monotonic by design.
|
|
31
|
+
const c = globalThis.crypto as unknown as CryptoLike;
|
|
32
|
+
const bun = (globalThis as unknown as { Bun?: { randomUUIDv7?: () => string } }).Bun;
|
|
33
|
+
const v7Available =
|
|
34
|
+
typeof c.randomUUIDv7 === "function" ||
|
|
35
|
+
(bun !== undefined && typeof bun.randomUUIDv7 === "function");
|
|
36
|
+
if (!v7Available) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const generated: string[] = [];
|
|
41
|
+
for (let i = 0; i < 1000; i++) {
|
|
42
|
+
generated.push(newId());
|
|
43
|
+
// Force sub-millisecond spread across a few ticks so the time component
|
|
44
|
+
// advances at least once during the run. UUID v7 counter handles
|
|
45
|
+
// intra-millisecond monotonicity on its own.
|
|
46
|
+
if (i % 200 === 0) {
|
|
47
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const sorted = [...generated].sort();
|
|
52
|
+
|
|
53
|
+
// Require overall monotonic trend: the full sorted list equals the
|
|
54
|
+
// generation order. Bun's v7 implementation uses a per-millisecond
|
|
55
|
+
// monotonic counter, so ties within the same ms remain ordered.
|
|
56
|
+
expect(sorted).toEqual(generated);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("@mandujs/core/id — newShortId", () => {
|
|
61
|
+
it("returns exactly 8 hex characters", () => {
|
|
62
|
+
const short = newShortId();
|
|
63
|
+
expect(short).toMatch(HEX8_REGEX);
|
|
64
|
+
expect(short.length).toBe(8);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("matches the prefix of the underlying UUID", () => {
|
|
68
|
+
// The short form is defined as the first 8 chars of the full ID, which is
|
|
69
|
+
// the first hex group of the UUID (pre-hyphen).
|
|
70
|
+
for (let i = 0; i < 50; i++) {
|
|
71
|
+
const short = newShortId();
|
|
72
|
+
expect(short).not.toContain("-");
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("@mandujs/core/id — generator resolution", () => {
|
|
78
|
+
it("prefers crypto.randomUUIDv7 when present", () => {
|
|
79
|
+
const sentinel = "01912345-6789-7abc-def0-123456789abc";
|
|
80
|
+
const fakeCrypto = {
|
|
81
|
+
randomUUIDv7: () => sentinel,
|
|
82
|
+
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
|
83
|
+
};
|
|
84
|
+
const fakeBun = { randomUUIDv7: () => "SHOULD_NOT_BE_CALLED" };
|
|
85
|
+
const gen = _resolveGenerator(fakeCrypto, fakeBun);
|
|
86
|
+
expect(gen()).toBe(sentinel);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("uses Bun.randomUUIDv7 when crypto.randomUUIDv7 is absent", () => {
|
|
90
|
+
const sentinel = "01abcdef-0123-7456-8789-abcdef012345";
|
|
91
|
+
const fakeCrypto = {
|
|
92
|
+
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
|
93
|
+
};
|
|
94
|
+
const fakeBun = { randomUUIDv7: () => sentinel };
|
|
95
|
+
const gen = _resolveGenerator(fakeCrypto, fakeBun);
|
|
96
|
+
expect(gen()).toBe(sentinel);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("falls back to crypto.randomUUID when neither v7 source is available", () => {
|
|
100
|
+
const fakeCrypto = {
|
|
101
|
+
// No randomUUIDv7 — force fallback.
|
|
102
|
+
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
|
103
|
+
};
|
|
104
|
+
const fakeBun = {}; // also no randomUUIDv7
|
|
105
|
+
const gen = _resolveGenerator(fakeCrypto, fakeBun);
|
|
106
|
+
const id = gen();
|
|
107
|
+
expect(id).toMatch(UUID_REGEX);
|
|
108
|
+
expect(id.length).toBe(36);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("falls back to crypto.randomUUID when Bun stub lacks randomUUIDv7", () => {
|
|
112
|
+
const fakeCrypto = {
|
|
113
|
+
randomUUID: () => "11111111-1111-4111-8111-111111111111",
|
|
114
|
+
};
|
|
115
|
+
// Pass an empty stub explicitly rather than `undefined` (which would
|
|
116
|
+
// trigger the default-parameter fallback to the real Bun global).
|
|
117
|
+
const gen = _resolveGenerator(fakeCrypto, {});
|
|
118
|
+
expect(gen()).toBe("11111111-1111-4111-8111-111111111111");
|
|
119
|
+
});
|
|
120
|
+
});
|
package/src/id/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/id
|
|
3
|
+
*
|
|
4
|
+
* Centralized ID generation utilities.
|
|
5
|
+
*
|
|
6
|
+
* Uses `crypto.randomUUIDv7()` when available (Bun 1.3+) so that generated IDs
|
|
7
|
+
* are time-ordered — lexicographic string sort matches chronological creation
|
|
8
|
+
* order. This makes v7 UUIDs ideal for log correlation, database primary keys,
|
|
9
|
+
* and any context where sortability is useful.
|
|
10
|
+
*
|
|
11
|
+
* On older runtimes we fall back to `crypto.randomUUID()` (v4). The fallback
|
|
12
|
+
* emits a single dev-only warning so integrators notice the reduced guarantee,
|
|
13
|
+
* and remains silent in production.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface CryptoWithUUIDv7 {
|
|
17
|
+
randomUUIDv7?: () => string;
|
|
18
|
+
randomUUID: () => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface BunWithUUIDv7 {
|
|
22
|
+
randomUUIDv7?: () => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const FALLBACK_WARNING =
|
|
26
|
+
"[@mandujs/core/id] randomUUIDv7 is unavailable — falling back to randomUUID (v4). IDs will not be time-ordered. Upgrade to Bun >= 1.3 for v7 support.";
|
|
27
|
+
|
|
28
|
+
let fallbackWarned = false;
|
|
29
|
+
|
|
30
|
+
function getCrypto(): CryptoWithUUIDv7 {
|
|
31
|
+
// `crypto` is available globally in Bun, Node 20+, Deno, and modern browsers.
|
|
32
|
+
// We type-assert via the narrow interface we actually use.
|
|
33
|
+
return globalThis.crypto as unknown as CryptoWithUUIDv7;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getBun(): BunWithUUIDv7 | undefined {
|
|
37
|
+
// `Bun` is only present when running under the Bun runtime. Access via
|
|
38
|
+
// a dynamic lookup so the module stays importable in Node / browsers.
|
|
39
|
+
const g = globalThis as unknown as { Bun?: BunWithUUIDv7 };
|
|
40
|
+
return g.Bun;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function warnFallbackOnce(): void {
|
|
44
|
+
if (fallbackWarned) return;
|
|
45
|
+
fallbackWarned = true;
|
|
46
|
+
if (
|
|
47
|
+
typeof process !== "undefined" &&
|
|
48
|
+
process.env &&
|
|
49
|
+
process.env.NODE_ENV !== "production"
|
|
50
|
+
) {
|
|
51
|
+
console.warn(FALLBACK_WARNING);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolves the concrete UUID generator function once per call. Exported
|
|
57
|
+
* internally so tests can validate the resolution order without monkey
|
|
58
|
+
* patching non-configurable globals.
|
|
59
|
+
*
|
|
60
|
+
* @internal
|
|
61
|
+
*/
|
|
62
|
+
export function _resolveGenerator(
|
|
63
|
+
c: CryptoWithUUIDv7 = getCrypto(),
|
|
64
|
+
bun: BunWithUUIDv7 | undefined = getBun(),
|
|
65
|
+
): () => string {
|
|
66
|
+
if (typeof c.randomUUIDv7 === "function") {
|
|
67
|
+
return c.randomUUIDv7.bind(c);
|
|
68
|
+
}
|
|
69
|
+
if (bun && typeof bun.randomUUIDv7 === "function") {
|
|
70
|
+
return bun.randomUUIDv7.bind(bun);
|
|
71
|
+
}
|
|
72
|
+
warnFallbackOnce();
|
|
73
|
+
return c.randomUUID.bind(c);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Generates a UUID v7 — a time-ordered UUID whose string sort matches
|
|
78
|
+
* chronological order.
|
|
79
|
+
*
|
|
80
|
+
* Resolution order:
|
|
81
|
+
* 1. `crypto.randomUUIDv7()` — web standard, Node 24+, newer Bun
|
|
82
|
+
* 2. `Bun.randomUUIDv7()` — Bun 1.3+ runtime API
|
|
83
|
+
* 3. `crypto.randomUUID()` — v4 fallback (non-monotonic)
|
|
84
|
+
*
|
|
85
|
+
* The fallback path emits a one-time warning in dev mode.
|
|
86
|
+
*
|
|
87
|
+
* @returns A 36-character UUID string in canonical 8-4-4-4-12 form.
|
|
88
|
+
*/
|
|
89
|
+
export function newId(): string {
|
|
90
|
+
return _resolveGenerator()();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* First 8 hex characters of a UUID v7 — suitable for log correlation IDs
|
|
95
|
+
* where collision risk is acceptable within a single request lifetime.
|
|
96
|
+
*
|
|
97
|
+
* Because v7 encodes the timestamp in its high bits, two short IDs generated
|
|
98
|
+
* within the same millisecond share a prefix; this is normal and expected.
|
|
99
|
+
* For strong uniqueness use {@link newId} instead.
|
|
100
|
+
*
|
|
101
|
+
* @returns Exactly 8 lowercase hex characters.
|
|
102
|
+
*/
|
|
103
|
+
export function newShortId(): string {
|
|
104
|
+
return newId().slice(0, 8);
|
|
105
|
+
}
|
package/src/kitchen/index.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* and architecture guard dashboard at /__kitchen.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
export { KitchenHandler, KITCHEN_PREFIX, getKitchenErrors, clearKitchenErrors } from "./kitchen-handler";
|
|
9
|
-
export type { KitchenOptions } from "./kitchen-handler";
|
|
8
|
+
export { KitchenHandler, KITCHEN_PREFIX, getKitchenErrors, clearKitchenErrors, computeAgentStats } from "./kitchen-handler";
|
|
9
|
+
export type { KitchenOptions, AgentStats, AgentStatsResponse } from "./kitchen-handler";
|
|
10
10
|
export { ActivitySSEBroadcaster } from "./stream/activity-sse";
|
|
11
11
|
export { FileTailer } from "./stream/file-tailer";
|
|
12
12
|
export { GuardAPI } from "./api/guard-api";
|
|
@@ -93,6 +93,87 @@ function parseWindow(input: string): number {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ========== Per-Agent Stats ==========
|
|
97
|
+
|
|
98
|
+
export interface AgentStats {
|
|
99
|
+
toolCalls: number;
|
|
100
|
+
failures: number;
|
|
101
|
+
topTools: Array<{ tool: string; count: number }>;
|
|
102
|
+
avgDuration: number;
|
|
103
|
+
firstSeen: number;
|
|
104
|
+
lastSeen: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface AgentStatsResponse {
|
|
108
|
+
agents: Record<string, AgentStats>;
|
|
109
|
+
totalAgents: number;
|
|
110
|
+
totalEvents: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Aggregate recent MCP events by sessionId to produce per-agent usage stats.
|
|
115
|
+
* Events without a sessionId are grouped under "unknown".
|
|
116
|
+
*/
|
|
117
|
+
export function computeAgentStats(): AgentStatsResponse {
|
|
118
|
+
const events = eventBus.getRecent(500, { type: "mcp" });
|
|
119
|
+
const agents: Record<string, AgentStats> = {};
|
|
120
|
+
const toolCounts: Record<string, Map<string, number>> = {};
|
|
121
|
+
const durations: Record<string, number[]> = {};
|
|
122
|
+
|
|
123
|
+
for (const e of events) {
|
|
124
|
+
const data = (e.data ?? {}) as Record<string, unknown>;
|
|
125
|
+
const sessionId = typeof data.sessionId === "string" && data.sessionId
|
|
126
|
+
? data.sessionId
|
|
127
|
+
: "unknown";
|
|
128
|
+
|
|
129
|
+
let agent = agents[sessionId];
|
|
130
|
+
if (!agent) {
|
|
131
|
+
agent = {
|
|
132
|
+
toolCalls: 0,
|
|
133
|
+
failures: 0,
|
|
134
|
+
topTools: [],
|
|
135
|
+
avgDuration: 0,
|
|
136
|
+
firstSeen: e.timestamp,
|
|
137
|
+
lastSeen: e.timestamp,
|
|
138
|
+
};
|
|
139
|
+
agents[sessionId] = agent;
|
|
140
|
+
toolCounts[sessionId] = new Map();
|
|
141
|
+
durations[sessionId] = [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
agent.toolCalls++;
|
|
145
|
+
if (e.severity === "error") agent.failures++;
|
|
146
|
+
if (e.timestamp < agent.firstSeen) agent.firstSeen = e.timestamp;
|
|
147
|
+
if (e.timestamp > agent.lastSeen) agent.lastSeen = e.timestamp;
|
|
148
|
+
|
|
149
|
+
const tool = typeof data.tool === "string" && data.tool
|
|
150
|
+
? data.tool
|
|
151
|
+
: e.source || "unknown";
|
|
152
|
+
toolCounts[sessionId].set(tool, (toolCounts[sessionId].get(tool) ?? 0) + 1);
|
|
153
|
+
|
|
154
|
+
if (typeof e.duration === "number") {
|
|
155
|
+
durations[sessionId].push(e.duration);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const [sessionId, agent] of Object.entries(agents)) {
|
|
160
|
+
agent.topTools = Array.from(toolCounts[sessionId].entries())
|
|
161
|
+
.map(([tool, count]) => ({ tool, count }))
|
|
162
|
+
.sort((a, b) => b.count - a.count)
|
|
163
|
+
.slice(0, 5);
|
|
164
|
+
const ds = durations[sessionId];
|
|
165
|
+
agent.avgDuration = ds.length
|
|
166
|
+
? ds.reduce((a, b) => a + b, 0) / ds.length
|
|
167
|
+
: 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
agents,
|
|
172
|
+
totalAgents: Object.keys(agents).length,
|
|
173
|
+
totalEvents: events.length,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
96
177
|
export class KitchenHandler {
|
|
97
178
|
private sse: ActivitySSEBroadcaster;
|
|
98
179
|
private guardAPI: GuardAPI;
|
|
@@ -271,6 +352,11 @@ export class KitchenHandler {
|
|
|
271
352
|
return Response.json({ events });
|
|
272
353
|
}
|
|
273
354
|
|
|
355
|
+
// Agent Stats API — per-agent (sessionId) aggregation of MCP events
|
|
356
|
+
if (sub === "/api/agent-stats" && req.method === "GET") {
|
|
357
|
+
return Response.json(computeAgentStats());
|
|
358
|
+
}
|
|
359
|
+
|
|
274
360
|
// Cache API — cache store stats
|
|
275
361
|
if ((sub === "/api/cache" || sub === "/api/cache-stats") && req.method === "GET") {
|
|
276
362
|
const store = getGlobalCache();
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import path from "path";
|
|
11
11
|
import { FileTailer } from "./file-tailer";
|
|
12
|
+
import { newId } from "../../id";
|
|
12
13
|
|
|
13
14
|
interface SSEClient {
|
|
14
15
|
id: string;
|
|
@@ -82,7 +83,7 @@ export class ActivitySSEBroadcaster {
|
|
|
82
83
|
|
|
83
84
|
/** Create an SSE Response for a new client connection */
|
|
84
85
|
createResponse(): Response {
|
|
85
|
-
const clientId =
|
|
86
|
+
const clientId = newId();
|
|
86
87
|
|
|
87
88
|
const stream = new ReadableStream<Uint8Array>({
|
|
88
89
|
start: (controller) => {
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF Middleware Plugin
|
|
3
|
+
*
|
|
4
|
+
* Double-submit cookie pattern (stateless, no session required).
|
|
5
|
+
*
|
|
6
|
+
* cookie: __csrf=<token>
|
|
7
|
+
* header: x-csrf-token: <token> (or form field _csrf)
|
|
8
|
+
*
|
|
9
|
+
* Both values MUST match AND the token's HMAC signature MUST verify.
|
|
10
|
+
*
|
|
11
|
+
* Internally delegates to `Bun.CSRF.generate` / `Bun.CSRF.verify` when
|
|
12
|
+
* available (Bun ≥ 1.3). Verified present and stable in Bun 1.3.10:
|
|
13
|
+
* Bun.CSRF.generate(secret, { maxAge? }) → URL-safe base64 token
|
|
14
|
+
* Bun.CSRF.verify(token, { secret, maxAge? }) → boolean
|
|
15
|
+
*
|
|
16
|
+
* A pure `crypto.subtle` + `crypto.getRandomValues` fallback is provided for
|
|
17
|
+
* compatibility. The exported `csrf()` API is identical either way.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { csrf } from "@mandujs/core/middleware";
|
|
22
|
+
*
|
|
23
|
+
* export default Mandu.filling()
|
|
24
|
+
* .use(csrf({ secret: process.env.CSRF_SECRET! }))
|
|
25
|
+
* .post((ctx) => ctx.ok({ ok: true }));
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
import type { ManduContext, CookieOptions } from "../filling/context";
|
|
29
|
+
|
|
30
|
+
// ========== Types ==========
|
|
31
|
+
|
|
32
|
+
export interface CsrfMiddlewareOptions {
|
|
33
|
+
/** Required. Used to HMAC-sign CSRF tokens. */
|
|
34
|
+
secret: string;
|
|
35
|
+
/** Cookie name. Default: "__csrf". */
|
|
36
|
+
cookieName?: string;
|
|
37
|
+
/** Header name checked on unsafe methods. Default: "x-csrf-token". */
|
|
38
|
+
headerName?: string;
|
|
39
|
+
/** Form field name checked as fallback. Default: "_csrf". */
|
|
40
|
+
fieldName?: string;
|
|
41
|
+
/** Methods that skip validation. Default: ["GET","HEAD","OPTIONS"]. */
|
|
42
|
+
safeMethods?: string[];
|
|
43
|
+
/** Cookie attribute overrides (merged with sensible defaults). */
|
|
44
|
+
cookieOptions?: {
|
|
45
|
+
/** Default: false — client JS needs to read the token to submit it in a header. */
|
|
46
|
+
httpOnly?: boolean;
|
|
47
|
+
/** Default: NODE_ENV === "production". */
|
|
48
|
+
secure?: boolean;
|
|
49
|
+
/** Default: "lax". */
|
|
50
|
+
sameSite?: "strict" | "lax" | "none";
|
|
51
|
+
/** Default: "/". */
|
|
52
|
+
path?: string;
|
|
53
|
+
/** Default: 86400 (1 day). */
|
|
54
|
+
maxAge?: number;
|
|
55
|
+
/** Optional cookie domain. */
|
|
56
|
+
domain?: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Middleware signature matching `jwt.ts`. */
|
|
61
|
+
type Middleware = (ctx: ManduContext) => Promise<Response | void>;
|
|
62
|
+
|
|
63
|
+
// ========== Implementation ==========
|
|
64
|
+
|
|
65
|
+
const DEFAULT_COOKIE_NAME = "__csrf";
|
|
66
|
+
const DEFAULT_HEADER_NAME = "x-csrf-token";
|
|
67
|
+
const DEFAULT_FIELD_NAME = "_csrf";
|
|
68
|
+
const DEFAULT_SAFE_METHODS: readonly string[] = ["GET", "HEAD", "OPTIONS"];
|
|
69
|
+
const DEFAULT_MAX_AGE = 86400; // 1 day
|
|
70
|
+
/** Guard against memory-exhaustion attacks via oversized tokens. */
|
|
71
|
+
const MAX_TOKEN_LENGTH = 512;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* CSRF protection middleware (double-submit cookie pattern).
|
|
75
|
+
*
|
|
76
|
+
* Behavior:
|
|
77
|
+
* 1. Ensures a signed CSRF token cookie is present. Issues a fresh one if
|
|
78
|
+
* the existing cookie is missing or its signature fails to verify.
|
|
79
|
+
* 2. For safe methods (GET/HEAD/OPTIONS): continues without further checks.
|
|
80
|
+
* 3. For unsafe methods: reads the submitted token from the configured
|
|
81
|
+
* header (preferred) or form field (fallback for form content types),
|
|
82
|
+
* then confirms:
|
|
83
|
+
* (a) submitted token === cookie token (constant-time equality)
|
|
84
|
+
* (b) the token's HMAC signature still verifies with `secret`
|
|
85
|
+
* Any failure returns 403 without leaking which check failed.
|
|
86
|
+
*/
|
|
87
|
+
export function csrf(options: CsrfMiddlewareOptions): Middleware {
|
|
88
|
+
if (!options.secret || typeof options.secret !== "string") {
|
|
89
|
+
throw new Error("[Mandu CSRF] `secret` is required and must be a non-empty string");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const {
|
|
93
|
+
secret,
|
|
94
|
+
cookieName = DEFAULT_COOKIE_NAME,
|
|
95
|
+
headerName = DEFAULT_HEADER_NAME,
|
|
96
|
+
fieldName = DEFAULT_FIELD_NAME,
|
|
97
|
+
safeMethods = DEFAULT_SAFE_METHODS,
|
|
98
|
+
} = options;
|
|
99
|
+
|
|
100
|
+
const normalizedSafeMethods = new Set(safeMethods.map((m) => m.toUpperCase()));
|
|
101
|
+
const cookieOptions = resolveCookieOptions(options.cookieOptions);
|
|
102
|
+
const maxAgeSec = cookieOptions.maxAge ?? DEFAULT_MAX_AGE;
|
|
103
|
+
|
|
104
|
+
return async (ctx: ManduContext): Promise<Response | void> => {
|
|
105
|
+
const method = ctx.request.method.toUpperCase();
|
|
106
|
+
|
|
107
|
+
// 1. Ensure a valid CSRF cookie is present for the next unsafe request.
|
|
108
|
+
const existing = ctx.cookies.get(cookieName);
|
|
109
|
+
let activeCookieToken: string | null = null;
|
|
110
|
+
|
|
111
|
+
if (typeof existing === "string" && isAcceptableToken(existing)) {
|
|
112
|
+
const valid = await verifyToken(existing, secret, maxAgeSec);
|
|
113
|
+
if (valid) {
|
|
114
|
+
// Keep existing token (no unnecessary rotation).
|
|
115
|
+
activeCookieToken = existing;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (activeCookieToken === null) {
|
|
120
|
+
activeCookieToken = await generateToken(secret, maxAgeSec);
|
|
121
|
+
ctx.cookies.set(cookieName, activeCookieToken, cookieOptions);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 2. Safe methods pass through.
|
|
125
|
+
if (normalizedSafeMethods.has(method)) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3. Unsafe methods: read + validate submitted token.
|
|
130
|
+
const submitted = await extractSubmittedToken(ctx, headerName, fieldName);
|
|
131
|
+
|
|
132
|
+
if (!submitted || !isAcceptableToken(submitted)) {
|
|
133
|
+
return ctx.forbidden("CSRF token missing or invalid");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Constant-time equality between submitted token and cookie token.
|
|
137
|
+
if (!safeEqual(submitted, activeCookieToken)) {
|
|
138
|
+
return ctx.forbidden("CSRF token missing or invalid");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// HMAC verification on the submitted token (prevents forged cookies from
|
|
142
|
+
// sibling subdomains since they cannot sign with our secret).
|
|
143
|
+
const verified = await verifyToken(submitted, secret, maxAgeSec);
|
|
144
|
+
if (!verified) {
|
|
145
|
+
return ctx.forbidden("CSRF token missing or invalid");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Valid — continue.
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ========== Helpers ==========
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve cookie options with production-safe defaults.
|
|
156
|
+
*
|
|
157
|
+
* `httpOnly: false` by default: a CSRF token cookie needs to be readable by
|
|
158
|
+
* client-side JS so the app can echo it back in the header. Callers who set
|
|
159
|
+
* the token from the server (e.g. via a hidden form field) may opt into
|
|
160
|
+
* `httpOnly: true`.
|
|
161
|
+
*/
|
|
162
|
+
function resolveCookieOptions(overrides?: CsrfMiddlewareOptions["cookieOptions"]): CookieOptions {
|
|
163
|
+
const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production";
|
|
164
|
+
return {
|
|
165
|
+
httpOnly: overrides?.httpOnly ?? false,
|
|
166
|
+
secure: overrides?.secure ?? isProd,
|
|
167
|
+
sameSite: overrides?.sameSite ?? "lax",
|
|
168
|
+
path: overrides?.path ?? "/",
|
|
169
|
+
maxAge: overrides?.maxAge ?? DEFAULT_MAX_AGE,
|
|
170
|
+
domain: overrides?.domain,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Read submitted token from header, falling back to form field if applicable. */
|
|
175
|
+
async function extractSubmittedToken(
|
|
176
|
+
ctx: ManduContext,
|
|
177
|
+
headerName: string,
|
|
178
|
+
fieldName: string
|
|
179
|
+
): Promise<string | null> {
|
|
180
|
+
// Header wins when present (cheap, safe, no body consumption).
|
|
181
|
+
const headerVal = ctx.headers.get(headerName);
|
|
182
|
+
if (typeof headerVal === "string" && headerVal.length > 0) {
|
|
183
|
+
return headerVal;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Form fallback: only when the request advertises a form-like content type.
|
|
187
|
+
// JSON bodies are NOT scanned — header submission is the canonical path.
|
|
188
|
+
const contentType = (ctx.headers.get("content-type") ?? "").toLowerCase();
|
|
189
|
+
const isForm =
|
|
190
|
+
contentType.includes("application/x-www-form-urlencoded") ||
|
|
191
|
+
contentType.includes("multipart/form-data");
|
|
192
|
+
if (!isForm) return null;
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
// Clone so downstream handlers can still read the body.
|
|
196
|
+
const form = await ctx.request.clone().formData();
|
|
197
|
+
const fieldVal = form.get(fieldName);
|
|
198
|
+
return typeof fieldVal === "string" ? fieldVal : null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Validate token shape before running expensive crypto. */
|
|
205
|
+
function isAcceptableToken(token: string): boolean {
|
|
206
|
+
return (
|
|
207
|
+
typeof token === "string" &&
|
|
208
|
+
token.length > 0 &&
|
|
209
|
+
token.length <= MAX_TOKEN_LENGTH
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Constant-time string comparison to avoid timing-oracle attacks.
|
|
215
|
+
* Returns `false` immediately on length mismatch (lengths themselves are not
|
|
216
|
+
* secret for our fixed-format tokens), then XORs character codes over the
|
|
217
|
+
* full length before folding into a single diff bit.
|
|
218
|
+
*/
|
|
219
|
+
function safeEqual(a: string, b: string): boolean {
|
|
220
|
+
if (a.length !== b.length) return false;
|
|
221
|
+
let diff = 0;
|
|
222
|
+
for (let i = 0; i < a.length; i++) {
|
|
223
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
224
|
+
}
|
|
225
|
+
return diff === 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ========== Token crypto (Bun.CSRF preferred, fallback to crypto.subtle) ==========
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Runtime capability probe for `Bun.CSRF`. Done once at module load — avoids
|
|
232
|
+
* re-checking on every request and allows non-Bun runtimes to use the
|
|
233
|
+
* fallback implementation.
|
|
234
|
+
*/
|
|
235
|
+
const bunCsrf = resolveBunCsrf();
|
|
236
|
+
|
|
237
|
+
function resolveBunCsrf():
|
|
238
|
+
| {
|
|
239
|
+
generate: (secret: string, options?: { maxAge?: number }) => string;
|
|
240
|
+
verify: (token: string, options: { secret: string; maxAge?: number }) => boolean;
|
|
241
|
+
}
|
|
242
|
+
| null {
|
|
243
|
+
if (typeof globalThis === "undefined") return null;
|
|
244
|
+
const bun = (globalThis as { Bun?: { CSRF?: unknown } }).Bun;
|
|
245
|
+
if (!bun || typeof bun !== "object" || bun === null) return null;
|
|
246
|
+
const csrfApi = (bun as { CSRF?: unknown }).CSRF;
|
|
247
|
+
if (!csrfApi || typeof csrfApi !== "object") return null;
|
|
248
|
+
const api = csrfApi as {
|
|
249
|
+
generate?: unknown;
|
|
250
|
+
verify?: unknown;
|
|
251
|
+
};
|
|
252
|
+
if (typeof api.generate !== "function" || typeof api.verify !== "function") {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return api as {
|
|
256
|
+
generate: (secret: string, options?: { maxAge?: number }) => string;
|
|
257
|
+
verify: (token: string, options: { secret: string; maxAge?: number }) => boolean;
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function generateToken(secret: string, maxAgeSec: number): Promise<string> {
|
|
262
|
+
if (bunCsrf) {
|
|
263
|
+
// Bun.CSRF handles timestamp + random + HMAC in native code.
|
|
264
|
+
return bunCsrf.generate(secret, { maxAge: maxAgeSec });
|
|
265
|
+
}
|
|
266
|
+
return fallbackGenerate(secret);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function verifyToken(token: string, secret: string, maxAgeSec: number): Promise<boolean> {
|
|
270
|
+
if (bunCsrf) {
|
|
271
|
+
try {
|
|
272
|
+
return bunCsrf.verify(token, { secret, maxAge: maxAgeSec });
|
|
273
|
+
} catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return fallbackVerify(token, secret);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ----- Fallback (no Bun.CSRF available) -----
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Token format: `<random-b64url>.<hmac-b64url>`
|
|
284
|
+
* - random: 32 bytes via `crypto.getRandomValues`
|
|
285
|
+
* - hmac: HMAC-SHA256(random, secret)
|
|
286
|
+
*
|
|
287
|
+
* Same pattern as `packages/core/src/filling/session.ts` (`hmacSign`,
|
|
288
|
+
* line 216-227) so we don't introduce a second crypto code path.
|
|
289
|
+
*/
|
|
290
|
+
async function fallbackGenerate(secret: string): Promise<string> {
|
|
291
|
+
const random = new Uint8Array(32);
|
|
292
|
+
crypto.getRandomValues(random);
|
|
293
|
+
const randomPart = base64UrlEncode(random);
|
|
294
|
+
const sig = await hmacSignB64Url(randomPart, secret);
|
|
295
|
+
return `${randomPart}.${sig}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function fallbackVerify(token: string, secret: string): Promise<boolean> {
|
|
299
|
+
const dotIdx = token.lastIndexOf(".");
|
|
300
|
+
if (dotIdx <= 0 || dotIdx === token.length - 1) return false;
|
|
301
|
+
const randomPart = token.slice(0, dotIdx);
|
|
302
|
+
const signature = token.slice(dotIdx + 1);
|
|
303
|
+
if (!randomPart || !signature) return false;
|
|
304
|
+
const expected = await hmacSignB64Url(randomPart, secret);
|
|
305
|
+
// Constant-time comparison on signatures.
|
|
306
|
+
return safeEqual(signature, expected);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function hmacSignB64Url(data: string, secret: string): Promise<string> {
|
|
310
|
+
const encoder = new TextEncoder();
|
|
311
|
+
const key = await crypto.subtle.importKey(
|
|
312
|
+
"raw",
|
|
313
|
+
encoder.encode(secret),
|
|
314
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
315
|
+
false,
|
|
316
|
+
["sign"]
|
|
317
|
+
);
|
|
318
|
+
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
319
|
+
return base64UrlEncode(new Uint8Array(sig));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
323
|
+
let binary = "";
|
|
324
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
325
|
+
binary += String.fromCharCode(bytes[i]);
|
|
326
|
+
}
|
|
327
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
328
|
+
}
|