@janux/server 0.1.0 → 0.2.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 +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 +79 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@janux/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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.0"
|
|
20
|
+
"janux": "0.1.0",
|
|
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,7 +1,10 @@
|
|
|
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>;
|
|
@@ -25,49 +28,32 @@ export interface ServerOptions {
|
|
|
25
28
|
islandModules?: Record<string, string>;
|
|
26
29
|
title?: string;
|
|
27
30
|
stylesheets?: string[];
|
|
31
|
+
favicon?: string;
|
|
32
|
+
llmsTxt?: LlmsTxtConfig;
|
|
33
|
+
agents?: AgentsConfig;
|
|
34
|
+
onAudit?: (entry: AuditEntry) => void;
|
|
28
35
|
}
|
|
29
36
|
|
|
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
|
-
});
|
|
37
|
+
async function resolveMeta(
|
|
38
|
+
rawMeta: unknown,
|
|
39
|
+
ctx: Ctx,
|
|
40
|
+
params: Record<string, string>,
|
|
41
|
+
): Promise<{ title?: string; description?: string } | undefined> {
|
|
42
|
+
try {
|
|
43
|
+
return typeof rawMeta === 'function' ? await rawMeta({ ctx, params }) : (rawMeta as any);
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
62
46
|
}
|
|
63
|
-
|
|
64
|
-
return result.value;
|
|
65
47
|
}
|
|
66
48
|
|
|
67
|
-
function
|
|
68
|
-
|
|
49
|
+
async function resolveStaticParams(rawParams: unknown): Promise<Array<Record<string, unknown>>> {
|
|
50
|
+
try {
|
|
51
|
+
const value = typeof rawParams === 'function' ? await rawParams() : rawParams;
|
|
69
52
|
|
|
70
|
-
|
|
53
|
+
return Array.isArray(value) ? value : [];
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
71
57
|
}
|
|
72
58
|
|
|
73
59
|
let proposalSeq = 0;
|
|
@@ -81,6 +67,34 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
81
67
|
|
|
82
68
|
const resolveCtx = async (req: Request): Promise<Ctx> => (await options.ctxFor?.(req)) ?? {};
|
|
83
69
|
|
|
70
|
+
const agentAuth = options.agents ? createAgentAuth(options.agents) : undefined;
|
|
71
|
+
|
|
72
|
+
let llmsTxtBody: string | undefined;
|
|
73
|
+
|
|
74
|
+
const expandRoute = async (route: Route): Promise<string[]> => {
|
|
75
|
+
if (!route.pattern.includes('[')) return [route.pattern];
|
|
76
|
+
const module = (await loadRoute(route.filePath).catch(() => undefined)) as any;
|
|
77
|
+
const expanded = expandPattern(route.pattern, await resolveStaticParams(module?.staticParams));
|
|
78
|
+
|
|
79
|
+
return expanded.length > 0 ? expanded : [route.pattern];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const listPages = async (): Promise<string[]> => {
|
|
83
|
+
const fsPages = (await Promise.all(router?.routes.map(expandRoute) ?? [])).flat();
|
|
84
|
+
|
|
85
|
+
return [...Object.keys(options.routes ?? {}), ...fsPages];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const renderLlmsTxt = async (): Promise<string> =>
|
|
89
|
+
buildLlmsTxt({ title: options.title, ...options.llmsTxt }, await listPages(), apiManifestTools(apiTools, {}) as LlmsTxtTool[]);
|
|
90
|
+
|
|
91
|
+
const ctxWithAgent = async (req: Request): Promise<Ctx> => {
|
|
92
|
+
const ctx = await resolveCtx(req);
|
|
93
|
+
const identity = (await agentAuth?.identify(req)) ?? null;
|
|
94
|
+
|
|
95
|
+
return identity ? { ...ctx, agent: identity } : ctx;
|
|
96
|
+
};
|
|
97
|
+
|
|
84
98
|
const findRoute = (pathname: string) => {
|
|
85
99
|
if (options.routes?.[pathname]) {
|
|
86
100
|
return { render: options.routes[pathname]!, params: {} as Record<string, string> };
|
|
@@ -94,10 +108,13 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
94
108
|
const route = findRoute(pathname);
|
|
95
109
|
|
|
96
110
|
if (!route) return undefined;
|
|
97
|
-
const
|
|
98
|
-
const
|
|
111
|
+
const module = 'render' in route ? undefined : ((await loadRoute(route.load)) as any);
|
|
112
|
+
const render = 'render' in route ? route.render : module.default;
|
|
113
|
+
const meta = await resolveMeta(module?.meta, ctx, route.params);
|
|
114
|
+
const vnode = await render({ ctx, params: route.params });
|
|
115
|
+
const result = await renderToString(vnode, { ctx, storeDefs: options.storeDefs });
|
|
99
116
|
|
|
100
|
-
return
|
|
117
|
+
return { ...result, meta };
|
|
101
118
|
};
|
|
102
119
|
|
|
103
120
|
const manifestFor = async (pathname: string, ctx: Ctx): Promise<unknown> => {
|
|
@@ -118,7 +135,7 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
118
135
|
|
|
119
136
|
if (!tool) throw Object.assign(new Error(`Unknown api tool "${name}"`), { code: 'invalid_input' });
|
|
120
137
|
|
|
121
|
-
return invokeApi(tool, input, ctx, 'agent');
|
|
138
|
+
return invokeApi(tool, input, ctx, 'agent', options.onAudit);
|
|
122
139
|
};
|
|
123
140
|
|
|
124
141
|
const handleApi = async (req: Request, name: string): Promise<Response> => {
|
|
@@ -127,7 +144,11 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
127
144
|
if (!tool) return json({ ok: false, error: `Unknown api "${name}"` }, 404);
|
|
128
145
|
const origin = req.headers.get('x-janux-origin') === 'agent' ? 'agent' : 'human';
|
|
129
146
|
const input = await req.json().catch(() => ({}));
|
|
130
|
-
const ctx = await
|
|
147
|
+
const ctx = await ctxWithAgent(req);
|
|
148
|
+
|
|
149
|
+
if (origin === 'agent' && agentAuth?.policy === 'require' && !(ctx.agent as AgentIdentity | undefined)?.verified) {
|
|
150
|
+
return json({ ok: false, error: 'agent_required' }, 401);
|
|
151
|
+
}
|
|
131
152
|
|
|
132
153
|
try {
|
|
133
154
|
if (origin === 'agent' && resolveApiGuard(tool, ctx) === 'confirm') {
|
|
@@ -135,12 +156,13 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
135
156
|
const id = `prop_api_${(proposalSeq += 1)}`;
|
|
136
157
|
|
|
137
158
|
evictOldestProposal(proposals);
|
|
138
|
-
proposals.set(id, { id, tool: tool.name, input: parsed, execute: () => invokeApi(tool, parsed, ctx, 'human') });
|
|
159
|
+
proposals.set(id, { id, tool: tool.name, input: parsed, execute: () => invokeApi(tool, parsed, ctx, 'human', options.onAudit) });
|
|
160
|
+
options.onAudit?.(apiAuditEntry(tool, origin, 'confirm', ctx, { input: parsed, ok: true }));
|
|
139
161
|
|
|
140
162
|
return json({ ok: true, result: { status: 'proposal', id, tool: tool.name, input: parsed } });
|
|
141
163
|
}
|
|
142
164
|
|
|
143
|
-
return json({ ok: true, result: await invokeApi(tool, input, ctx, origin) });
|
|
165
|
+
return json({ ok: true, result: await invokeApi(tool, input, ctx, origin, options.onAudit) });
|
|
144
166
|
} catch (error) {
|
|
145
167
|
return json({ ok: false, error: String(error) }, errorStatus(error));
|
|
146
168
|
}
|
|
@@ -164,13 +186,15 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
164
186
|
const islandNames = [...new Set(result.registry.islands.map(({ def }) => def.name))];
|
|
165
187
|
const html = htmlDocument({
|
|
166
188
|
html: result.html,
|
|
167
|
-
title: options.title,
|
|
189
|
+
title: result.meta?.title ?? options.title,
|
|
190
|
+
description: result.meta?.description,
|
|
168
191
|
snapshots: result.snapshots,
|
|
169
192
|
islandNames,
|
|
170
193
|
islandModules: options.islandModules,
|
|
171
194
|
runtimeUrl: islandNames.length > 0 ? options.runtimeUrl : undefined,
|
|
172
195
|
manifestUrl: `/_janux/manifest?path=${encodeURIComponent(pathname)}`,
|
|
173
196
|
stylesheets: options.stylesheets,
|
|
197
|
+
favicon: options.favicon,
|
|
174
198
|
});
|
|
175
199
|
|
|
176
200
|
return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
@@ -187,11 +211,16 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
187
211
|
|
|
188
212
|
return json({ ok: id ? proposals.delete(id) : false });
|
|
189
213
|
}
|
|
214
|
+
if (pathname === '/llms.txt' && options.llmsTxt) {
|
|
215
|
+
llmsTxtBody ??= await renderLlmsTxt();
|
|
216
|
+
|
|
217
|
+
return new Response(llmsTxtBody, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
218
|
+
}
|
|
190
219
|
if (pathname === '/_janux/manifest') {
|
|
191
220
|
return json(await manifestFor(url.searchParams.get('path') ?? '/', await resolveCtx(req)));
|
|
192
221
|
}
|
|
193
222
|
if (pathname === '/_janux/agent' && options.agent) {
|
|
194
|
-
const ctx = await
|
|
223
|
+
const ctx = await ctxWithAgent(req);
|
|
195
224
|
|
|
196
225
|
return options.agent.handle(req, {
|
|
197
226
|
tools: apiTools,
|
|
@@ -203,5 +232,5 @@ export function createJanuxServer(options: ServerOptions = {}) {
|
|
|
203
232
|
return handlePage(req, pathname);
|
|
204
233
|
};
|
|
205
234
|
|
|
206
|
-
return { fetch, apiTools, manifestFor };
|
|
235
|
+
return { fetch, apiTools, manifestFor, listPages };
|
|
207
236
|
}
|