@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@janux/server",
3
- "version": "0.1.0",
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.0"
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,10 @@
1
+ import { jsx } from 'janux';
2
+
3
+ export const meta = {
4
+ title: 'About — Janux fixture',
5
+ description: 'Route-level metadata fixture.',
6
+ };
7
+
8
+ export default function About() {
9
+ return jsx('main', { children: 'About page' });
10
+ }
@@ -0,0 +1,9 @@
1
+ import { jsx } from 'janux';
2
+
3
+ export const meta = {
4
+ title: '</title><script>alert(1)</script>',
5
+ };
6
+
7
+ export default function Evil() {
8
+ return jsx('main', { children: 'safe' });
9
+ }
@@ -0,0 +1,9 @@
1
+ import { jsx } from 'janux';
2
+
3
+ export function staticParams() {
4
+ return [{ id: '1' }, { id: '2' }];
5
+ }
6
+
7
+ export default function Order({ params }: { params: { id: string } }) {
8
+ return jsx('main', { children: `Order ${params.id}` });
9
+ }
@@ -0,0 +1,5 @@
1
+ import { jsx } from 'janux';
2
+
3
+ export default function Tag({ params }: { params: { tag: string } }) {
4
+ return jsx('main', { children: `Tag ${params.tag}` });
5
+ }
@@ -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
- /** Single invocation pipeline for api() tools: guard → validate → run → validate output. */
74
- export async function invokeApi(tool: ApiTool, input: unknown, ctx: Ctx, origin: Origin): Promise<unknown> {
75
- const guard = resolveApiGuard(tool, ctx);
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 (origin === 'agent' && guard === 'forbidden') {
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
- if (tool.output) {
84
- const check = validate(tool.output, result);
79
+ return check.value;
80
+ }
85
81
 
86
- if (!check.ok) throw new Error(`Janux: api "${tool.name}" returned an invalid output`);
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
- return check.value;
89
- }
86
+ return agent?.verified ? agent.keyId : undefined;
87
+ }
90
88
 
91
- return result;
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
- `<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${options.title ?? 'Janux app'}</title>${manifestLink}${styleLinks}</head>`,
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
+ });
@@ -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
+ }
@@ -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('&lt;/title>&lt;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, validate, type ComponentDef, type Ctx } from 'janux';
2
- import { apiManifestTools, collectApis, invokeApi, resolveApiGuard, type ApiTool } from './api';
3
- import { createFsRouter } from './router';
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
- interface PendingApiProposal {
31
- id: string;
32
- tool: string;
33
- input: unknown;
34
- execute: () => Promise<unknown>;
35
- }
36
-
37
- function json(body: unknown, status = 200): Response {
38
- return new Response(JSON.stringify(body), {
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 errorStatus(error: unknown): number {
68
- const code = (error as any)?.code;
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
- return code === 'forbidden' ? 403 : code === 'invalid_input' ? 400 : 500;
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 render = 'render' in route ? route.render : ((await loadRoute(route.load)) as any).default;
98
- const vnode = render({ ctx, params: route.params });
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 renderToString(vnode, { ctx, storeDefs: options.storeDefs });
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 resolveCtx(req);
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 resolveCtx(req);
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
  }