dsh-side-chat-plus 0.3.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 +21 -0
- package/README.md +317 -0
- package/README.zh.md +261 -0
- package/cordis.patch.yml +8 -0
- package/dsh.plugin.json +16 -0
- package/lib/client-registry.js +2949 -0
- package/lib/client-registry.js.map +1 -0
- package/lib/client.js +2949 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +840 -0
- package/lib/types/client/api.d.ts +218 -0
- package/lib/types/client/attachments/AttachmentRail.d.ts +39 -0
- package/lib/types/client/attachments/DropOverlay.d.ts +18 -0
- package/lib/types/client/attachments/ImageLightbox.d.ts +20 -0
- package/lib/types/client/attachments/MessageImage.d.ts +38 -0
- package/lib/types/client/attachments/index.d.ts +18 -0
- package/lib/types/client/index.d.ts +6 -0
- package/lib/types/client/locales.d.ts +178 -0
- package/lib/types/context-types.d.ts +390 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/settings-shared.d.ts +24 -0
- package/lib/types/trust-fence.d.ts +20 -0
- package/lib/types/wire.d.ts +25 -0
- package/package.json +114 -0
- package/src/client/api.ts +112 -0
- package/src/client/attachments/AttachmentRail.module.css +89 -0
- package/src/client/attachments/AttachmentRail.tsx +173 -0
- package/src/client/attachments/DropOverlay.module.css +38 -0
- package/src/client/attachments/DropOverlay.tsx +62 -0
- package/src/client/attachments/ImageLightbox.module.css +44 -0
- package/src/client/attachments/ImageLightbox.tsx +58 -0
- package/src/client/attachments/MessageImage.module.css +61 -0
- package/src/client/attachments/MessageImage.tsx +120 -0
- package/src/client/attachments/index.ts +19 -0
- package/src/client/client.module.css +1032 -0
- package/src/client/index.tsx +1966 -0
- package/src/client/layout.css +16 -0
- package/src/client/locales.ts +181 -0
- package/src/context-types.ts +384 -0
- package/src/css-modules.d.ts +10 -0
- package/src/index.ts +840 -0
- package/src/settings-shared.ts +33 -0
- package/src/trust-fence.ts +70 -0
- package/src/wire.ts +81 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared side-chat preference vocabulary (types + constants), consumed by
|
|
3
|
+
* BOTH halves: the host registers the schemastery schema over these values
|
|
4
|
+
* (index.ts) and the client reads/writes them through the plugin's own
|
|
5
|
+
* fenced /sidechat settings routes. Kept free of schemastery so the browser
|
|
6
|
+
* bundle never pulls the schema runtime in.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The user-settings namespace holding the side-chat preferences. */
|
|
10
|
+
export const SUBCHAT_PREFS_NS = 'dsh-side-chat'
|
|
11
|
+
|
|
12
|
+
/** How a brought-back reply lands in the main conversation. */
|
|
13
|
+
export type BringMode = 'draft' | 'context'
|
|
14
|
+
|
|
15
|
+
/** User-facing side-chat preferences. */
|
|
16
|
+
export interface SubchatPrefs {
|
|
17
|
+
/** Whether the "look up workspace / parent when needed" switch defaults on. */
|
|
18
|
+
lookupDefault: boolean
|
|
19
|
+
/** Whether selecting text sends it immediately (true) or stages it as an attachment (false). */
|
|
20
|
+
sendImmediately: boolean
|
|
21
|
+
/** Extra prompt appended when the selection is sent immediately (empty = none). */
|
|
22
|
+
defaultPrompt: string
|
|
23
|
+
/** How brought-back content lands: into the composer draft, or as a collapsed context row. */
|
|
24
|
+
bringMode: BringMode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
|
28
|
+
export const SUBCHAT_PREFS_DEFAULTS: SubchatPrefs = {
|
|
29
|
+
lookupDefault: false,
|
|
30
|
+
sendImmediately: true,
|
|
31
|
+
defaultPrompt: '',
|
|
32
|
+
bringMode: 'draft',
|
|
33
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-trust fence for the /sidechat routes, behaviorally identical to the
|
|
3
|
+
* /api gateway's fence (loopback or configured trusted authority; cross-site
|
|
4
|
+
* browser markers refuse). Self-contained copy, since the DSH connection
|
|
5
|
+
* package does not export these helpers.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingHttpHeaders } from 'node:http'
|
|
8
|
+
|
|
9
|
+
interface ApiTrustRequest {
|
|
10
|
+
headers: IncomingHttpHeaders
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
|
14
|
+
const value = headers[name]
|
|
15
|
+
return typeof value === 'string' ? value : undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseAuthority(authority: string): URL | undefined {
|
|
19
|
+
try {
|
|
20
|
+
return new URL(`http://${authority}`)
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether a normalized URL hostname names the local loopback authority. */
|
|
27
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
28
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
29
|
+
const parts = hostname.split('.')
|
|
30
|
+
return parts.length === 4
|
|
31
|
+
&& parts[0] === '127'
|
|
32
|
+
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function canonicalAuthority(entry: string, entryUrl: URL): string {
|
|
36
|
+
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
|
|
37
|
+
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
|
|
41
|
+
return trustedHosts.some((entry) => {
|
|
42
|
+
const entryUrl = parseAuthority(entry)
|
|
43
|
+
if (entryUrl === undefined) return false
|
|
44
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
|
45
|
+
? entryUrl.hostname === hostUrl.hostname
|
|
46
|
+
: entryUrl.host === hostUrl.host
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decide whether one sidechat request may reach the plugin routes.
|
|
52
|
+
* @param request - node HTTP request facts (headers).
|
|
53
|
+
* @param trustedHosts - non-loopback authorities this deployment serves.
|
|
54
|
+
* @returns true when the Host is ours (loopback or trusted) and browser markers are same-origin.
|
|
55
|
+
*/
|
|
56
|
+
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
|
|
57
|
+
const host = header(request.headers, 'host')
|
|
58
|
+
if (host === undefined) return false
|
|
59
|
+
const hostUrl = parseAuthority(host)
|
|
60
|
+
if (hostUrl === undefined) return false
|
|
61
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
|
|
62
|
+
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
|
|
63
|
+
const origin = header(request.headers, 'origin')
|
|
64
|
+
if (origin === undefined) return true
|
|
65
|
+
try {
|
|
66
|
+
return new URL(origin).host === hostUrl.host
|
|
67
|
+
} catch {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire helpers for the /sidechat JSON API: bounded body reading, response
|
|
3
|
+
* writing, and the shared error envelope. Mirrors the /sidebar wire helpers
|
|
4
|
+
* (loopback + trusted-host fence), kept self-contained so the plugin never
|
|
5
|
+
* depends on the DSH gateway internals.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
8
|
+
|
|
9
|
+
/** One API failure with its wire code and HTTP status. */
|
|
10
|
+
export class SidechatError extends Error {
|
|
11
|
+
constructor(
|
|
12
|
+
readonly code: string,
|
|
13
|
+
message: string,
|
|
14
|
+
readonly status = 400,
|
|
15
|
+
) {
|
|
16
|
+
super(message)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Body size bound of one JSON request. */
|
|
21
|
+
const MAX_BODY_BYTES = 1 << 20
|
|
22
|
+
|
|
23
|
+
/** Read and parse the JSON request body (bounded; malformed → bad-request). */
|
|
24
|
+
export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
25
|
+
const chunks: Buffer[] = []
|
|
26
|
+
let total = 0
|
|
27
|
+
for await (const chunk of req) {
|
|
28
|
+
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
|
29
|
+
total += buffer.length
|
|
30
|
+
if (total > MAX_BODY_BYTES) {
|
|
31
|
+
throw new SidechatError('bad-request', 'request body too large')
|
|
32
|
+
}
|
|
33
|
+
chunks.push(buffer)
|
|
34
|
+
}
|
|
35
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
36
|
+
if (text.trim() === '') return {}
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(text) as unknown
|
|
39
|
+
} catch {
|
|
40
|
+
throw new SidechatError('bad-request', 'request body is not valid JSON')
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Write a JSON response with the given status. */
|
|
45
|
+
export function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
46
|
+
const payload = JSON.stringify(body)
|
|
47
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
48
|
+
res.end(payload)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Write the success envelope. */
|
|
52
|
+
export function writeOk(res: ServerResponse, value: unknown): void {
|
|
53
|
+
writeJson(res, 200, { ok: true, value })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Write the failure envelope for any thrown value (unknown → internal 500). */
|
|
57
|
+
export function writeError(res: ServerResponse, error: unknown): void {
|
|
58
|
+
if (error instanceof SidechatError) {
|
|
59
|
+
writeJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
63
|
+
writeJson(res, 500, { ok: false, error: { code: 'internal', message } })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Narrow an unknown payload value to a string, else throw bad-request. */
|
|
67
|
+
export function requireString(payload: unknown, key: string): string {
|
|
68
|
+
const record = payload as Record<string, unknown> | null
|
|
69
|
+
const value = record?.[key]
|
|
70
|
+
if (typeof value !== 'string' || value === '') {
|
|
71
|
+
throw new SidechatError('bad-request', `missing or invalid "${key}"`)
|
|
72
|
+
}
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Narrow an unknown payload value to a boolean (default false). */
|
|
77
|
+
export function optionalBoolean(payload: unknown, key: string): boolean {
|
|
78
|
+
const record = payload as Record<string, unknown> | null
|
|
79
|
+
const value = record?.[key]
|
|
80
|
+
return value === true
|
|
81
|
+
}
|