@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@janux/server",
3
- "version": "0.1.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,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,7 +1,10 @@
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>;
@@ -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
- 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
- });
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 errorStatus(error: unknown): number {
68
- const code = (error as any)?.code;
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
- return code === 'forbidden' ? 403 : code === 'invalid_input' ? 400 : 500;
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 render = 'render' in route ? route.render : ((await loadRoute(route.load)) as any).default;
98
- const vnode = render({ ctx, params: route.params });
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 renderToString(vnode, { ctx, storeDefs: options.storeDefs });
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 resolveCtx(req);
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 resolveCtx(req);
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
  }