@janux/server 0.1.0 → 0.2.1
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 +3 -2
- package/src/__fixtures__/routes/about.tsx +10 -0
- package/src/__fixtures__/routes/evil.tsx +9 -0
- package/src/__fixtures__/routes/orders/[id].tsx +9 -0
- package/src/__fixtures__/routes/tags/[tag].tsx +5 -0
- package/src/agent-auth.test.ts +169 -0
- package/src/agent-auth.ts +67 -0
- package/src/api.ts +51 -15
- package/src/html-shell.test.ts +33 -0
- package/src/html-shell.ts +20 -3
- package/src/http.ts +44 -0
- package/src/index.ts +2 -0
- package/src/llms-txt.test.ts +92 -0
- package/src/llms-txt.ts +51 -0
- package/src/server.test.ts +21 -0
- package/src/server.ts +84 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@janux/server",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Janux fullstack server: SSR, api() RPC endpoints that double as agent tools, manifest service and MCP surface.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
".": "./src/index.ts"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"janux": "0.1
|
|
20
|
+
"janux": "0.2.1",
|
|
21
|
+
"web-bot-auth": "^0.1.3"
|
|
21
22
|
},
|
|
22
23
|
"homepage": "https://github.com/aralroca/Janux#readme",
|
|
23
24
|
"bugs": "https://github.com/aralroca/Janux/issues",
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { signatureHeaders } from 'web-bot-auth';
|
|
3
|
+
import { signerFromJWK } from 'web-bot-auth/crypto';
|
|
4
|
+
import { jsx, schema, str, type AuditEntry } from 'janux';
|
|
5
|
+
import { api } from './api';
|
|
6
|
+
import { createJanuxServer, type ServerOptions } from './server';
|
|
7
|
+
|
|
8
|
+
let goodJwk: JsonWebKey;
|
|
9
|
+
let goodPrivate: JsonWebKey;
|
|
10
|
+
let strangerPrivate: JsonWebKey;
|
|
11
|
+
|
|
12
|
+
async function generateEd25519(): Promise<{ publicJwk: JsonWebKey; privateJwk: JsonWebKey }> {
|
|
13
|
+
const pair = (await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])) as CryptoKeyPair;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
publicJwk: await crypto.subtle.exportKey('jwk', pair.publicKey),
|
|
17
|
+
privateJwk: await crypto.subtle.exportKey('jwk', pair.privateKey),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
const good = await generateEd25519();
|
|
23
|
+
const stranger = await generateEd25519();
|
|
24
|
+
|
|
25
|
+
goodJwk = good.publicJwk;
|
|
26
|
+
goodPrivate = good.privateJwk;
|
|
27
|
+
strangerPrivate = stranger.privateJwk;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function makeServer(overrides: Partial<ServerOptions> = {}, audit?: AuditEntry[]) {
|
|
31
|
+
return createJanuxServer({
|
|
32
|
+
routes: { '/': () => jsx('main', {}) },
|
|
33
|
+
apis: {
|
|
34
|
+
shop: {
|
|
35
|
+
echoAgent: api({
|
|
36
|
+
description: 'Echo the verified agent',
|
|
37
|
+
run: ({ ctx }) => ({ agent: ctx.agent ?? null }),
|
|
38
|
+
}),
|
|
39
|
+
pay: api({
|
|
40
|
+
description: 'Charge. Irreversible.',
|
|
41
|
+
input: schema({ total: str() }),
|
|
42
|
+
guard: 'confirm',
|
|
43
|
+
run: ({ input }) => ({ charged: input.total }),
|
|
44
|
+
}),
|
|
45
|
+
search: api({
|
|
46
|
+
description: 'Search products',
|
|
47
|
+
input: schema({ q: str() }),
|
|
48
|
+
run: ({ input }) => [input.q],
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
onAudit: audit ? (entry) => audit.push(entry) : undefined,
|
|
53
|
+
...overrides,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function signedRequest(path: string, body: unknown, privateJwk: JsonWebKey, expiresInMs = 60_000): Promise<Request> {
|
|
58
|
+
const request = new Request(`http://test${path}`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
headers: { 'content-type': 'application/json', 'x-janux-origin': 'agent' },
|
|
62
|
+
});
|
|
63
|
+
const expires = Date.now() + expiresInMs;
|
|
64
|
+
const headers = await signatureHeaders(request, await signerFromJWK(privateJwk), {
|
|
65
|
+
created: new Date(expires - 60_000),
|
|
66
|
+
expires: new Date(expires),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return new Request(request, {
|
|
70
|
+
headers: {
|
|
71
|
+
'content-type': 'application/json',
|
|
72
|
+
'x-janux-origin': 'agent',
|
|
73
|
+
Signature: headers['Signature'],
|
|
74
|
+
'Signature-Input': headers['Signature-Input'],
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const agentPost = (server: ReturnType<typeof createJanuxServer>, path: string, body: unknown) =>
|
|
80
|
+
server.fetch(
|
|
81
|
+
new Request(`http://test${path}`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
body: JSON.stringify(body),
|
|
84
|
+
headers: { 'content-type': 'application/json', 'x-janux-origin': 'agent' },
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
describe('web bot auth', () => {
|
|
89
|
+
it('observe policy serves unsigned agent calls with no identity', async () => {
|
|
90
|
+
const server = makeServer({ agents: { webBotAuth: { keys: [] }, policy: 'observe' } });
|
|
91
|
+
const body: any = await (await agentPost(server, '/_janux/api/shop.echoAgent', {})).json();
|
|
92
|
+
|
|
93
|
+
expect(body).toEqual({ ok: true, result: { agent: null } });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('require policy rejects unsigned agent calls with 401 and never gates humans', async () => {
|
|
97
|
+
const server = makeServer({ agents: { webBotAuth: { keys: [] }, policy: 'require' } });
|
|
98
|
+
const denied = await agentPost(server, '/_janux/api/shop.echoAgent', {});
|
|
99
|
+
const human = await server.fetch(
|
|
100
|
+
new Request('http://test/_janux/api/shop.echoAgent', {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
body: '{}',
|
|
103
|
+
headers: { 'content-type': 'application/json' },
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(denied.status).toBe(401);
|
|
108
|
+
expect(((await denied.json()) as any).error).toBe('agent_required');
|
|
109
|
+
expect(human.status).toBe(200);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('require policy accepts a signed request and exposes ctx.agent', async () => {
|
|
113
|
+
const server = makeServer({ agents: { webBotAuth: { keys: [goodJwk] }, policy: 'require' } });
|
|
114
|
+
const res = await server.fetch(await signedRequest('/_janux/api/shop.echoAgent', {}, goodPrivate));
|
|
115
|
+
const body: any = await res.json();
|
|
116
|
+
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
expect(body.result.agent.verified).toBe(true);
|
|
119
|
+
expect(typeof body.result.agent.keyId).toBe('string');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('fails closed on unknown keys and expired signatures', async () => {
|
|
123
|
+
const server = makeServer({ agents: { webBotAuth: { keys: [goodJwk] }, policy: 'require' } });
|
|
124
|
+
const stranger = await server.fetch(await signedRequest('/_janux/api/shop.echoAgent', {}, strangerPrivate));
|
|
125
|
+
const expired = await server.fetch(await signedRequest('/_janux/api/shop.echoAgent', {}, goodPrivate, -1000));
|
|
126
|
+
|
|
127
|
+
expect(stranger.status).toBe(401);
|
|
128
|
+
expect(expired.status).toBe(401);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('api audit trail', () => {
|
|
133
|
+
it('audits human and agent api calls, including failures', async () => {
|
|
134
|
+
const entries: AuditEntry[] = [];
|
|
135
|
+
const server = makeServer({}, entries);
|
|
136
|
+
|
|
137
|
+
await server.fetch(
|
|
138
|
+
new Request('http://test/_janux/api/shop.echoAgent', {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
body: '{}',
|
|
141
|
+
headers: { 'content-type': 'application/json' },
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
await agentPost(server, '/_janux/api/shop.search', { q: 42 });
|
|
145
|
+
|
|
146
|
+
expect(entries).toHaveLength(2);
|
|
147
|
+
expect(entries[0]).toMatchObject({ tool: 'api.shop.echoAgent', origin: 'human', ok: true });
|
|
148
|
+
expect(entries[1]).toMatchObject({ tool: 'api.shop.search', origin: 'agent', ok: false });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('audits the proposal → approve flow with the verified agent key', async () => {
|
|
152
|
+
const entries: AuditEntry[] = [];
|
|
153
|
+
const server = makeServer({ agents: { webBotAuth: { keys: [goodJwk] }, policy: 'require' } }, entries);
|
|
154
|
+
const proposalRes = await server.fetch(await signedRequest('/_janux/api/shop.pay', { total: '10' }, goodPrivate));
|
|
155
|
+
const proposal: any = ((await proposalRes.json()) as any).result;
|
|
156
|
+
|
|
157
|
+
await server.fetch(
|
|
158
|
+
new Request('http://test/_janux/approve', {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
body: JSON.stringify({ id: proposal.id }),
|
|
161
|
+
headers: { 'content-type': 'application/json' },
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(entries[0]).toMatchObject({ tool: 'api.shop.pay', origin: 'agent', guard: 'confirm', ok: true });
|
|
166
|
+
expect(typeof entries[0]?.agent).toBe('string');
|
|
167
|
+
expect(entries[1]).toMatchObject({ tool: 'api.shop.pay', origin: 'human', ok: true });
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { helpers, jwkToKeyID, verify } from 'web-bot-auth';
|
|
2
|
+
import { verifierFromJWK } from 'web-bot-auth/crypto';
|
|
3
|
+
|
|
4
|
+
export interface AgentIdentity {
|
|
5
|
+
verified: boolean;
|
|
6
|
+
keyId?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AgentsConfig {
|
|
10
|
+
webBotAuth: { keys: JsonWebKey[] };
|
|
11
|
+
policy?: 'observe' | 'require';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type KeyVerifier = (data: string, signature: Uint8Array, params: VerifyParams) => Promise<void>;
|
|
15
|
+
|
|
16
|
+
interface VerifyParams {
|
|
17
|
+
keyid: string;
|
|
18
|
+
expires: Date;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function keyEntry(jwk: JsonWebKey): Promise<[string, KeyVerifier]> {
|
|
22
|
+
const keyId = await jwkToKeyID(jwk, helpers.WEBCRYPTO_SHA256, helpers.BASE64URL_DECODE);
|
|
23
|
+
|
|
24
|
+
return [keyId, (await verifierFromJWK(jwk)) as unknown as KeyVerifier];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function loadKeys(config: AgentsConfig): Promise<Map<string, KeyVerifier>> {
|
|
28
|
+
return new Map(await Promise.all(config.webBotAuth.keys.map(keyEntry)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function dispatchVerifier(keys: Map<string, KeyVerifier>) {
|
|
32
|
+
return async (data: string, signature: Uint8Array, params: VerifyParams): Promise<string> => {
|
|
33
|
+
const verifyKey = keys.get(params.keyid);
|
|
34
|
+
|
|
35
|
+
if (!verifyKey) throw new Error(`Janux: unknown agent keyid "${params.keyid}"`);
|
|
36
|
+
if (params.expires.getTime() < Date.now()) throw new Error('Janux: agent signature expired');
|
|
37
|
+
await verifyKey(data, signature, params);
|
|
38
|
+
|
|
39
|
+
return params.keyid;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Web Bot Auth (RFC 9421) verification against an allowlist of agent JWKs.
|
|
45
|
+
* Fail closed: unknown key, bad signature or expired window ⇒ `verified: false`.
|
|
46
|
+
*/
|
|
47
|
+
export function createAgentAuth(config: AgentsConfig) {
|
|
48
|
+
let dispatchPromise: Promise<ReturnType<typeof dispatchVerifier>> | undefined;
|
|
49
|
+
|
|
50
|
+
const dispatch = () => (dispatchPromise ??= loadKeys(config).then(dispatchVerifier));
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
policy: config.policy ?? 'observe',
|
|
54
|
+
|
|
55
|
+
async identify(req: Request): Promise<AgentIdentity | null> {
|
|
56
|
+
if (!req.headers.get('signature')) return null;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return { verified: true, keyId: await verify(req, await dispatch()) };
|
|
60
|
+
} catch {
|
|
61
|
+
return { verified: false };
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type AgentAuth = ReturnType<typeof createAgentAuth>;
|
package/src/api.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { JxType, validate, toJsonSchema, JanuxIntentError } from 'janux';
|
|
2
|
-
import type { Ctx, Guard, GuardValue, Origin } from 'janux';
|
|
2
|
+
import type { AuditEntry, Ctx, Guard, GuardValue, Origin } from 'janux';
|
|
3
3
|
|
|
4
4
|
export interface ApiDef {
|
|
5
5
|
description?: string;
|
|
@@ -70,25 +70,61 @@ function parseApiInput(tool: ApiTool, input: unknown): unknown {
|
|
|
70
70
|
return result.value;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
73
|
+
function checkOutput(tool: ApiTool, result: unknown): unknown {
|
|
74
|
+
if (!tool.output) return result;
|
|
75
|
+
const check = validate(tool.output, result);
|
|
76
76
|
|
|
77
|
-
if (
|
|
78
|
-
throw new JanuxIntentError('forbidden', `Tool "${tool.name}" is not available`);
|
|
79
|
-
}
|
|
80
|
-
const parsed = parseApiInput(tool, input);
|
|
81
|
-
const result = await tool.run({ input: parsed, ctx });
|
|
77
|
+
if (!check.ok) throw new Error(`Janux: api "${tool.name}" returned an invalid output`);
|
|
82
78
|
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
return check.value;
|
|
80
|
+
}
|
|
85
81
|
|
|
86
|
-
|
|
82
|
+
/** The verified agent key id on the request context, if Web Bot Auth identified one. */
|
|
83
|
+
export function agentKeyId(ctx: Ctx): string | undefined {
|
|
84
|
+
const agent = ctx.agent as { verified?: boolean; keyId?: string } | undefined;
|
|
87
85
|
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
return agent?.verified ? agent.keyId : undefined;
|
|
87
|
+
}
|
|
90
88
|
|
|
91
|
-
|
|
89
|
+
export type ApiAudit = (entry: AuditEntry) => void;
|
|
90
|
+
|
|
91
|
+
/** The single place the api() audit-entry shape is assembled (used by the pipeline and the proposal path). */
|
|
92
|
+
export function apiAuditEntry(
|
|
93
|
+
tool: ApiTool,
|
|
94
|
+
origin: Origin,
|
|
95
|
+
guard: GuardValue,
|
|
96
|
+
ctx: Ctx,
|
|
97
|
+
extra: { input: unknown; ok: boolean; error?: string },
|
|
98
|
+
): AuditEntry {
|
|
99
|
+
return { tool: `api.${tool.name}`, origin, guard, at: Date.now(), agent: agentKeyId(ctx), ...extra };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Single invocation pipeline for api() tools: guard → validate → run → validate output. */
|
|
103
|
+
export async function invokeApi(
|
|
104
|
+
tool: ApiTool,
|
|
105
|
+
input: unknown,
|
|
106
|
+
ctx: Ctx,
|
|
107
|
+
origin: Origin,
|
|
108
|
+
onAudit?: ApiAudit,
|
|
109
|
+
): Promise<unknown> {
|
|
110
|
+
const guard = resolveApiGuard(tool, ctx);
|
|
111
|
+
const audit = (extra: { input: unknown; ok: boolean; error?: string }) =>
|
|
112
|
+
onAudit?.(apiAuditEntry(tool, origin, guard, ctx, extra));
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
if (origin === 'agent' && guard === 'forbidden') {
|
|
116
|
+
throw new JanuxIntentError('forbidden', `Tool "${tool.name}" is not available`);
|
|
117
|
+
}
|
|
118
|
+
const parsed = parseApiInput(tool, input);
|
|
119
|
+
const result = checkOutput(tool, await tool.run({ input: parsed, ctx }));
|
|
120
|
+
|
|
121
|
+
audit({ input: parsed, ok: true });
|
|
122
|
+
|
|
123
|
+
return result;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
audit({ input, ok: false, error: String(error) });
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
92
128
|
}
|
|
93
129
|
|
|
94
130
|
export function apiManifestTools(tools: ApiTool[], ctx: Ctx) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { htmlDocument, type ShellOptions } from './html-shell';
|
|
3
|
+
|
|
4
|
+
const base: ShellOptions = {
|
|
5
|
+
html: '<main>hi</main>',
|
|
6
|
+
snapshots: [],
|
|
7
|
+
islandNames: [],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// These guard the SPA-navigation FOUC fix: head resource links must be keyed
|
|
11
|
+
// (matched by identity across the diff) and the conditional description meta
|
|
12
|
+
// must sit AFTER the stylesheets, so omitting it never shifts the stylesheet's
|
|
13
|
+
// position — otherwise the diff re-resolves it and the page flashes unstyled.
|
|
14
|
+
describe('htmlDocument head keying (SPA-navigation FOUC guard)', () => {
|
|
15
|
+
it('gives stylesheet, favicon and manifest links a stable id', () => {
|
|
16
|
+
const html = htmlDocument({
|
|
17
|
+
...base,
|
|
18
|
+
stylesheets: ['/styles.css'],
|
|
19
|
+
favicon: '/favicon.svg',
|
|
20
|
+
manifestUrl: '/_janux/manifest',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(html).toContain('<link rel="stylesheet" id="jx-style-0" href="/styles.css">');
|
|
24
|
+
expect(html).toContain('<link rel="icon" id="jx-favicon" href="/favicon.svg">');
|
|
25
|
+
expect(html).toContain('id="jx-manifest"');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('places the conditional description meta after the stylesheet links', () => {
|
|
29
|
+
const html = htmlDocument({ ...base, stylesheets: ['/styles.css'], description: 'D' });
|
|
30
|
+
|
|
31
|
+
expect(html.indexOf('id="jx-style-0"')).toBeLessThan(html.indexOf('name="description"'));
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/html-shell.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
export interface ShellOptions {
|
|
2
2
|
html: string;
|
|
3
3
|
title?: string;
|
|
4
|
+
description?: string;
|
|
4
5
|
snapshots: { uri: string; state: Record<string, unknown>; sources?: Record<string, unknown> }[];
|
|
5
6
|
islandNames: string[];
|
|
6
7
|
islandModules?: Record<string, string>;
|
|
7
8
|
runtimeUrl?: string;
|
|
8
9
|
manifestUrl?: string;
|
|
9
10
|
stylesheets?: string[];
|
|
11
|
+
favicon?: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
function safeJson(value: unknown): string {
|
|
@@ -45,16 +47,31 @@ function runtimeScripts(options: ShellOptions): string {
|
|
|
45
47
|
*/
|
|
46
48
|
export function htmlDocument(options: ShellOptions): string {
|
|
47
49
|
const manifestLink = options.manifestUrl
|
|
48
|
-
? `<link rel="janux-manifest" href="${options.manifestUrl}">`
|
|
50
|
+
? `<link rel="janux-manifest" id="jx-manifest" href="${options.manifestUrl}">`
|
|
49
51
|
: '';
|
|
52
|
+
// Stable ids key these head links across an SPA-navigation diff so the diff
|
|
53
|
+
// matches them by identity instead of by position. Without a key, a page
|
|
54
|
+
// whose head has a different node count (e.g. a description meta present on
|
|
55
|
+
// one page, absent on another) shifts every following node, making the diff
|
|
56
|
+
// re-resolve the stylesheet link — a brief unstyled flash. See navigate.ts.
|
|
50
57
|
const styleLinks = (options.stylesheets ?? [])
|
|
51
|
-
.map((href) => `<link rel="stylesheet" href="${safeAttr(href)}">`)
|
|
58
|
+
.map((href, index) => `<link rel="stylesheet" id="jx-style-${index}" href="${safeAttr(href)}">`)
|
|
52
59
|
.join('');
|
|
60
|
+
const description = options.description
|
|
61
|
+
? `<meta name="description" id="jx-description" content="${safeAttr(options.description)}">`
|
|
62
|
+
: '';
|
|
63
|
+
const favicon = options.favicon
|
|
64
|
+
? `<link rel="icon" id="jx-favicon" href="${safeAttr(options.favicon)}">`
|
|
65
|
+
: '';
|
|
53
66
|
|
|
54
67
|
return [
|
|
55
68
|
'<!doctype html>',
|
|
56
69
|
'<html>',
|
|
57
|
-
|
|
70
|
+
// Order matters for SPA-navigation diffing: persistent, keyed resource
|
|
71
|
+
// links (favicon, stylesheets) sit before the conditional description meta,
|
|
72
|
+
// so a page that omits the description never shifts the stylesheet's
|
|
73
|
+
// position — it stays put across the diff instead of being moved/re-resolved.
|
|
74
|
+
`<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${safeAttr(options.title ?? 'Janux app')}</title>${favicon}${manifestLink}${styleLinks}${description}</head>`,
|
|
58
75
|
'<body>',
|
|
59
76
|
options.html,
|
|
60
77
|
options.islandNames.length > 0 ? stateScripts(options.snapshots) : '',
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { validate } from 'janux';
|
|
2
|
+
|
|
3
|
+
export interface PendingApiProposal {
|
|
4
|
+
id: string;
|
|
5
|
+
tool: string;
|
|
6
|
+
input: unknown;
|
|
7
|
+
execute: () => Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MAX_PENDING_PROPOSALS = 100;
|
|
11
|
+
|
|
12
|
+
export function json(body: unknown, status = 200): Response {
|
|
13
|
+
return new Response(JSON.stringify(body), {
|
|
14
|
+
status,
|
|
15
|
+
headers: { 'content-type': 'application/json' },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function errorStatus(error: unknown): number {
|
|
20
|
+
const code = (error as any)?.code;
|
|
21
|
+
|
|
22
|
+
return code === 'forbidden' ? 403 : code === 'invalid_input' ? 400 : 500;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function evictOldestProposal(proposals: Map<string, PendingApiProposal>): void {
|
|
26
|
+
if (proposals.size < MAX_PENDING_PROPOSALS) return;
|
|
27
|
+
const oldest = proposals.keys().next().value;
|
|
28
|
+
|
|
29
|
+
if (oldest) proposals.delete(oldest);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function assertValidInput(tool: { name: string; input?: any }, input: unknown): unknown {
|
|
33
|
+
const result = validate(tool.input, input ?? {});
|
|
34
|
+
|
|
35
|
+
if (!result.ok) {
|
|
36
|
+
const detail = result.errors.map((e: any) => `${e.path}: ${e.message}`).join('; ');
|
|
37
|
+
|
|
38
|
+
throw Object.assign(new Error(`Invalid input for "${tool.name}" — ${detail}`), {
|
|
39
|
+
code: 'invalid_input',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return result.value;
|
|
44
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { api, collectApis, invokeApi, apiManifestTools, isApi, type ApiDef, type ApiTool } from './api';
|
|
2
2
|
export { createJanuxServer, type ServerOptions, type AgentMount, type AgentDeps } from './server';
|
|
3
3
|
export { createFsRouter, type RouteMatch } from './router';
|
|
4
|
+
export { buildLlmsTxt, type LlmsTxtConfig, type LlmsTxtTool } from './llms-txt';
|
|
5
|
+
export { createAgentAuth, type AgentAuth, type AgentIdentity, type AgentsConfig } from './agent-auth';
|
|
4
6
|
export { htmlDocument, type ShellOptions } from './html-shell';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { jsx, schema, str } from 'janux';
|
|
3
|
+
import { api } from './api';
|
|
4
|
+
import { buildLlmsTxt, expandPattern } from './llms-txt';
|
|
5
|
+
import { createJanuxServer } from './server';
|
|
6
|
+
|
|
7
|
+
describe('expandPattern', () => {
|
|
8
|
+
it('substitutes every dynamic segment per params record, URI-encoded', () => {
|
|
9
|
+
const paths = expandPattern('/docs/[section]/[slug]', [
|
|
10
|
+
{ section: 'guide', slug: 'getting-started' },
|
|
11
|
+
{ section: 'more', slug: 'a b' },
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
expect(paths).toEqual(['/docs/guide/getting-started', '/docs/more/a%20b']);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('drops records missing a param', () => {
|
|
18
|
+
expect(expandPattern('/docs/[section]/[slug]', [{ section: 'guide' }])).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('buildLlmsTxt', () => {
|
|
23
|
+
it('renders title, pages and annotated tools', () => {
|
|
24
|
+
const output = buildLlmsTxt(
|
|
25
|
+
{ title: 'Shop', description: 'A demo shop.' },
|
|
26
|
+
['/', '/cart'],
|
|
27
|
+
[
|
|
28
|
+
{ name: 'api.shop.catalog', description: 'List products', guard: 'auto' },
|
|
29
|
+
{ name: 'api.shop.pay', description: 'Charge the cart', guard: 'confirm' },
|
|
30
|
+
],
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(output).toContain('# Shop');
|
|
34
|
+
expect(output).toContain('> A demo shop.');
|
|
35
|
+
expect(output).toContain('- [/cart](/cart)');
|
|
36
|
+
expect(output).toContain('- [api.shop.catalog](/_janux/api/shop.catalog): List products');
|
|
37
|
+
expect(output).toContain('- [api.shop.pay](/_janux/api/shop.pay): Charge the cart (requires human approval)');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('is byte-stable for an app without pages or tools', () => {
|
|
41
|
+
expect(buildLlmsTxt({}, [], [])).toBe('# Janux app\n');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('GET /llms.txt', () => {
|
|
46
|
+
const apis = {
|
|
47
|
+
pay: api({ description: 'Charge', input: schema({ total: str() }), guard: 'confirm', run: () => ({}) }),
|
|
48
|
+
hidden: api({ guard: 'forbidden', run: () => 'secret' }),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
it('serves the index when llmsTxt is configured, excluding forbidden tools', async () => {
|
|
52
|
+
const server = createJanuxServer({
|
|
53
|
+
routes: { '/': () => jsx('main', {}) },
|
|
54
|
+
apis: { shop: apis },
|
|
55
|
+
title: 'Shop',
|
|
56
|
+
llmsTxt: { description: 'A demo shop.' },
|
|
57
|
+
});
|
|
58
|
+
const res = await server.fetch(new Request('http://test/llms.txt'));
|
|
59
|
+
const body = await res.text();
|
|
60
|
+
|
|
61
|
+
expect(res.headers.get('content-type')).toContain('text/plain');
|
|
62
|
+
expect(body).toContain('# Shop');
|
|
63
|
+
expect(body).toContain('api.shop.pay');
|
|
64
|
+
expect(body).not.toContain('hidden');
|
|
65
|
+
expect(await (await server.fetch(new Request('http://test/llms.txt'))).text()).toBe(body);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('expands dynamic fs routes via staticParams and keeps the pattern without it', async () => {
|
|
69
|
+
const server = createJanuxServer({
|
|
70
|
+
routesDir: `${import.meta.dirname}/__fixtures__/routes`,
|
|
71
|
+
llmsTxt: {},
|
|
72
|
+
});
|
|
73
|
+
const body = await (await server.fetch(new Request('http://test/llms.txt'))).text();
|
|
74
|
+
|
|
75
|
+
expect(body).toContain('- [/orders/1](/orders/1)');
|
|
76
|
+
expect(body).toContain('- [/orders/2](/orders/2)');
|
|
77
|
+
expect(body).not.toContain('[id]');
|
|
78
|
+
expect(body).toContain('- [/tags/[tag]](/tags/[tag])');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('listPages returns concrete paths, keeping unexpandable patterns', async () => {
|
|
82
|
+
const server = createJanuxServer({ routesDir: `${import.meta.dirname}/__fixtures__/routes` });
|
|
83
|
+
|
|
84
|
+
expect(await server.listPages()).toEqual(['/about', '/evil', '/orders/1', '/orders/2', '/tags/[tag]']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('404s when llmsTxt is not configured', async () => {
|
|
88
|
+
const server = createJanuxServer({ routes: { '/': () => jsx('main', {}) } });
|
|
89
|
+
|
|
90
|
+
expect((await server.fetch(new Request('http://test/llms.txt'))).status).toBe(404);
|
|
91
|
+
});
|
|
92
|
+
});
|
package/src/llms-txt.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface LlmsTxtConfig {
|
|
2
|
+
title?: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface LlmsTxtTool {
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
guard: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PARAM_SEGMENT = /\[([^\]]+)\]/g;
|
|
13
|
+
|
|
14
|
+
function fillPattern(pattern: string, params: Record<string, unknown>): string | undefined {
|
|
15
|
+
const names = [...pattern.matchAll(PARAM_SEGMENT)].map((match) => match[1]!);
|
|
16
|
+
|
|
17
|
+
if (!names.every((name) => params[name] != null)) return undefined;
|
|
18
|
+
|
|
19
|
+
return pattern.replace(PARAM_SEGMENT, (_, name) => encodeURIComponent(String(params[name])));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Expands a dynamic route pattern with each params record; records missing a param are dropped. */
|
|
23
|
+
export function expandPattern(pattern: string, paramsList: Array<Record<string, unknown>>): string[] {
|
|
24
|
+
return paramsList
|
|
25
|
+
.map((params) => fillPattern(pattern, params))
|
|
26
|
+
.filter((path): path is string => path !== undefined);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TOOLS_INTRO =
|
|
30
|
+
'Server tools callable via `POST /_janux/api/<name>` (JSON body). ' +
|
|
31
|
+
'Per-page tools and resources: `GET /_janux/manifest?path=<page>`.';
|
|
32
|
+
|
|
33
|
+
function toolLine(tool: LlmsTxtTool): string {
|
|
34
|
+
const wire = tool.name.replace(/^api\./, '');
|
|
35
|
+
const approval = tool.guard === 'confirm' ? ' (requires human approval)' : '';
|
|
36
|
+
const description = tool.description ? `: ${tool.description}` : ':';
|
|
37
|
+
|
|
38
|
+
return `- [${tool.name}](/_janux/api/${wire})${description}${approval}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Renders the llms.txt markdown index: title, pages and the agent tool surface. */
|
|
42
|
+
export function buildLlmsTxt(config: LlmsTxtConfig, pages: string[], tools: LlmsTxtTool[]): string {
|
|
43
|
+
const blocks = [
|
|
44
|
+
`# ${config.title ?? 'Janux app'}`,
|
|
45
|
+
config.description ? `> ${config.description}` : undefined,
|
|
46
|
+
pages.length > 0 ? `## Pages\n\n${pages.map((page) => `- [${page}](${page})`).join('\n')}` : undefined,
|
|
47
|
+
tools.length > 0 ? `## Agent tools\n\n${TOOLS_INTRO}\n\n${tools.map(toolLine).join('\n')}` : undefined,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
return `${blocks.filter(Boolean).join('\n\n')}\n`;
|
|
51
|
+
}
|
package/src/server.test.ts
CHANGED
|
@@ -116,6 +116,27 @@ describe('api endpoints', () => {
|
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
describe('route meta', () => {
|
|
120
|
+
it('uses route-level meta for title and description', async () => {
|
|
121
|
+
const fsServer = createJanuxServer({ routesDir: `${import.meta.dirname}/__fixtures__/routes` });
|
|
122
|
+
const html = await (await fsServer.fetch(new Request('http://test/about'))).text();
|
|
123
|
+
|
|
124
|
+
expect(html).toContain('<title>About — Janux fixture</title>');
|
|
125
|
+
expect(html).toContain('<meta name="description" id="jx-description" content="Route-level metadata fixture.">');
|
|
126
|
+
expect(html).toContain('<main>About page</main>');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('title escaping (XSS regression)', () => {
|
|
131
|
+
it('escapes route meta titles', async () => {
|
|
132
|
+
const fsServer = createJanuxServer({ routesDir: `${import.meta.dirname}/__fixtures__/routes` });
|
|
133
|
+
const html = await (await fsServer.fetch(new Request('http://test/evil'))).text();
|
|
134
|
+
|
|
135
|
+
expect(html).not.toContain('</title><script>');
|
|
136
|
+
expect(html).toContain('</title><script>');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
119
140
|
describe('manifest endpoint', () => {
|
|
120
141
|
it('merges mounted islands and api tools per route', async () => {
|
|
121
142
|
const manifest: any = await (await get('/_janux/manifest?path=/shop')).json();
|
package/src/server.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { buildManifest, renderToString,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { buildManifest, renderToString, type AuditEntry, type ComponentDef, type Ctx } from 'janux';
|
|
2
|
+
import { assertValidInput, errorStatus, evictOldestProposal, json, type PendingApiProposal } from './http';
|
|
3
|
+
import { apiAuditEntry, apiManifestTools, collectApis, invokeApi, resolveApiGuard, type ApiTool } from './api';
|
|
4
|
+
import { createAgentAuth, type AgentIdentity, type AgentsConfig } from './agent-auth';
|
|
5
|
+
import { createFsRouter, type Route } from './router';
|
|
4
6
|
import { htmlDocument } from './html-shell';
|
|
7
|
+
import { buildLlmsTxt, expandPattern, type LlmsTxtConfig, type LlmsTxtTool } from './llms-txt';
|
|
5
8
|
|
|
6
9
|
export interface AgentMount {
|
|
7
10
|
handle(req: Request, deps: AgentDeps): Promise<Response>;
|
|
11
|
+
/** One-turn LLM proxy for browser-side agent loops (`serverLlm()` from `@janux/agent/local`). */
|
|
12
|
+
handleLlm?(req: Request): Promise<Response>;
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
export interface AgentDeps {
|
|
@@ -25,49 +30,32 @@ export interface ServerOptions {
|
|
|
25
30
|
islandModules?: Record<string, string>;
|
|
26
31
|
title?: string;
|
|
27
32
|
stylesheets?: string[];
|
|
33
|
+
favicon?: string;
|
|
34
|
+
llmsTxt?: LlmsTxtConfig;
|
|
35
|
+
agents?: AgentsConfig;
|
|
36
|
+
onAudit?: (entry: AuditEntry) => void;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
status,
|
|
40
|
-
headers: { 'content-type': 'application/json' },
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const MAX_PENDING_PROPOSALS = 100;
|
|
45
|
-
|
|
46
|
-
function evictOldestProposal(proposals: Map<string, PendingApiProposal>): void {
|
|
47
|
-
if (proposals.size < MAX_PENDING_PROPOSALS) return;
|
|
48
|
-
const oldest = proposals.keys().next().value;
|
|
49
|
-
|
|
50
|
-
if (oldest) proposals.delete(oldest);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function assertValidInput(tool: { name: string; input?: any }, input: unknown): unknown {
|
|
54
|
-
const result = validate(tool.input, input ?? {});
|
|
55
|
-
|
|
56
|
-
if (!result.ok) {
|
|
57
|
-
const detail = result.errors.map((e: any) => `${e.path}: ${e.message}`).join('; ');
|
|
58
|
-
|
|
59
|
-
throw Object.assign(new Error(`Invalid input for "${tool.name}" — ${detail}`), {
|
|
60
|
-
code: 'invalid_input',
|
|
61
|
-
});
|
|
39
|
+
async function resolveMeta(
|
|
40
|
+
rawMeta: unknown,
|
|
41
|
+
ctx: Ctx,
|
|
42
|
+
params: Record<string, string>,
|
|
43
|
+
): Promise<{ title?: string; description?: string } | undefined> {
|
|
44
|
+
try {
|
|
45
|
+
return typeof rawMeta === 'function' ? await rawMeta({ ctx, params }) : (rawMeta as any);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
62
48
|
}
|
|
63
|
-
|
|
64
|
-
return result.value;
|
|
65
49
|
}
|
|
66
50
|
|
|
67
|
-
function
|
|
68
|
-
|
|
51
|
+
async function resolveStaticParams(rawParams: unknown): Promise<Array<Record<string, unknown>>> {
|
|
52
|
+
try {
|
|
53
|
+
const value = typeof rawParams === 'function' ? await rawParams() : rawParams;
|
|
69
54
|
|
|
70
|
-
|
|
55
|
+
return Array.isArray(value) ? value : [];
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
71
59
|
}
|
|
72
60
|
|
|
73
61
|
let proposalSeq = 0;
|
|
@@ -81,6 +69,34 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
81
69
|
|
|
82
70
|
const resolveCtx = async (req: Request): Promise<Ctx> => (await options.ctxFor?.(req)) ?? {};
|
|
83
71
|
|
|
72
|
+
const agentAuth = options.agents ? createAgentAuth(options.agents) : undefined;
|
|
73
|
+
|
|
74
|
+
let llmsTxtBody: string | undefined;
|
|
75
|
+
|
|
76
|
+
const expandRoute = async (route: Route): Promise<string[]> => {
|
|
77
|
+
if (!route.pattern.includes('[')) return [route.pattern];
|
|
78
|
+
const module = (await loadRoute(route.filePath).catch(() => undefined)) as any;
|
|
79
|
+
const expanded = expandPattern(route.pattern, await resolveStaticParams(module?.staticParams));
|
|
80
|
+
|
|
81
|
+
return expanded.length > 0 ? expanded : [route.pattern];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const listPages = async (): Promise<string[]> => {
|
|
85
|
+
const fsPages = (await Promise.all(router?.routes.map(expandRoute) ?? [])).flat();
|
|
86
|
+
|
|
87
|
+
return [...Object.keys(options.routes ?? {}), ...fsPages];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const renderLlmsTxt = async (): Promise<string> =>
|
|
91
|
+
buildLlmsTxt({ title: options.title, ...options.llmsTxt }, await listPages(), apiManifestTools(apiTools, {}) as LlmsTxtTool[]);
|
|
92
|
+
|
|
93
|
+
const ctxWithAgent = async (req: Request): Promise<Ctx> => {
|
|
94
|
+
const ctx = await resolveCtx(req);
|
|
95
|
+
const identity = (await agentAuth?.identify(req)) ?? null;
|
|
96
|
+
|
|
97
|
+
return identity ? { ...ctx, agent: identity } : ctx;
|
|
98
|
+
};
|
|
99
|
+
|
|
84
100
|
const findRoute = (pathname: string) => {
|
|
85
101
|
if (options.routes?.[pathname]) {
|
|
86
102
|
return { render: options.routes[pathname]!, params: {} as Record<string, string> };
|
|
@@ -94,10 +110,13 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
94
110
|
const route = findRoute(pathname);
|
|
95
111
|
|
|
96
112
|
if (!route) return undefined;
|
|
97
|
-
const
|
|
98
|
-
const
|
|
113
|
+
const module = 'render' in route ? undefined : ((await loadRoute(route.load)) as any);
|
|
114
|
+
const render = 'render' in route ? route.render : module.default;
|
|
115
|
+
const meta = await resolveMeta(module?.meta, ctx, route.params);
|
|
116
|
+
const vnode = await render({ ctx, params: route.params });
|
|
117
|
+
const result = await renderToString(vnode, { ctx, storeDefs: options.storeDefs });
|
|
99
118
|
|
|
100
|
-
return
|
|
119
|
+
return { ...result, meta };
|
|
101
120
|
};
|
|
102
121
|
|
|
103
122
|
const manifestFor = async (pathname: string, ctx: Ctx): Promise<unknown> => {
|
|
@@ -118,7 +137,7 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
118
137
|
|
|
119
138
|
if (!tool) throw Object.assign(new Error(`Unknown api tool "${name}"`), { code: 'invalid_input' });
|
|
120
139
|
|
|
121
|
-
return invokeApi(tool, input, ctx, 'agent');
|
|
140
|
+
return invokeApi(tool, input, ctx, 'agent', options.onAudit);
|
|
122
141
|
};
|
|
123
142
|
|
|
124
143
|
const handleApi = async (req: Request, name: string): Promise<Response> => {
|
|
@@ -127,7 +146,11 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
127
146
|
if (!tool) return json({ ok: false, error: `Unknown api "${name}"` }, 404);
|
|
128
147
|
const origin = req.headers.get('x-janux-origin') === 'agent' ? 'agent' : 'human';
|
|
129
148
|
const input = await req.json().catch(() => ({}));
|
|
130
|
-
const ctx = await
|
|
149
|
+
const ctx = await ctxWithAgent(req);
|
|
150
|
+
|
|
151
|
+
if (origin === 'agent' && agentAuth?.policy === 'require' && !(ctx.agent as AgentIdentity | undefined)?.verified) {
|
|
152
|
+
return json({ ok: false, error: 'agent_required' }, 401);
|
|
153
|
+
}
|
|
131
154
|
|
|
132
155
|
try {
|
|
133
156
|
if (origin === 'agent' && resolveApiGuard(tool, ctx) === 'confirm') {
|
|
@@ -135,12 +158,13 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
135
158
|
const id = `prop_api_${(proposalSeq += 1)}`;
|
|
136
159
|
|
|
137
160
|
evictOldestProposal(proposals);
|
|
138
|
-
proposals.set(id, { id, tool: tool.name, input: parsed, execute: () => invokeApi(tool, parsed, ctx, 'human') });
|
|
161
|
+
proposals.set(id, { id, tool: tool.name, input: parsed, execute: () => invokeApi(tool, parsed, ctx, 'human', options.onAudit) });
|
|
162
|
+
options.onAudit?.(apiAuditEntry(tool, origin, 'confirm', ctx, { input: parsed, ok: true }));
|
|
139
163
|
|
|
140
164
|
return json({ ok: true, result: { status: 'proposal', id, tool: tool.name, input: parsed } });
|
|
141
165
|
}
|
|
142
166
|
|
|
143
|
-
return json({ ok: true, result: await invokeApi(tool, input, ctx, origin) });
|
|
167
|
+
return json({ ok: true, result: await invokeApi(tool, input, ctx, origin, options.onAudit) });
|
|
144
168
|
} catch (error) {
|
|
145
169
|
return json({ ok: false, error: String(error) }, errorStatus(error));
|
|
146
170
|
}
|
|
@@ -164,13 +188,15 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
164
188
|
const islandNames = [...new Set(result.registry.islands.map(({ def }) => def.name))];
|
|
165
189
|
const html = htmlDocument({
|
|
166
190
|
html: result.html,
|
|
167
|
-
title: options.title,
|
|
191
|
+
title: result.meta?.title ?? options.title,
|
|
192
|
+
description: result.meta?.description,
|
|
168
193
|
snapshots: result.snapshots,
|
|
169
194
|
islandNames,
|
|
170
195
|
islandModules: options.islandModules,
|
|
171
196
|
runtimeUrl: islandNames.length > 0 ? options.runtimeUrl : undefined,
|
|
172
197
|
manifestUrl: `/_janux/manifest?path=${encodeURIComponent(pathname)}`,
|
|
173
198
|
stylesheets: options.stylesheets,
|
|
199
|
+
favicon: options.favicon,
|
|
174
200
|
});
|
|
175
201
|
|
|
176
202
|
return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
@@ -187,11 +213,19 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
187
213
|
|
|
188
214
|
return json({ ok: id ? proposals.delete(id) : false });
|
|
189
215
|
}
|
|
216
|
+
if (pathname === '/llms.txt' && options.llmsTxt) {
|
|
217
|
+
llmsTxtBody ??= await renderLlmsTxt();
|
|
218
|
+
|
|
219
|
+
return new Response(llmsTxtBody, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
220
|
+
}
|
|
190
221
|
if (pathname === '/_janux/manifest') {
|
|
191
222
|
return json(await manifestFor(url.searchParams.get('path') ?? '/', await resolveCtx(req)));
|
|
192
223
|
}
|
|
224
|
+
if (pathname === '/_janux/llm' && options.agent?.handleLlm) {
|
|
225
|
+
return options.agent.handleLlm(req);
|
|
226
|
+
}
|
|
193
227
|
if (pathname === '/_janux/agent' && options.agent) {
|
|
194
|
-
const ctx = await
|
|
228
|
+
const ctx = await ctxWithAgent(req);
|
|
195
229
|
|
|
196
230
|
return options.agent.handle(req, {
|
|
197
231
|
tools: apiTools,
|
|
@@ -203,5 +237,5 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
203
237
|
return handlePage(req, pathname);
|
|
204
238
|
};
|
|
205
239
|
|
|
206
|
-
return { fetch, apiTools, manifestFor };
|
|
240
|
+
return { fetch, apiTools, manifestFor, listPages };
|
|
207
241
|
}
|