@lincyaw/server 0.0.0-stage → 1.0.0-rc.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/LICENSE +202 -0
- package/README.md +17 -2
- package/package.json +38 -3
- package/src/auth.ts +35 -0
- package/src/control.ts +194 -0
- package/src/diagnostics.ts +139 -0
- package/src/focus.ts +47 -0
- package/src/http.ts +188 -0
- package/src/index.ts +8 -0
- package/src/mcp-access.ts +76 -0
- package/src/mcp-host.ts +96 -0
- package/src/mounts.ts +118 -0
- package/src/server-host.ts +75 -0
- package/src/server-routes.ts +203 -0
- package/src/server.ts +307 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { IncomingMessage } from 'node:http'
|
|
3
|
+
import { errorMessage, hasRole, isRecord, jsonResponse, MAX_BODY, problemResponse, sseResponse } from '@lincyaw/kernel'
|
|
4
|
+
import type { AuthoringDiagnostics, CanonicalSource, Finding, Member, PreviewDiagnostics } from '@lincyaw/kernel'
|
|
5
|
+
import type { McpHost } from '@lincyaw/mcp'
|
|
6
|
+
import type { ControlChannel, WindowPosition } from './control.ts'
|
|
7
|
+
import type { DiagnosticStore } from './diagnostics.ts'
|
|
8
|
+
import { toFocusRequest } from './focus.ts'
|
|
9
|
+
import type { FocusStore } from './focus.ts'
|
|
10
|
+
import { hasBearerToken, limitBody } from './http.ts'
|
|
11
|
+
import type { McpTokens } from './mcp-access.ts'
|
|
12
|
+
import type { ServerHost } from './server-host.ts'
|
|
13
|
+
|
|
14
|
+
export const SERVER_PATH = '/__presmium/presmium.server'
|
|
15
|
+
|
|
16
|
+
export interface ServerRoutesOptions {
|
|
17
|
+
control: ControlChannel
|
|
18
|
+
mcpTokens: McpTokens
|
|
19
|
+
focus: FocusStore
|
|
20
|
+
diagnostics: DiagnosticStore
|
|
21
|
+
mcpHost: McpHost
|
|
22
|
+
serverHost?: ServerHost
|
|
23
|
+
launcherToken?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ServerRouteContext {
|
|
27
|
+
member: Member
|
|
28
|
+
document: string
|
|
29
|
+
req: IncomingMessage
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The server's own routes under SERVER_PATH: hello, control, tokens, focus and diagnostics. */
|
|
33
|
+
export function createServerRouteHandler(options: ServerRoutesOptions): (request: Request, route: string, ctx: ServerRouteContext) => Promise<Response> {
|
|
34
|
+
const { control, mcpTokens, focus, diagnostics, launcherToken } = options
|
|
35
|
+
const isFromLauncher = (request: Request) => launcherToken !== undefined && hasBearerToken(request, launcherToken)
|
|
36
|
+
const hasDocument = async (serverHost: ServerHost, document: string) => (await serverHost.documents()).some(doc => doc.dir === document)
|
|
37
|
+
|
|
38
|
+
const controlRoute = async (request: Request, { member, document, req }: ServerRouteContext): Promise<Response> => {
|
|
39
|
+
if (request.method === 'GET') {
|
|
40
|
+
const query = new URL(request.url).searchParams
|
|
41
|
+
const window = query.get('window') || randomUUID()
|
|
42
|
+
const stream = control.open(window, member, document, { observer: query.get('observe') === 'approvals' })
|
|
43
|
+
req.once('close', () => stream.end())
|
|
44
|
+
return sseResponse(stream, request.signal)
|
|
45
|
+
}
|
|
46
|
+
if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
|
|
47
|
+
const limited = await limitBody(request, MAX_BODY)
|
|
48
|
+
if (limited instanceof Response) return limited
|
|
49
|
+
const body: unknown = await limited.json().catch(() => undefined)
|
|
50
|
+
if (!isRecord(body)) return problemResponse(400, 'a JSON object')
|
|
51
|
+
if (body.type === 'answer' && typeof body.id === 'string') {
|
|
52
|
+
const status = control.answer(body.id, body.allow === true, member)
|
|
53
|
+
return status === 200 ? jsonResponse({ ok: true }) : problemResponse(status, status === 404 ? 'no such approval' : 'this member may not answer this approval')
|
|
54
|
+
}
|
|
55
|
+
if (body.type === 'report' && typeof body.window === 'string') {
|
|
56
|
+
const position = toWindowPosition(body.position)
|
|
57
|
+
const ok = control.report(body.window, member, { position, role: typeof body.role === 'string' ? body.role : undefined, status: isRecord(body.status) ? body.status : undefined })
|
|
58
|
+
return ok ? jsonResponse({ ok: true }) : problemResponse(404, 'unknown window')
|
|
59
|
+
}
|
|
60
|
+
return problemResponse(400, 'unknown message')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const tokensRoute = async (request: Request): Promise<Response> => {
|
|
64
|
+
if (!launcherToken) return problemResponse(403, 'session tokens are issued only to the host that started this service (PRESMIUM_SERVICE_TOKEN)')
|
|
65
|
+
if (!isFromLauncher(request)) return problemResponse(401, 'session tokens need Authorization: Bearer <the service token>')
|
|
66
|
+
const limited = await limitBody(request, MAX_BODY)
|
|
67
|
+
if (limited instanceof Response) return limited
|
|
68
|
+
const body: unknown = await limited.json().catch(() => ({}))
|
|
69
|
+
const session = isRecord(body) && typeof body.session === 'string' && body.session !== '' ? body.session : undefined
|
|
70
|
+
if (request.method === 'DELETE') return jsonResponse({ revoked: mcpTokens.revokeAgentSessions(session) })
|
|
71
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST or DELETE')
|
|
72
|
+
if (!isRecord(body) || typeof body.document !== 'string' || session === undefined) return problemResponse(400, '{ document, session }')
|
|
73
|
+
const document = body.document
|
|
74
|
+
if (options.serverHost && !(await hasDocument(options.serverHost, document))) return problemResponse(404, `no document ${JSON.stringify(document)}`)
|
|
75
|
+
const grant = mcpTokens.grantAgentSession(session, document)
|
|
76
|
+
return jsonResponse({ url: grant.url, token: grant.token, document }, 201)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const focusRoute = async (request: Request, member: Member): Promise<Response> => {
|
|
80
|
+
if (request.method === 'GET') {
|
|
81
|
+
const params = new URL(request.url).searchParams
|
|
82
|
+
const found = focus.find(params.has('document') ? params.get('document')! : undefined)
|
|
83
|
+
return jsonResponse(found ? { document: found.document, focus: found.focus, reported: new Date(found.at).toISOString() } : { focus: null })
|
|
84
|
+
}
|
|
85
|
+
if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
|
|
86
|
+
if (launcherToken && !isFromLauncher(request)) return problemResponse(401, 'a focus report needs Authorization: Bearer <the service token>')
|
|
87
|
+
const { serverHost } = options
|
|
88
|
+
if (!serverHost) return problemResponse(409, 'this server compiles no document')
|
|
89
|
+
const limited = await limitBody(request, MAX_BODY)
|
|
90
|
+
if (limited instanceof Response) return limited
|
|
91
|
+
const body = toFocusRequest(await limited.json().catch(() => undefined))
|
|
92
|
+
if (typeof body === 'string') return problemResponse(400, body)
|
|
93
|
+
const workspaceFocus = body.focus.filter(report => report.scope === 'workspace')
|
|
94
|
+
if (workspaceFocus.length && !hasRole(member, 'owner')) return problemResponse(403, 'workspace source selections need the owner role')
|
|
95
|
+
if (workspaceFocus.length && !serverHost.readSource) return problemResponse(400, 'this host does not read workspace source selections')
|
|
96
|
+
if (!(await hasDocument(serverHost, body.document))) return problemResponse(404, `no document ${JSON.stringify(body.document)}`)
|
|
97
|
+
try {
|
|
98
|
+
const doc = body.focus.some(report => report.scope !== 'workspace') ? (await serverHost.compile(body.document)).doc : undefined
|
|
99
|
+
const sources = new Map(await Promise.all(workspaceFocus.map(async (report) => {
|
|
100
|
+
const path = report.source!.file
|
|
101
|
+
return [path, (await serverHost.readSource!(path, member)).content] as const
|
|
102
|
+
})))
|
|
103
|
+
const record = focus.report(body.document, doc, body.focus, path => sources.get(path)!)
|
|
104
|
+
return jsonResponse({ document: record.document, focus: record.focus, reported: new Date(record.at).toISOString() })
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return problemResponse(400, errorMessage(error))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const diagnosticsRoute = async (request: Request, stream: boolean, req: IncomingMessage): Promise<Response> => {
|
|
111
|
+
if (launcherToken && !isFromLauncher(request)) return problemResponse(401, 'diagnostics need Authorization: Bearer <the service token>')
|
|
112
|
+
const { serverHost } = options
|
|
113
|
+
if (!serverHost) return problemResponse(409, 'this server compiles no document')
|
|
114
|
+
const params = new URL(request.url).searchParams
|
|
115
|
+
if (request.method === 'GET') {
|
|
116
|
+
const document = params.has('document') ? params.get('document')! : undefined
|
|
117
|
+
if (document !== undefined) {
|
|
118
|
+
if (!(await hasDocument(serverHost, document))) return problemResponse(404, `no document ${JSON.stringify(document)}`)
|
|
119
|
+
await options.mcpHost.diagnostics!(document)
|
|
120
|
+
}
|
|
121
|
+
if (!stream) return jsonResponse(diagnostics.snapshot(document))
|
|
122
|
+
const connection = new AbortController()
|
|
123
|
+
req.once('close', () => connection.abort())
|
|
124
|
+
return sseResponse(diagnostics.events(document, AbortSignal.any([connection.signal, request.signal])), request.signal)
|
|
125
|
+
}
|
|
126
|
+
if (request.method !== 'POST' || stream) return problemResponse(405, stream ? 'GET' : 'GET or POST')
|
|
127
|
+
const limited = await limitBody(request, MAX_BODY)
|
|
128
|
+
if (limited instanceof Response) return limited
|
|
129
|
+
const body: unknown = await limited.json().catch(() => undefined)
|
|
130
|
+
const report = toPreviewDiagnostics(body)
|
|
131
|
+
if (typeof report === 'string') return problemResponse(400, report)
|
|
132
|
+
if (!(await hasDocument(serverHost, report.document))) return problemResponse(404, `no document ${JSON.stringify(report.document)}`)
|
|
133
|
+
return jsonResponse({ accepted: diagnostics.report(report) })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return async (request, route, ctx) => {
|
|
137
|
+
if (route === 'hello') return jsonResponse({ member: ctx.member, document: ctx.document })
|
|
138
|
+
if (route === 'control') return await controlRoute(request, ctx)
|
|
139
|
+
if (route === 'tokens') return await tokensRoute(request)
|
|
140
|
+
if (route === 'focus') return await focusRoute(request, ctx.member)
|
|
141
|
+
if (route === 'diagnostics/authoring') {
|
|
142
|
+
if (launcherToken && !isFromLauncher(request)) return problemResponse(401, 'authoring diagnostics need the service token')
|
|
143
|
+
if (!hasRole(ctx.member, 'editor')) return problemResponse(403, 'authoring diagnostics need the role editor')
|
|
144
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
145
|
+
if (!options.serverHost?.authoring) return problemResponse(409, 'authoring diagnostics are unavailable')
|
|
146
|
+
const limited = await limitBody(request, 64 << 10)
|
|
147
|
+
if (limited instanceof Response) return limited
|
|
148
|
+
const report = toAuthoringDiagnostics(await limited.json().catch(() => undefined))
|
|
149
|
+
if (typeof report === 'string') return problemResponse(400, report)
|
|
150
|
+
if (!(await hasDocument(options.serverHost, report.document))) return problemResponse(404, 'no such authoring document')
|
|
151
|
+
diagnostics.reportAuthoring(report, ctx.member.id)
|
|
152
|
+
return jsonResponse({ accepted: true })
|
|
153
|
+
}
|
|
154
|
+
if (route === 'diagnostics' || route === 'diagnostics/events') return await diagnosticsRoute(request, route === 'diagnostics/events', ctx.req)
|
|
155
|
+
return problemResponse(404, 'not found')
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toAuthoringDiagnostics(body: unknown): AuthoringDiagnostics | string {
|
|
160
|
+
if (!isRecord(body) || Object.keys(body).some(key => !['document', 'instance', 'findings'].includes(key))
|
|
161
|
+
|| typeof body.document !== 'string' || body.document.length > 1024 || typeof body.instance !== 'string' || !/^workbench:[a-zA-Z0-9.-]{1,80}$/.test(body.instance)
|
|
162
|
+
|| !Array.isArray(body.findings) || body.findings.length > 20) return 'authoring diagnostics need document, workbench instance and at most 20 findings'
|
|
163
|
+
const findings: AuthoringDiagnostics['findings'] = []
|
|
164
|
+
for (const item of body.findings) {
|
|
165
|
+
if (!isRecord(item) || Object.keys(item).some(key => !['checkId', 'severity', 'message', 'authoringSource'].includes(key))
|
|
166
|
+
|| typeof item.checkId !== 'string' || !item.checkId.startsWith('presmium.workbench.') || item.checkId.length > 100
|
|
167
|
+
|| item.severity !== 'error' || typeof item.message !== 'string' || item.message.length > 600) return 'invalid authoring diagnostic'
|
|
168
|
+
let source: CanonicalSource | undefined
|
|
169
|
+
if (item.authoringSource !== undefined) {
|
|
170
|
+
const value = item.authoringSource
|
|
171
|
+
if (!isRecord(value) || Object.keys(value).some(key => !['documentId', 'version', 'fingerprint'].includes(key))
|
|
172
|
+
|| !['documentId', 'version'].every(key => typeof value[key] === 'string' && (value[key] as string).length > 0 && (value[key] as string).length <= 200)
|
|
173
|
+
|| typeof value.fingerprint !== 'string' || !/^[a-f\d]{16}$/.test(value.fingerprint)) return 'invalid authoring diagnostic source'
|
|
174
|
+
source = { documentId: value.documentId as string, version: value.version as string, fingerprint: value.fingerprint }
|
|
175
|
+
}
|
|
176
|
+
findings.push({ checkId: item.checkId, severity: 'error', message: item.message, ...source ? { authoringSource: source } : {} })
|
|
177
|
+
}
|
|
178
|
+
return { document: body.document, instance: body.instance, findings }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function toPreviewDiagnostics(body: unknown): PreviewDiagnostics | string {
|
|
182
|
+
if (!isRecord(body) || typeof body.document !== 'string' || (body.renderer !== 'web' && body.renderer !== 'slides') || typeof body.instance !== 'string' || !body.instance || typeof body.revision !== 'string' || !Array.isArray(body.findings)) return 'a preview diagnostic report needs document, renderer, instance, revision and findings'
|
|
183
|
+
const findings: Finding[] = []
|
|
184
|
+
for (const item of body.findings) {
|
|
185
|
+
if (!isRecord(item) || typeof item.checkId !== 'string' || typeof item.message !== 'string' || !['error', 'warning', 'info'].includes(String(item.severity))) return 'each diagnostic needs checkId, severity and message'
|
|
186
|
+
if (item.hint !== undefined && typeof item.hint !== 'string') return 'a diagnostic hint must be text'
|
|
187
|
+
if (item.source !== undefined && (!isRecord(item.source) || typeof item.source.file !== 'string' || (item.source.line !== undefined && (!Number.isInteger(item.source.line) || Number(item.source.line) < 1)))) return 'a diagnostic source needs a file and an optional positive line'
|
|
188
|
+
findings.push({ checkId: item.checkId, severity: item.severity as Finding['severity'], message: item.message, ...item.hint ? { hint: item.hint as string } : {}, ...item.source ? { source: item.source as Finding['source'] } : {} })
|
|
189
|
+
}
|
|
190
|
+
return { document: body.document, renderer: body.renderer, instance: body.instance, revision: body.revision, findings }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toWindowPosition(value: unknown): WindowPosition | undefined {
|
|
194
|
+
if (!isRecord(value) || typeof value.slide !== 'number' || typeof value.step !== 'number') return undefined
|
|
195
|
+
const { trail } = value
|
|
196
|
+
return {
|
|
197
|
+
slide: value.slide,
|
|
198
|
+
step: value.step,
|
|
199
|
+
...typeof value.view === 'string' ? { view: value.view } : {},
|
|
200
|
+
...typeof value.section === 'string' ? { section: value.section } : {},
|
|
201
|
+
...Array.isArray(trail) && trail.length <= 10_000 && trail.every(key => typeof key === 'string') ? { trail: trail as string[] } : {},
|
|
202
|
+
}
|
|
203
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import type { IncomingMessage } from 'node:http'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
import { APPROVALS, errorMessage, hasRole, MAX_BODY, MCP_ENDPOINT, problemResponse } from '@lincyaw/kernel'
|
|
5
|
+
import type { ApprovalKind, Logger, Member, ProxySpec, Route, ServiceToken } from '@lincyaw/kernel'
|
|
6
|
+
import { pluginLogger } from '@lincyaw/compiler'
|
|
7
|
+
import { createPresmiumMcp } from '@lincyaw/mcp'
|
|
8
|
+
import type { McpCaller } from '@lincyaw/mcp'
|
|
9
|
+
import { authenticate, machineHosts } from './auth.ts'
|
|
10
|
+
import type { MachineHosts } from './auth.ts'
|
|
11
|
+
import type { Refusal } from './auth.ts'
|
|
12
|
+
import { ControlChannel } from './control.ts'
|
|
13
|
+
import type { ApprovalPolicy } from './control.ts'
|
|
14
|
+
import { FocusStore } from './focus.ts'
|
|
15
|
+
import { DiagnosticStore } from './diagnostics.ts'
|
|
16
|
+
import { bearer, createProxy, header, isLoopback, limitBody, listen, nodeServer, normalizeHostName, urlHost } from './http.ts'
|
|
17
|
+
import type { Decision, ProxyTarget } from './http.ts'
|
|
18
|
+
import { McpTokens } from './mcp-access.ts'
|
|
19
|
+
import { serverMcpHost } from './mcp-host.ts'
|
|
20
|
+
import { Mounts } from './mounts.ts'
|
|
21
|
+
import { createServerRouteHandler, SERVER_PATH } from './server-routes.ts'
|
|
22
|
+
import type { ServedDocument, ServerHost } from './server-host.ts'
|
|
23
|
+
|
|
24
|
+
function isMcp(path: string): boolean {
|
|
25
|
+
return path === '/mcp' || path.startsWith('/mcp/')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ServerOptions {
|
|
29
|
+
mode: 'dev' | 'serve'
|
|
30
|
+
listenAddress?: string
|
|
31
|
+
port?: number
|
|
32
|
+
document(dir: string): Promise<ServedDocument | undefined>
|
|
33
|
+
serverHost?: ServerHost
|
|
34
|
+
settings?: Record<string, Record<string, unknown>>
|
|
35
|
+
approvals?: Partial<Record<ApprovalKind, ApprovalPolicy>>
|
|
36
|
+
/** The origin to forward a path of the site to (in `serve`): the dev server that serves it. */
|
|
37
|
+
forward?(path: string): string | undefined | Promise<string | undefined>
|
|
38
|
+
operatorDir?: string
|
|
39
|
+
/**
|
|
40
|
+
* The address browsers use when a reverse proxy fronts the server: its host is trusted as this machine's, and pages
|
|
41
|
+
* are advertised under it. A path (`https://host/user/a/preview/`) is the base below which the proxy forwards the
|
|
42
|
+
* unchanged path; the server's own routes are found below it, and still at the root for calls on its own address.
|
|
43
|
+
*/
|
|
44
|
+
publicUrl?: string
|
|
45
|
+
stateFile?: string
|
|
46
|
+
mcp?(request: Request, access: McpCaller): Promise<Response>
|
|
47
|
+
env?: NodeJS.ProcessEnv
|
|
48
|
+
approvalTimeoutMs?: number
|
|
49
|
+
log?: Logger
|
|
50
|
+
diagnostics?: DiagnosticStore
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PresmiumServer {
|
|
54
|
+
url: string
|
|
55
|
+
/** The address browsers use: `publicUrl` when given, else `url`. */
|
|
56
|
+
publicUrl: string
|
|
57
|
+
port: number
|
|
58
|
+
mcpToken: string
|
|
59
|
+
services: [ServiceToken<unknown>, unknown][]
|
|
60
|
+
control: ControlChannel
|
|
61
|
+
diagnostics: DiagnosticStore
|
|
62
|
+
serverHost?: ServerHost
|
|
63
|
+
unmount(dir: string, options?: { preserveFocus?: boolean }): Promise<void>
|
|
64
|
+
close(): Promise<void>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parsePluginPath(pathname: string): { plugin: string, path: string, raw: string, plain: boolean } | undefined {
|
|
68
|
+
const match = /^\/__presmium\/([^/]+)\/?(.*)$/.exec(pathname)
|
|
69
|
+
if (!match) return undefined
|
|
70
|
+
const raw = match[2]!
|
|
71
|
+
let path = raw
|
|
72
|
+
let plain = !/%(?:2f|5c)/i.test(raw) && !raw.includes('\\')
|
|
73
|
+
try {
|
|
74
|
+
path = decodeURIComponent(raw)
|
|
75
|
+
} catch {
|
|
76
|
+
plain = false
|
|
77
|
+
}
|
|
78
|
+
if (path.split('/').some(part => part === '.' || part === '..')) plain = false
|
|
79
|
+
return { plugin: match[1]!, path, raw, plain }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function findRoute(routes: Record<string, Route> | undefined, path: string): { route: Route, rest: string, key: string } | undefined {
|
|
83
|
+
if (!routes) return undefined
|
|
84
|
+
if (routes[path] && !path.endsWith('/*')) return { route: routes[path]!, rest: '', key: path }
|
|
85
|
+
let best: { route: Route, rest: string, key: string } | undefined
|
|
86
|
+
for (const [key, route] of Object.entries(routes)) {
|
|
87
|
+
if (!key.endsWith('/*')) continue
|
|
88
|
+
const prefix = key.slice(0, -1)
|
|
89
|
+
const base = key.slice(0, -2)
|
|
90
|
+
if ((path.startsWith(prefix) || path === base) && (!best || key.length > best.key.length)) best = { route, rest: path.slice(prefix.length), key }
|
|
91
|
+
}
|
|
92
|
+
return best
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function findProxy(proxies: ProxySpec[] | undefined, path: string): { spec: ProxySpec, rest: string } | undefined {
|
|
96
|
+
for (const spec of proxies ?? []) {
|
|
97
|
+
const base = spec.path.replace(/^\/+|\/+$/g, '')
|
|
98
|
+
if (path === base || path.startsWith(`${base}/`)) return { spec, rest: path.slice(base.length) }
|
|
99
|
+
}
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function requestedDocument(headers: IncomingMessage['headers'], url: URL): string {
|
|
104
|
+
const value = header(headers, 'presmium-document') ?? url.searchParams.get('document') ?? ''
|
|
105
|
+
try {
|
|
106
|
+
return decodeURIComponent(value).replace(/^\/+|\/+$/g, '')
|
|
107
|
+
} catch {
|
|
108
|
+
return value
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function remoteListenRefusal(mode: ServerOptions['mode'], listenAddress: string, port: number | undefined): string | undefined {
|
|
113
|
+
if (mode === 'serve' || isLoopback(listenAddress)) return undefined
|
|
114
|
+
const at = port || 'its port'
|
|
115
|
+
return `presmium ${mode} listens on this machine only, not on ${listenAddress}: it serves the one user who runs it. To use it from another machine, forward its port over SSH (ssh -L ${at}:127.0.0.1:${at} <this machine>), or use presmium serve --host`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function startServer(options: ServerOptions): Promise<PresmiumServer> {
|
|
119
|
+
const env = options.env ?? process.env
|
|
120
|
+
const log = options.log ?? pluginLogger('presmium server')
|
|
121
|
+
const listenAddress = options.listenAddress ?? '127.0.0.1'
|
|
122
|
+
const refused = remoteListenRefusal(options.mode, listenAddress, options.port)
|
|
123
|
+
if (refused) throw new Error(refused)
|
|
124
|
+
const basePath = options.publicUrl ? `${new URL(options.publicUrl).pathname.replace(/\/+$/, '')}/` : '/'
|
|
125
|
+
const inner = (path: string) => basePath !== '/' && path.startsWith(basePath) ? path.slice(basePath.length - 1) : path
|
|
126
|
+
const machine = machineHosts(listenAddress)
|
|
127
|
+
const publicHost = options.publicUrl ? normalizeHostName(new URL(options.publicUrl).host) : undefined
|
|
128
|
+
const local: MachineHosts = publicHost ? host => host === publicHost || machine(host) : machine
|
|
129
|
+
|
|
130
|
+
const control = new ControlChannel({
|
|
131
|
+
policy: options.approvals ?? {},
|
|
132
|
+
timeoutMs: options.approvalTimeoutMs,
|
|
133
|
+
})
|
|
134
|
+
let url = ''
|
|
135
|
+
const mcpTokens = new McpTokens(() => url)
|
|
136
|
+
const services: [ServiceToken<unknown>, unknown][] = [[APPROVALS, control.approvals], [MCP_ENDPOINT, mcpTokens.endpoint]]
|
|
137
|
+
const ownServices = new Map(services.map(([token, implementation]) => [token.id, implementation]))
|
|
138
|
+
const ownService = <T>(token: ServiceToken<T>) => ownServices.get(token.id) as T | undefined
|
|
139
|
+
const focus = new FocusStore()
|
|
140
|
+
const diagnostics = options.diagnostics ?? new DiagnosticStore()
|
|
141
|
+
const mounts = new Mounts({ document: options.document, ownService, settings: options.settings, operatorDir: options.operatorDir, env, diagnostics, log })
|
|
142
|
+
|
|
143
|
+
const mcpHost = serverMcpHost({
|
|
144
|
+
serverHost: options.serverHost,
|
|
145
|
+
control,
|
|
146
|
+
command: options.mode,
|
|
147
|
+
mount: async dir => (await mounts.mount(dir))?.doc,
|
|
148
|
+
mounted: () => mounts.documents(),
|
|
149
|
+
service: ownService,
|
|
150
|
+
focus,
|
|
151
|
+
diagnostics,
|
|
152
|
+
})
|
|
153
|
+
const mcp = options.mcp ? undefined : createPresmiumMcp(mcpHost)
|
|
154
|
+
const serverRoute = createServerRouteHandler({ control, mcpTokens, focus, diagnostics, mcpHost, serverHost: options.serverHost, launcherToken: env.PRESMIUM_SERVICE_TOKEN || undefined })
|
|
155
|
+
|
|
156
|
+
const members = new WeakMap<IncomingMessage, Member | Refusal>()
|
|
157
|
+
const memberOf = (req: IncomingMessage): Member | Refusal => {
|
|
158
|
+
let found = members.get(req)
|
|
159
|
+
if (!found) members.set(req, found = authenticate(req, local))
|
|
160
|
+
return found
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const refuse = (refusal: Refusal): Decision => ({ kind: 'answer', response: problemResponse(refusal.status, refusal.message) })
|
|
164
|
+
|
|
165
|
+
const decide = async (req: IncomingMessage): Promise<Decision> => {
|
|
166
|
+
const url = new URL(req.url ?? '/', 'http://presmium')
|
|
167
|
+
const path = inner(url.pathname)
|
|
168
|
+
const member = memberOf(req)
|
|
169
|
+
if ('status' in member) return refuse(member)
|
|
170
|
+
const target = parsePluginPath(path)
|
|
171
|
+
if (target && !target.plain) return refuse({ status: 400, message: 'a path with a . or .. part or an encoded separator' })
|
|
172
|
+
if (isMcp(path)) return { kind: 'fetch' }
|
|
173
|
+
if (target) {
|
|
174
|
+
if (target.plugin === 'presmium.server') return { kind: 'fetch' }
|
|
175
|
+
if (findRoute(options.serverHost?.routes?.[target.plugin], target.path)) return { kind: 'fetch' }
|
|
176
|
+
const proxy = await proxyOf(target, requestedDocument(req.headers, url), member, url.search)
|
|
177
|
+
return proxy ?? { kind: 'fetch' }
|
|
178
|
+
}
|
|
179
|
+
const forward = options.mode === 'serve' ? await options.forward?.(url.pathname) : undefined
|
|
180
|
+
if (!forward) return { kind: 'answer', response: problemResponse(404, 'not found') }
|
|
181
|
+
const site = new URL(forward)
|
|
182
|
+
const lan = !isLoopback(header(req.headers, 'host'))
|
|
183
|
+
const headers = lan ? { host: site.host, ...header(req.headers, 'origin') ? { origin: site.origin } : {} } : undefined
|
|
184
|
+
return { kind: 'proxy', to: { target: forward, path: req.url ?? '/', ...headers ? { headers } : {} } }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const proxyOf = async (target: { plugin: string, path: string, raw: string }, dir: string, member: Member, search: string): Promise<Decision | undefined> => {
|
|
188
|
+
const contribution = (await mounts.mount(dir))?.contributions.get(target.plugin)
|
|
189
|
+
const found = findProxy(contribution?.proxies, target.path)
|
|
190
|
+
if (!found) return undefined
|
|
191
|
+
const role = found.spec.role ?? 'editor'
|
|
192
|
+
if (!hasRole(member, role)) return refuse({ status: 403, message: `this proxy needs the role ${role}` })
|
|
193
|
+
const base = found.spec.path.split('/').filter(Boolean).length
|
|
194
|
+
const rest = target.raw.split('/').slice(base).join('/')
|
|
195
|
+
const path = `${rest ? `/${rest}` : found.rest || '/'}${search}`
|
|
196
|
+
return { kind: 'proxy', to: { target: found.spec.target, path, headers: found.spec.headers } }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const decideUpgrade = async (req: IncomingMessage): Promise<{ to?: ProxyTarget, status?: number }> => {
|
|
200
|
+
const decision = await decide(req)
|
|
201
|
+
if (decision.kind === 'proxy') return { to: decision.to }
|
|
202
|
+
return { status: decision.kind === 'answer' ? decision.response.status : 404 }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const serveMcp = async (request: Request): Promise<Response> => {
|
|
206
|
+
const given = bearer(request.headers.get('authorization'))
|
|
207
|
+
const caller = mcpTokens.callerOf(given)
|
|
208
|
+
if (!caller) return problemResponse(401, 'MCP needs an Authorization: Bearer token')
|
|
209
|
+
const limited = await limitBody(request, MAX_BODY)
|
|
210
|
+
if (limited instanceof Response) return limited
|
|
211
|
+
const response = await (options.mcp ? options.mcp(limited, caller) : mcp!.fetch(limited, caller))
|
|
212
|
+
return mcpTokens.tieToGrant(given!, response)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const pluginRoute = async (request: Request, target: { plugin: string, path: string }, member: Member, document: string): Promise<Response> => {
|
|
216
|
+
let found = findRoute(options.serverHost?.routes?.[target.plugin], target.path)
|
|
217
|
+
if (!found) {
|
|
218
|
+
const known = await mounts.mount(document)
|
|
219
|
+
if (!known) return problemResponse(404, `no document ${JSON.stringify(document)}`)
|
|
220
|
+
found = findRoute(known.contributions.get(target.plugin)?.routes, target.path)
|
|
221
|
+
}
|
|
222
|
+
if (!found) return problemResponse(404, 'not found')
|
|
223
|
+
const role = found.route.role ?? 'editor'
|
|
224
|
+
if (!hasRole(member, role)) return problemResponse(403, `this route needs the role ${role}`)
|
|
225
|
+
const limited = await limitBody(request, found.route.maxBody ?? MAX_BODY)
|
|
226
|
+
if (limited instanceof Response) return limited
|
|
227
|
+
try {
|
|
228
|
+
const response = await found.route.handle(limited, { member, document, rest: found.rest })
|
|
229
|
+
return response.headers.has('cache-control') ? response : withHeaders(response, { 'Cache-Control': 'no-store' })
|
|
230
|
+
} catch (error) {
|
|
231
|
+
log.error(`route ${target.plugin}/${target.path} failed: ${errorMessage(error)}`)
|
|
232
|
+
return problemResponse(500, errorMessage(error))
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const handle = async (request: Request, req: IncomingMessage): Promise<Response> => {
|
|
237
|
+
const url = new URL(request.url)
|
|
238
|
+
const path = inner(url.pathname)
|
|
239
|
+
const member = memberOf(req)
|
|
240
|
+
if ('status' in member) return problemResponse(member.status, member.message)
|
|
241
|
+
const document = requestedDocument(req.headers, url)
|
|
242
|
+
if (isMcp(path)) return serveMcp(request)
|
|
243
|
+
if (path.startsWith(`${SERVER_PATH}/`)) return await serverRoute(request, path.slice(SERVER_PATH.length + 1), { member, document, req })
|
|
244
|
+
const target = parsePluginPath(path)
|
|
245
|
+
if (!target) return problemResponse(404, 'not found')
|
|
246
|
+
return await pluginRoute(request, target, member, document)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const proxy = createProxy()
|
|
250
|
+
const http = nodeServer({ decide, decideUpgrade, fetch: handle, proxy })
|
|
251
|
+
let closing: Promise<void> | undefined
|
|
252
|
+
const close = (): Promise<void> => closing ??= Promise.resolve().then(async () => {
|
|
253
|
+
mounts.stop()
|
|
254
|
+
mcpTokens.revokeAgentSessions()
|
|
255
|
+
control.close()
|
|
256
|
+
await mcp?.close().catch((error: unknown) => log.error(`MCP failed to close: ${errorMessage(error)}`))
|
|
257
|
+
try {
|
|
258
|
+
await mounts.close()
|
|
259
|
+
} finally {
|
|
260
|
+
try { proxy.close() }
|
|
261
|
+
finally { await http.close() }
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
let port: number
|
|
265
|
+
try {
|
|
266
|
+
port = await listen(http.server, options.port ?? (options.mode === 'dev' ? 0 : 4310), listenAddress)
|
|
267
|
+
url = `http://${urlHost(listenAddress)}:${port}`
|
|
268
|
+
log.debug(`listening at ${url}`)
|
|
269
|
+
if (options.stateFile) {
|
|
270
|
+
mkdirSync(dirname(options.stateFile), { recursive: true })
|
|
271
|
+
writeFileSync(options.stateFile, `${JSON.stringify({ url, mcp: `${url}/mcp`, token: mcpTokens.processMcpToken, pid: process.pid }, null, 2)}\n`, { mode: 0o600 })
|
|
272
|
+
chmodSync(options.stateFile, 0o600)
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
await close().catch((cleanup: unknown) => log.error(`the server failed to close after startup failed: ${errorMessage(cleanup)}`))
|
|
276
|
+
throw error
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
url,
|
|
281
|
+
publicUrl: options.publicUrl?.replace(/\/+$/, '') ?? url,
|
|
282
|
+
port,
|
|
283
|
+
mcpToken: mcpTokens.processMcpToken,
|
|
284
|
+
services,
|
|
285
|
+
control,
|
|
286
|
+
serverHost: options.serverHost,
|
|
287
|
+
diagnostics,
|
|
288
|
+
async unmount(dir, unmountOptions = {}) {
|
|
289
|
+
if (!unmountOptions.preserveFocus) focus.forget(dir)
|
|
290
|
+
await mounts.unmount(dir)
|
|
291
|
+
},
|
|
292
|
+
close,
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function withHeaders(response: Response, headers: Record<string, string>): Response {
|
|
297
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
298
|
+
try {
|
|
299
|
+
response.headers.set(name, value)
|
|
300
|
+
} catch {
|
|
301
|
+
const copy = new Response(response.body, response)
|
|
302
|
+
for (const [n, v] of Object.entries(headers)) copy.headers.set(n, v)
|
|
303
|
+
return copy
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return response
|
|
307
|
+
}
|