@vintasoftware/pr-review-canvas 0.1.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.
Files changed (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,64 @@
1
+ import { Hono } from 'hono'
2
+ import { type Appearance, type AppearanceQuery, appearanceForRequest } from '../../contract/settings.js'
3
+ import type { AppContext } from '../context.js'
4
+ import type { AppEnv } from '../env.js'
5
+ import { AppError } from '../errors.js'
6
+ import { homePage, reviewPage } from '../html.js'
7
+ import { parsePrNumber } from './api.js'
8
+
9
+ /** How the page is painted, rendered onto the tag so nothing flashes before the app module runs. */
10
+ export async function appearanceFor(ctx: AppContext, query: AppearanceQuery): Promise<Appearance> {
11
+ return appearanceForRequest(await ctx.settings.read(), query)
12
+ }
13
+
14
+ /** The `?skin` and `?theme` of one request, which pick an appearance for that load alone. */
15
+ export function appearanceQuery(c: { req: { query: (name: string) => string | undefined } }): AppearanceQuery {
16
+ return { skin: c.req.query('skin'), theme: c.req.query('theme') }
17
+ }
18
+
19
+ export function pageRoutes(ctx: AppContext): Hono<AppEnv> {
20
+ const app = new Hono<AppEnv>()
21
+
22
+ app.get('/', async c => {
23
+ const recentPrs = await ctx.prs.listRecent(10)
24
+ return c.html(
25
+ homePage(
26
+ {
27
+ recentPrs,
28
+ owner: ctx.config.repo.owner,
29
+ repo: ctx.config.repo.name,
30
+ version: ctx.version,
31
+ port: ctx.config.port,
32
+ },
33
+ c.get('cspNonce'),
34
+ await appearanceFor(ctx, appearanceQuery(c))
35
+ )
36
+ )
37
+ })
38
+
39
+ // Where the home page's form lands. It is a plain GET form, so the page needs no script of its
40
+ // own and the number arrives as a query parameter.
41
+ app.get('/review', c => {
42
+ const raw = c.req.query('n') ?? ''
43
+ return c.redirect(`/review/${encodeURIComponent(raw)}`, 303)
44
+ })
45
+
46
+ app.get('/review/:n', async c => {
47
+ const raw = c.req.param('n')
48
+ let prNumber: number
49
+ try {
50
+ prNumber = parsePrNumber(raw)
51
+ } catch {
52
+ throw new AppError('BAD_REQUEST', `"${raw}" is not a pull request number`, 400, 'use /review/<number>')
53
+ }
54
+ return c.html(
55
+ reviewPage(
56
+ { prNumber, owner: ctx.config.repo.owner, repo: ctx.config.repo.name, version: ctx.version },
57
+ c.get('cspNonce'),
58
+ await appearanceFor(ctx, appearanceQuery(c))
59
+ )
60
+ )
61
+ })
62
+
63
+ return app
64
+ }
@@ -0,0 +1,245 @@
1
+ // The routes that write: local review state, and the three things the tool posts to GitHub.
2
+ import { Hono } from 'hono'
3
+ import { z } from 'zod'
4
+ import type { ReviewBodyResponse, StateResponse } from '../../contract/api.js'
5
+ import { PostCommentInputSchema, type PostCommentResult } from '../../contract/comments.js'
6
+ import type { Pr, ReviewArtifact } from '../../contract/review-artifact.js'
7
+ import { checkInlineTarget, postComment } from '../../github/post-comment.js'
8
+ import { PostReviewInputSchema, postReview } from '../../github/post-review.js'
9
+ import { buildReviewBody, stateForHead, unreviewedLayers } from '../../github/review-body.js'
10
+ import { isReviewedId } from '../../store/state-store.js'
11
+ import type { PrLoader } from '../bundle.js'
12
+ import type { AppContext } from '../context.js'
13
+ import { AppError } from '../errors.js'
14
+ import { parsePrNumber } from './api.js'
15
+
16
+ const ReviewedBodySchema = z.object({
17
+ reviewed: z.boolean(),
18
+ /** The commit the page was showing; a mark made on another commit is refused. */
19
+ headSha: z
20
+ .string()
21
+ .regex(/^[0-9a-f]{40}$/)
22
+ .optional(),
23
+ })
24
+ const DismissedBodySchema = z.object({ dismissed: z.boolean(), reason: z.string().max(500).optional() })
25
+ const HiddenBodySchema = z.object({ hidden: z.boolean() })
26
+
27
+ /**
28
+ * The page says which commit it was showing. When the pull request has moved on, the post is
29
+ * refused instead of landing on code the reader never saw.
30
+ */
31
+ function requireSameHead(expected: string | undefined, current: string): void {
32
+ if (expected !== undefined && expected !== current) {
33
+ throw new AppError(
34
+ 'CANVAS_STALE',
35
+ 'the pull request has a new head commit since this page was drawn',
36
+ 409,
37
+ 'reload the page and try again'
38
+ )
39
+ }
40
+ }
41
+
42
+ /** A JSON body the route can read, or a 400 that says what shape it expected. */
43
+ async function readBody<T>(request: Request, schema: z.ZodType<T>, expected: string): Promise<T> {
44
+ let raw: unknown
45
+ try {
46
+ raw = await request.json()
47
+ } catch {
48
+ throw new AppError('BAD_REQUEST', `send a JSON body: ${expected}`, 400)
49
+ }
50
+ const parsed = schema.safeParse(raw)
51
+ if (!parsed.success) {
52
+ throw new AppError('BAD_REQUEST', `send a JSON body: ${expected}`, 400, parsed.error.issues[0]?.message)
53
+ }
54
+ return parsed.data
55
+ }
56
+
57
+ /** The canvas the reviewer is signing off on: the one written for the pull request's head. */
58
+ async function artifactForHead(ctx: AppContext, number: number, pr: Pr): Promise<ReviewArtifact> {
59
+ if (ctx.fixtureArtifact !== null) {
60
+ return { ...ctx.fixtureArtifact, pr }
61
+ }
62
+ const found = await ctx.canvases.findForPr(number, pr.headSha)
63
+ if (found.status !== 'ready') {
64
+ throw new AppError(
65
+ 'SIGNOFF_INCOMPLETE',
66
+ 'the canvas on screen was generated for another commit',
67
+ 409,
68
+ 'regenerate the canvas for the current head, then review its layers'
69
+ )
70
+ }
71
+ const artifact = await ctx.canvases.readArtifact(found.headSha)
72
+ if (artifact === null) {
73
+ throw new AppError('CANVAS_NOT_FOUND', `no canvas for pull request ${number}`, 404, 'generate one first')
74
+ }
75
+ return artifact
76
+ }
77
+
78
+ export function reviewRoutes(ctx: AppContext, loader: PrLoader): Hono {
79
+ const api = new Hono()
80
+
81
+ /** Posting is refused here as well as in the UI, so a stale page cannot post either. */
82
+ const requirePosting = async (): Promise<void> => {
83
+ const caps = await ctx.capabilities.get()
84
+ if (caps.canComment === false) {
85
+ throw new AppError(
86
+ 'COMMENT_FORBIDDEN',
87
+ caps.reason ?? 'this GitHub login cannot post on this repository',
88
+ 403,
89
+ caps.hint
90
+ )
91
+ }
92
+ }
93
+
94
+ const stateBody = (number: number, state: StateResponse['state']): StateResponse => ({ prNumber: number, state })
95
+
96
+ api.get('/prs/:n/state', async c => {
97
+ const number = parsePrNumber(c.req.param('n'))
98
+ return c.json(stateBody(number, await ctx.state.read(number)))
99
+ })
100
+
101
+ api.put('/prs/:n/reviewed/:id{.+}', async c => {
102
+ const number = parsePrNumber(c.req.param('n'))
103
+ const id = c.req.param('id')
104
+ if (!isReviewedId(id)) {
105
+ throw new AppError('BAD_REQUEST', `not a reviewed id: ${id}`, 400, 'use layer:<id> or layer:<id>/file:<key>')
106
+ }
107
+ const body = await readBody(c.req.raw, ReviewedBodySchema, '{ "reviewed": true }')
108
+ const pr = await loader.currentPr(number)
109
+ requireSameHead(body.headSha, pr.headSha)
110
+ return c.json(stateBody(number, await ctx.state.setReviewed(number, id, body.reviewed, pr.headSha)))
111
+ })
112
+
113
+ api.put('/prs/:n/points/:fingerprint/dismissed', async c => {
114
+ const number = parsePrNumber(c.req.param('n'))
115
+ const fingerprint = c.req.param('fingerprint')
116
+ const body = await readBody(c.req.raw, DismissedBodySchema, '{ "dismissed": true, "reason": "…" }')
117
+ const next = await ctx.state.setDismissed(number, fingerprint, body.dismissed, body.reason)
118
+ return c.json(stateBody(number, next))
119
+ })
120
+
121
+ api.put('/prs/:n/threads/:rootCommentId/hidden', async c => {
122
+ const number = parsePrNumber(c.req.param('n'))
123
+ const rootId = z.coerce.number().int().positive().safeParse(c.req.param('rootCommentId'))
124
+ if (!rootId.success) {
125
+ throw new AppError('BAD_REQUEST', 'the thread id is the numeric id of its first comment', 400)
126
+ }
127
+ const { hidden } = await readBody(c.req.raw, HiddenBodySchema, '{ "hidden": true }')
128
+ return c.json(stateBody(number, await ctx.state.setThreadHidden(number, rootId.data, hidden)))
129
+ })
130
+
131
+ api.get('/prs/:n/capabilities', async c => {
132
+ parsePrNumber(c.req.param('n'))
133
+ return c.json(await ctx.capabilities.get({ refresh: c.req.query('refresh') === '1' }))
134
+ })
135
+
136
+ api.post('/prs/:n/comments', async c => {
137
+ const number = parsePrNumber(c.req.param('n'))
138
+ const input = await readBody(c.req.raw, PostCommentInputSchema, 'an inline, reply, or issue comment')
139
+ await requirePosting()
140
+ const pr = await loader.currentPr(number)
141
+ requireSameHead(input.headSha, pr.headSha)
142
+ if (input.kind === 'inline') {
143
+ const derived = await ctx.derived.read(pr.headSha)
144
+ if (derived === null) {
145
+ throw new AppError(
146
+ 'NOT_FOUND',
147
+ 'the diff of this head is not available locally, so the line cannot be checked',
148
+ 404,
149
+ 'fetch the PR head and reload'
150
+ )
151
+ }
152
+ const problem = checkInlineTarget(derived.files, input)
153
+ if (problem !== null) {
154
+ throw new AppError('COMMENT_LINE_NOT_IN_DIFF', problem, 422, 'comment on a line the diff shows')
155
+ }
156
+ }
157
+ const posted = await postComment(ctx.gh, ctx.config.repo, number, pr.headSha, input)
158
+ await appendComment(ctx, number, posted)
159
+ const entry =
160
+ input.kind === 'inline' && input.pointFingerprint !== undefined
161
+ ? { commentId: posted.comment.id, pointFingerprint: input.pointFingerprint }
162
+ : { commentId: posted.comment.id }
163
+ const state = await ctx.state.addPosted(number, entry)
164
+ return c.json({ ...posted, state }, 201)
165
+ })
166
+
167
+ api.get('/prs/:n/review/body', async c => {
168
+ const number = parsePrNumber(c.req.param('n'))
169
+ const pr = await loader.currentPr(number)
170
+ const artifact = await artifactForHead(ctx, number, pr)
171
+ const state = stateForHead(await ctx.state.read(number), pr.headSha)
172
+ const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
173
+ const body: ReviewBodyResponse = {
174
+ headSha: pr.headSha,
175
+ body: buildReviewBody({ artifact, state, comments, headSha: pr.headSha }),
176
+ unreviewed: unreviewedLayers(artifact, state).map(l => l.title),
177
+ }
178
+ return c.json(body)
179
+ })
180
+
181
+ api.post('/prs/:n/review', async c => {
182
+ const number = parsePrNumber(c.req.param('n'))
183
+ const input = await readBody(c.req.raw, PostReviewInputSchema, '{ "event": "APPROVE" }')
184
+ await requirePosting()
185
+ const pr = await loader.currentPr(number)
186
+ requireSameHead(input.headSha, pr.headSha)
187
+ const artifact = await artifactForHead(ctx, number, pr)
188
+ const state = stateForHead(await ctx.state.read(number), pr.headSha)
189
+ if (input.event === 'APPROVE') {
190
+ const missing = unreviewedLayers(artifact, state)
191
+ if (missing.length > 0) {
192
+ throw new AppError(
193
+ 'SIGNOFF_INCOMPLETE',
194
+ `${missing.length} ${missing.length === 1 ? 'layer is' : 'layers are'} not reviewed yet`,
195
+ 409,
196
+ missing.map(l => l.title).join(', ')
197
+ )
198
+ }
199
+ }
200
+ const comments = (await ctx.prs.readComments(number)) ?? (await loader.refreshComments(number)).comments
201
+ const body = input.body ?? buildReviewBody({ artifact, state, comments, headSha: pr.headSha })
202
+ const review = await postReview(ctx.gh, ctx.config.repo, number, pr.headSha, { event: input.event, body })
203
+ return c.json({ review }, 201)
204
+ })
205
+
206
+ return api
207
+ }
208
+
209
+ /** One append at a time per PR, so two posts that land together do not overwrite each other. */
210
+ const appendChains = new Map<number, Promise<unknown>>()
211
+
212
+ function appendComment(ctx: AppContext, number: number, posted: PostCommentResult): Promise<void> {
213
+ const run = () => writeAppendedComment(ctx, number, posted)
214
+ const chained = (appendChains.get(number) ?? Promise.resolve()).then(run, run)
215
+ appendChains.set(
216
+ number,
217
+ chained.catch(() => undefined)
218
+ )
219
+ return chained
220
+ }
221
+
222
+ /**
223
+ * Keeps the cached comments in step with what was just posted, so a reload shows it once. With
224
+ * nothing cached there is nothing to keep in step: the next read fetches the list from GitHub.
225
+ */
226
+ async function writeAppendedComment(ctx: AppContext, number: number, posted: PostCommentResult): Promise<void> {
227
+ const comments = await ctx.prs.readComments(number)
228
+ if (comments === null) {
229
+ return
230
+ }
231
+ if (posted.kind === 'review') {
232
+ if (comments.reviewComments.some(c => c.id === posted.comment.id)) {
233
+ return
234
+ }
235
+ await ctx.prs.writeComments(number, {
236
+ ...comments,
237
+ reviewComments: [...comments.reviewComments, posted.comment],
238
+ })
239
+ return
240
+ }
241
+ if (comments.issueComments.some(c => c.id === posted.comment.id)) {
242
+ return
243
+ }
244
+ await ctx.prs.writeComments(number, { ...comments, issueComments: [...comments.issueComments, posted.comment] })
245
+ }
@@ -0,0 +1,114 @@
1
+ import { readFile, realpath, stat } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { Hono } from 'hono'
4
+ import type { AppContext, VendorRoots } from '../context.js'
5
+ import { AppError } from '../errors.js'
6
+
7
+ const CONTENT_TYPES: Record<string, string> = {
8
+ '.js': 'text/javascript; charset=utf-8',
9
+ '.mjs': 'text/javascript; charset=utf-8',
10
+ '.css': 'text/css; charset=utf-8',
11
+ '.json': 'application/json; charset=utf-8',
12
+ '.map': 'application/json; charset=utf-8',
13
+ '.svg': 'image/svg+xml',
14
+ '.png': 'image/png',
15
+ '.ico': 'image/x-icon',
16
+ '.txt': 'text/plain; charset=utf-8',
17
+ }
18
+
19
+ export function contentTypeFor(file: string): string {
20
+ return CONTENT_TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream'
21
+ }
22
+
23
+ function under(root: string, full: string): boolean {
24
+ return full === root || full.startsWith(root + path.sep)
25
+ }
26
+
27
+ /**
28
+ * Resolves `rel` under `root`, or null when it escapes the root or is not a plain file. The
29
+ * real path is checked as well as the written one, so a symlink inside the root that points
30
+ * outside it names no file either.
31
+ */
32
+ export async function resolveUnder(root: string, rel: string): Promise<string | null> {
33
+ let decoded: string
34
+ try {
35
+ decoded = decodeURIComponent(rel)
36
+ } catch {
37
+ // A malformed percent escape names no file.
38
+ return null
39
+ }
40
+ if (decoded.includes('\0')) {
41
+ return null
42
+ }
43
+ const full = path.resolve(root, decoded)
44
+ if (!under(root, full)) {
45
+ return null
46
+ }
47
+ try {
48
+ const s = await stat(full)
49
+ if (!s.isFile()) {
50
+ return null
51
+ }
52
+ const real = await realpath(full)
53
+ const realRoot = await realpath(root)
54
+ return under(realRoot, real) ? full : null
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ /**
61
+ * The browser-side libraries, served from node_modules. Single-file libraries map to one exact
62
+ * file; `diff` and `mermaid` ship many ES modules with relative imports, so their directories are
63
+ * served as a whole (JavaScript files only). Mermaid loads the chunk of a diagram type at render
64
+ * time, which is why its whole `dist/` has to be reachable.
65
+ */
66
+ export async function resolveVendor(roots: VendorRoots, rel: string): Promise<string | null> {
67
+ const exact: Record<string, string> = {
68
+ 'marked.js': roots.marked,
69
+ 'purify.js': roots.dompurify,
70
+ 'highlight.js': roots.hljs,
71
+ }
72
+ const hit = exact[rel]
73
+ if (hit !== undefined) {
74
+ return resolveUnder(path.dirname(hit), path.basename(hit))
75
+ }
76
+ if (rel.startsWith('diff/') && rel.endsWith('.js')) {
77
+ return resolveUnder(roots.diff, rel.slice('diff/'.length))
78
+ }
79
+ if (rel.startsWith('mermaid/') && rel.endsWith('.mjs')) {
80
+ return resolveUnder(roots.mermaid, rel.slice('mermaid/'.length))
81
+ }
82
+ return null
83
+ }
84
+
85
+ export function staticRoutes(ctx: AppContext): Hono {
86
+ const app = new Hono()
87
+
88
+ async function send(file: string, cache: string): Promise<Response> {
89
+ const body = await readFile(file)
90
+ return new Response(new Uint8Array(body), {
91
+ headers: { 'content-type': contentTypeFor(file), 'cache-control': cache },
92
+ })
93
+ }
94
+
95
+ app.get('/static/*', async c => {
96
+ const rel = c.req.path.slice('/static/'.length)
97
+ const file = await resolveUnder(ctx.staticDir, rel)
98
+ if (file === null) {
99
+ throw new AppError('NOT_FOUND', `no static file ${rel}`, 404)
100
+ }
101
+ return send(file, 'no-cache')
102
+ })
103
+
104
+ app.get('/vendor/*', async c => {
105
+ const rel = c.req.path.slice('/vendor/'.length)
106
+ const file = await resolveVendor(ctx.vendorRoots, rel)
107
+ if (file === null) {
108
+ throw new AppError('NOT_FOUND', `no vendored file ${rel}`, 404)
109
+ }
110
+ return send(file, 'public, max-age=86400')
111
+ })
112
+
113
+ return app
114
+ }
@@ -0,0 +1,104 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import type { MiddlewareHandler } from 'hono'
3
+ import type { AppEnv } from './env.js'
4
+ import { AppError } from './errors.js'
5
+
6
+ const ALLOWED_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]'])
7
+
8
+ /** The hostname part of a Host header (`localhost:3010` → `localhost`, `[::1]:3010` → `[::1]`). */
9
+ export function hostnameOf(host: string): string {
10
+ const m = /^(\[[^\]]+\]|[^:]+)(?::\d+)?$/.exec(host.trim())
11
+ return m?.[1]?.toLowerCase() ?? ''
12
+ }
13
+
14
+ export function isAllowedHost(host: string | undefined): host is string {
15
+ return host !== undefined && ALLOWED_HOSTNAMES.has(hostnameOf(host))
16
+ }
17
+
18
+ /** Origin must name this very server. `null` (opaque origin) is rejected too. */
19
+ export function isSameOrigin(origin: string | undefined, host: string): boolean {
20
+ if (origin === undefined) {
21
+ return true
22
+ }
23
+ let url: URL
24
+ try {
25
+ url = new URL(origin)
26
+ } catch {
27
+ return false
28
+ }
29
+ return url.host.toLowerCase() === host.toLowerCase() && isAllowedHost(url.host)
30
+ }
31
+
32
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
33
+
34
+ /**
35
+ * Two checks. The Host allowlist stops DNS rebinding: a page on an attacker's domain that
36
+ * resolves to 127.0.0.1 sends its own hostname. The same-origin check on state-changing methods
37
+ * stops cross-site requests, because this server can post to GitHub as the user.
38
+ */
39
+ export const securityMiddleware: MiddlewareHandler<AppEnv> = async (c, next) => {
40
+ const host = c.req.header('host')
41
+ if (!isAllowedHost(host)) {
42
+ throw new AppError('FORBIDDEN_HOST', 'this server only answers to localhost', 403)
43
+ }
44
+ if (!SAFE_METHODS.has(c.req.method)) {
45
+ const site = c.req.header('sec-fetch-site')
46
+ if (site !== undefined && site !== 'same-origin' && site !== 'none') {
47
+ throw new AppError('CROSS_ORIGIN', 'cross-site request rejected', 403)
48
+ }
49
+ if (!isSameOrigin(c.req.header('origin'), host)) {
50
+ throw new AppError('CROSS_ORIGIN', 'cross-origin request rejected', 403)
51
+ }
52
+ }
53
+ await next()
54
+ }
55
+
56
+ /**
57
+ * The page's own scripts and styles, nothing else. Inline styles are allowed because the page
58
+ * sets dynamic values that way (progress width, layer stripe colors) and mermaid injects a style
59
+ * element per drawing; inline scripts are not, except the two the shell carries under its nonce.
60
+ */
61
+ export function contentSecurityPolicy(nonce: string): string {
62
+ return [
63
+ "default-src 'none'",
64
+ `script-src 'self' 'nonce-${nonce}'`,
65
+ "style-src 'self' 'unsafe-inline'",
66
+ "img-src 'self' data: https:",
67
+ "font-src 'self'",
68
+ "connect-src 'self'",
69
+ "form-action 'self'",
70
+ "base-uri 'none'",
71
+ "frame-ancestors 'none'",
72
+ "object-src 'none'",
73
+ ].join('; ')
74
+ }
75
+
76
+ /** A fresh nonce per HTML response, so the shell's inline scripts run and nothing else does. */
77
+ export function createNonce(): string {
78
+ return randomBytes(16).toString('base64')
79
+ }
80
+
81
+ /**
82
+ * Headers every answer carries. `no-store` on the API keeps PR text, comments, and canvases out
83
+ * of any cache; HTML pages carry the policy above.
84
+ */
85
+ export function applyResponseHeaders(res: Response, path: string, nonce: string): void {
86
+ res.headers.set('x-content-type-options', 'nosniff')
87
+ res.headers.set('referrer-policy', 'no-referrer')
88
+ if (path.startsWith('/api/')) {
89
+ res.headers.set('cache-control', 'no-store')
90
+ }
91
+ if ((res.headers.get('content-type') ?? '').startsWith('text/html')) {
92
+ res.headers.set('content-security-policy', contentSecurityPolicy(nonce))
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Puts the headers on every answer of the normal path. Errors never reach the code after
98
+ * `next()`, so `createApp` applies the same headers to what its error handlers build.
99
+ */
100
+ export const responseHeaders: MiddlewareHandler<AppEnv> = async (c, next) => {
101
+ c.set('cspNonce', createNonce())
102
+ await next()
103
+ applyResponseHeaders(c.res, c.req.path, c.get('cspNonce'))
104
+ }
@@ -0,0 +1,67 @@
1
+ // Server-sent events, written by hand: one named event per frame, JSON in the data line.
2
+ import type { ChatEvent } from '../contract/chat.js'
3
+
4
+ export const SSE_HEADERS = {
5
+ 'content-type': 'text/event-stream; charset=utf-8',
6
+ 'cache-control': 'no-store',
7
+ connection: 'keep-alive',
8
+ /** Nothing between the browser and this server should buffer a stream on localhost. */
9
+ 'x-accel-buffering': 'no',
10
+ } as const
11
+
12
+ /**
13
+ * One frame. A data line cannot hold a newline, so the JSON is written as one line, which
14
+ * `JSON.stringify` already gives.
15
+ */
16
+ export function sseFrame(event: ChatEvent): string {
17
+ const { event: name, ...rest } = event
18
+ return `event: ${name}\ndata: ${JSON.stringify(rest)}\n\n`
19
+ }
20
+
21
+ /**
22
+ * The event stream of one chat turn as a response body.
23
+ *
24
+ * `onCancel` runs the moment the reader goes away. Asking the iterator to return is not enough on
25
+ * its own: an async generator queues `return()` behind a `next()` that is still waiting, so a
26
+ * silent agent would keep running until it spoke again.
27
+ */
28
+ export function sseStream(
29
+ events: AsyncIterable<ChatEvent>,
30
+ onClose?: () => void,
31
+ onCancel?: () => void
32
+ ): ReadableStream<Uint8Array> {
33
+ const encoder = new TextEncoder()
34
+ const iterator = events[Symbol.asyncIterator]()
35
+ return new ReadableStream<Uint8Array>({
36
+ async pull(controller) {
37
+ let next: IteratorResult<ChatEvent>
38
+ try {
39
+ next = await iterator.next()
40
+ } catch (err) {
41
+ controller.enqueue(
42
+ encoder.encode(
43
+ sseFrame({
44
+ event: 'error',
45
+ code: 'INTERNAL',
46
+ message: err instanceof Error ? err.message : String(err),
47
+ })
48
+ )
49
+ )
50
+ controller.close()
51
+ onClose?.()
52
+ return
53
+ }
54
+ if (next.done === true) {
55
+ controller.close()
56
+ onClose?.()
57
+ return
58
+ }
59
+ controller.enqueue(encoder.encode(sseFrame(next.value)))
60
+ },
61
+ cancel() {
62
+ onCancel?.()
63
+ void iterator.return?.(undefined)
64
+ onClose?.()
65
+ },
66
+ })
67
+ }
@@ -0,0 +1,68 @@
1
+ import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import type { z } from 'zod'
4
+
5
+ /** Writes JSON through a temp file and a rename, so a reader never sees a half-written file. */
6
+ export async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
7
+ return writeTextAtomic(file, `${JSON.stringify(value, null, 2)}\n`)
8
+ }
9
+
10
+ export async function writeTextAtomic(file: string, text: string): Promise<void> {
11
+ await mkdir(path.dirname(file), { recursive: true })
12
+ const temporaryDir = await mkdtemp(`${file}.`)
13
+ try {
14
+ const tmp = path.join(temporaryDir, 'content')
15
+ await writeFile(tmp, text, 'utf8')
16
+ await rename(tmp, file)
17
+ } finally {
18
+ await rm(temporaryDir, { recursive: true, force: true })
19
+ }
20
+ }
21
+
22
+ /** Parsed and validated JSON, or null when the file does not exist. Invalid content throws. */
23
+ export async function readJson<T>(file: string, schema: z.ZodType<T>): Promise<T | null> {
24
+ let text: string
25
+ try {
26
+ text = await readFile(file, 'utf8')
27
+ } catch (err) {
28
+ if (isNotFound(err)) {
29
+ return null
30
+ }
31
+ throw err
32
+ }
33
+ return schema.parse(JSON.parse(text))
34
+ }
35
+
36
+ /**
37
+ * Like readJson, but a missing file, invalid JSON, or a shape the schema rejects all return
38
+ * `fallback()`. Other read errors still throw.
39
+ */
40
+ export async function readJsonOrDefault<T, D>(file: string, schema: z.ZodType<T>, fallback: () => D): Promise<T | D> {
41
+ const text = await readText(file)
42
+ if (text === null) {
43
+ return fallback()
44
+ }
45
+ let raw: unknown
46
+ try {
47
+ raw = JSON.parse(text)
48
+ } catch {
49
+ return fallback()
50
+ }
51
+ const parsed = schema.safeParse(raw)
52
+ return parsed.success ? parsed.data : fallback()
53
+ }
54
+
55
+ export async function readText(file: string): Promise<string | null> {
56
+ try {
57
+ return await readFile(file, 'utf8')
58
+ } catch (err) {
59
+ if (isNotFound(err)) {
60
+ return null
61
+ }
62
+ throw err
63
+ }
64
+ }
65
+
66
+ export function isNotFound(err: unknown): boolean {
67
+ return typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT'
68
+ }