feat: add lightweight shared Agent Studios - #1640
Conversation
There was a problem hiding this comment.
Findings
-
[Major] Generated share links lose the configured hub origin — the owner page copies
window.location.origin, while HAPI explicitly supports a separately hosted PWA whose API client usesbaseUrl. Both public studio clients then fetch relative/api/public/...URLs, so links created from app.hapi.run or another standalone host call the static host instead of the selected hub and cannot load the studio. Evidence:web/src/routes/studios/owner.tsx:93,web/src/studio/StudioLiteApp.tsx:74
Suggested fix:const { api, baseUrl } = useAppContext() const shareUrl = room ? `${window.location.origin}/studio/${room.shareToken}?hub=${encodeURIComponent(baseUrl)}` : '' // Studio Lite: validate hub as http(s), then use it for GET and POST. const apiBase = new URLSearchParams(window.location.search).get('hub') ?? window.location.origin await fetch(new URL(`/api/public/studios/${encodeURIComponent(token)}`, apiBase))
-
[Major] Public posting limit is bypassable and its bucket map is unbounded —
guestIdis supplied by the caller, and the forwarded-IP headers may also be caller-controlled in direct deployments. Varying either value creates a fresh bucket, allowing unlimited database writes; every unique key also remains inpostRateBucketsforever. Evidence:hub/src/web/routes/studios.ts:157
Suggested fix:// Enforce a bounded room-wide limit before accepting any post. if (!allowPost(`room:${room.id}`)) { return c.json({ error: 'Too many posts; try again shortly' }, 429) } // Also prune empty buckets periodically, or use a bounded TTL cache.
-
[Major] The 200-post cap permanently hides newer moderation items —
listPostssorts ascending before applyingLIMIT, so after 200 lifetime posts every later discussion/suggestion is stored but absent from both owner and public responses. This can silently strand valid suggestions and is easy to trigger through the public endpoint. Evidence:hub/src/store/studioStore.ts:157
Suggested fix:SELECT * FROM ( SELECT * FROM studio_posts WHERE room_id = ? ORDER BY created_at DESC, id DESC LIMIT ? ) ORDER BY created_at ASC, id ASC
-
[Minor] New messages force readers back to the bottom — this effect depends on
unreadCount; while the conversation tab is already active (the desktop/default state), incrementing unread count immediately invokesscrollToLatest. The jump button therefore cannot preserve a reader's position in history. Evidence:web/src/studio/StudioLiteApp.tsx:195
Suggested fix:const previousTabRef = useRef(tab) useEffect(() => { const openedConversation = previousTabRef.current !== 'conversation' && tab === 'conversation' previousTabRef.current = tab if (openedConversation && unreadCount > 0) scrollToLatest('smooth') }, [scrollToLatest, tab, unreadCount])
Questions
- None.
Summary
- Review mode: initial
- Three major issues: standalone/relay share links target the wrong API origin, public posting throttling is trivially bypassed with unbounded memory growth, and the post query hides all entries after the oldest 200. One minor regression breaks the advertised read-position behavior.
Testing
- Not run (automation); PR content was not executed.
- Add coverage for a configured remote hub share URL, rate-limit attempts with rotating guest IDs, retrieval after more than 200 posts, and receiving new messages while scrolled away from the bottom.
HAPI Bot
| () => posts.filter((post) => post.roomId === room?.id && post.kind === 'discussion'), | ||
| [posts, room?.id] | ||
| ) | ||
| const shareUrl = room ? `${window.location.origin}/studio/${room.shareToken}` : '' |
There was a problem hiding this comment.
[MAJOR] Generated share links lose the configured hub origin
This page uses the authenticated API client's configured baseUrl, but the copied URL is built from window.location.origin. In the supported standalone/official PWA setup those origins differ. The public clients then fetch relative /api/public/... URLs (StudioLiteApp.tsx:74), so the guest calls the static web host instead of the selected hub and the share cannot load.
Suggested fix:
const { api, baseUrl } = useAppContext()
const shareUrl = room
? `${window.location.origin}/studio/${room.shareToken}?hub=${encodeURIComponent(baseUrl)}`
: ''
// Studio Lite: validate hub as http(s), then use it for both GET and POST.
const apiBase = new URLSearchParams(window.location.search).get('hub') ?? window.location.origin
await fetch(new URL(`/api/public/studios/${encodeURIComponent(token)}`, apiBase))| const remote = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() | ||
| ?? c.req.header('x-real-ip') | ||
| ?? 'unknown' | ||
| if (!allowPost(`${room.id}:${remote}:${parsed.data.guestId}`)) { |
There was a problem hiding this comment.
[MAJOR] Public posting limit is bypassable and its bucket map is unbounded
guestId is caller-supplied, and forwarded-IP headers may also be caller-controlled in direct deployments. Rotating either value creates a fresh bucket, so a token holder can write without an effective limit. Each unique key also remains in postRateBuckets forever, adding a memory-exhaustion path.
Suggested fix:
// Enforce a bounded room-wide limit before accepting any post.
if (!allowPost(`room:${room.id}`)) {
return c.json({ error: 'Too many posts; try again shortly' }, 429)
}
// Also prune empty buckets periodically, or replace the map with a bounded TTL cache.| const rows = this.db.prepare(` | ||
| SELECT * FROM studio_posts | ||
| WHERE room_id = ? | ||
| ORDER BY created_at ASC, id ASC |
There was a problem hiding this comment.
[MAJOR] The 200-post cap permanently hides newer moderation items
The query orders oldest-first and only then applies LIMIT 200. Once a studio has 200 posts, every later discussion or suggestion is persisted but never returned to the owner or guests, silently stranding new moderation work.
Suggested fix:
SELECT * FROM (
SELECT * FROM studio_posts
WHERE room_id = ?
ORDER BY created_at DESC, id DESC
LIMIT ?
)
ORDER BY created_at ASC, id ASC| return undefined | ||
| }, [data?.messages, nearBottom, scrollToLatest]) | ||
|
|
||
| useEffect(() => { |
There was a problem hiding this comment.
[MINOR] New messages force readers back to the bottom
Because this effect also depends on unreadCount, a new message received while the conversation tab is already active increments the count and immediately calls scrollToLatest. On desktop that tab is always active, so the unread/jump control cannot preserve a reader's position.
Suggested fix:
const previousTabRef = useRef(tab)
useEffect(() => {
const openedConversation =
previousTabRef.current !== 'conversation' && tab === 'conversation'
previousTabRef.current = tab
if (openedConversation && unreadCount > 0) scrollToLatest('smooth')
}, [scrollToLatest, tab, unreadCount])There was a problem hiding this comment.
Findings
-
[Major] GitHub Pages fallback still ignores the selected hub - standalone share links include
?hub=..., butweb/package.jsonpublishes the full PWA as404.html. A direct/studio/<token>request on app.hapi.run therefore loads this fallback component, whose GET and POST both use relative/apiURLs. The request still goes to app.hapi.run instead of the shared room's hub. Evidence:web/src/routes/studios/public.tsx:21
Suggested fix:const apiOrigin = resolveStudioApiOrigin( new URLSearchParams(window.location.search).get('hub'), window.location.origin ) const response = await fetch( new URL(`/api/public/studios/${encodeURIComponent(token)}`, apiOrigin) ) // Use the same apiOrigin for POST.
-
[Major] Public transcript projection bypasses existing visibility rules - all stored rows are projected directly. User rows with
invokedAt === nullare queued/scheduled prompts, and assistant output markedisMetaorisCompactSummaryis hidden by the existing export pipeline; this route can publish both through the share token. Evidence:hub/src/web/routes/studios.ts:79
Suggested fix:if (record.role === 'user' && message.invokedAt === null) return null const output = isObject(record.content) && record.content.type === 'output' && isObject(record.content.data) ? record.content.data : null if (output?.isMeta === true || output?.isCompactSummary === true) return null
-
[Major] The mixed 200-post cap still hides valid room data -
listPostslimits the combined discussion/suggestion stream before callers filter it. Two hundred suggestions make the public discussion response empty even when older discussions exist; two hundred discussions push older open suggestions out of the owner's moderation view. Evidence:hub/src/store/studioStore.ts:159
Suggested fix:const publicPosts = store.studios.listPostsByKind(room.id, 'discussion', 200) const openSuggestions = store.studios.listOpenSuggestions(room.id) // Paginate recent history separately; do not cap before applying kind/status.
Questions
- None.
Summary
Review mode: follow-up after new commits
- Three major issues remain: standalone Pages links still target the static host API, public transcript redaction exposes non-visible message rows, and post limiting can hide discussions or unresolved suggestions.
Testing
- Not run (automation); PR code was not executed.
- GitHub
integrationcheck passed;testwas pending at review time. - Missing coverage: GitHub Pages/full-PWA fallback with a remote
hub, queued/meta transcript filtering, and mixed post kinds beyond 200 rows.
HAPI Bot
| } | ||
|
|
||
| async function requestStudio(token: string): Promise<PublicStudioResponse> { | ||
| const response = await fetch(`/api/public/studios/${encodeURIComponent(token)}`, { |
There was a problem hiding this comment.
[MAJOR] GitHub Pages fallback still calls the static host API
The copied cross-origin link now carries ?hub=..., but GitHub Pages serves the full PWA's 404.html for /studio/<token>. This fallback route ignores that parameter here and again in the POST at line 69, so app.hapi.run requests app.hapi.run/api/... rather than the selected hub.
Suggested fix:
const apiOrigin = resolveStudioApiOrigin(
new URLSearchParams(window.location.search).get('hub'),
window.location.origin
)
const response = await fetch(
new URL(`/api/public/studios/${encodeURIComponent(token)}`, apiOrigin)
)
// Use apiOrigin for POST too.| function projectMessage(message: StoredMessage): PublicStudioMessage | null { | ||
| const record = unwrapRoleWrappedRecordEnvelope(message.content) | ||
| if (!record) return null | ||
| if (record.role === 'user') { |
There was a problem hiding this comment.
[MAJOR] Apply the existing message-visibility policy before publishing
This projects every stored user row, including queued or future scheduled prompts (invokedAt === null). The assistant branch also lacks the existing export filters for isMeta and isCompactSummary, so content hidden from normal exports can become public through the share token.
Suggested fix:
if (record.role === 'user' && message.invokedAt === null) return null
const output = isObject(record.content)
&& record.content.type === 'output'
&& isObject(record.content.data)
? record.content.data
: null
if (output?.isMeta === true || output?.isCompactSummary === true) return null| SELECT * FROM studio_posts | ||
| WHERE room_id = ? | ||
| ORDER BY created_at DESC, id DESC | ||
| LIMIT ? |
There was a problem hiding this comment.
[MAJOR] The shared limit still drops items required by each view
The limit applies before kind/status filtering. If the newest 200 rows are suggestions, the public discussion list is empty despite older discussions; if they are discussions, an older open suggestion disappears from owner moderation. The room-wide limiter only slows this down.
Suggested fix:
const publicPosts = store.studios.listPostsByKind(room.id, 'discussion', 200)
const openSuggestions = store.studios.listOpenSuggestions(room.id)
// Paginate recent history separately; filter in SQL before LIMIT.There was a problem hiding this comment.
Findings
-
[Major] Cross-origin Studio reads trigger a disallowed preflight — the explicit
cache-controlrequest header is not CORS-safelisted, while the hub only allowsauthorization,content-type, andlast-event-id. Official/standalone Studio links therefore cannot GET the remote hub before rendering. Evidence:web/src/studio/StudioLiteApp.tsx:78,web/src/routes/studios/public.tsx:24, related contexthub/src/web/server.ts:247
Suggested fix:const response = await fetch(url, { cache: 'no-store' })
-
[Major] GitHub Pages still bypasses the Lite entry — the build keeps the full PWA as
404.html, and Pages serves that file for direct/studio/<token>requests. Relay share links therefore load the full application bundle instead of the dedicated Studio build. Evidence:web/package.json:9, related deploymentweb/.github/workflows/webapp.ymlnot found; repository workflow.github/workflows/webapp.yml:41
Suggested fix:<script> const match = location.pathname.match(/^\/studio\/([^/]+)/) if (match) { const target = new URL('/studio.html', location.origin) target.search = location.search target.searchParams.set('token', match[1]) location.replace(target) } </script>
return pathMatch?.[1] ?? new URLSearchParams(location.search).get('token') ?? ''
-
[Major] Owner polling returns every suggestion without a bound — a token holder may keep adding up to 60 suggestions per minute, and the owner page refetches the entire accumulated set every two seconds. This creates an ever-growing SQLite scan, JSON response, and browser render path. Evidence:
hub/src/web/routes/studios.ts:98, related pollweb/src/routes/studios/owner.tsx:82
Suggested fix:const suggestionPage = store.studios.listSuggestionPage(roomId, { limit: 200, before: cursor }) return { posts: [...discussions, ...suggestionPage.items], nextSuggestionCursor: suggestionPage.nextCursor }
-
[Major] Transcript limiting happens before visibility projection —
getMessages(..., 200)selects the newest raw rows by sequence, then drops queued/meta/tool-only rows. A tool-heavy turn can therefore return few or zero visible messages despite older visible conversation, and scheduled messages are not ordered by the normalinvokedAtdisplay position. Evidence:hub/src/web/routes/studios.ts:161
Suggested fix:const messages = await collectProjectedMessagesByPosition( options.store.messages, room.sessionId, projectMessage, 200 ) // Page backward with getMessagesByPosition until 200 projected rows or EOF.
Questions
- None.
Summary
Review mode: follow-up after new commits
- Four major issues remain: remote Studio GETs fail CORS preflight, Pages direct links do not use Studio Lite, suggestion moderation payloads grow without bound, and transcript pagination can discard valid visible conversation.
Testing
- Not run (automation); PR code was not executed.
- GitHub
integrationpassed;testwas in progress at review time. - Missing coverage: cross-origin public GET preflight, Pages
/studio/<token>entry selection, paginated large suggestion queues, and more than 200 raw rows with fewer than 200 visible messages.
HAPI Bot
| async function loadStudio(token: string, apiOrigin: string): Promise<PublicStudioResponse> { | ||
| const response = await fetch(new URL(`/api/public/studios/${encodeURIComponent(token)}`, apiOrigin), { | ||
| cache: 'no-store', | ||
| headers: { 'cache-control': 'no-cache' } |
There was a problem hiding this comment.
[MAJOR] Remove the non-safelisted request header
This public GET is cross-origin for the official/standalone PWA. The explicit cache-control header triggers a CORS preflight, but hub/src/web/server.ts:247 does not allow that header, so the browser blocks the Studio before it can render. The same header exists in web/src/routes/studios/public.tsx:24.
Suggested fix:
const response = await fetch(url, {
cache: 'no-store'
})| "scripts": { | ||
| "dev": "vite", | ||
| "build": "vite build && cp dist/index.html dist/404.html", | ||
| "build": "vite build && cp dist/index.html dist/404.html && rm -rf dist-studio && vite build --config vite.studio.config.ts && cp dist-studio/studio.html dist/studio.html && mkdir -p dist/studio-assets && cp -R dist-studio/studio-assets/. dist/studio-assets/ && rm -rf dist-studio", |
There was a problem hiding this comment.
[MAJOR] Route Pages deep links to the Lite HTML
GitHub Pages serves 404.html for /studio/<token>, and this command still copies the full PWA there. Thus the primary relay share URL never loads the dedicated Studio entry and pays the full application bundle cost.
Suggested fix:
<script>
const match = location.pathname.match(/^\/studio\/([^/]+)/)
if (match) {
const target = new URL('/studio.html', location.origin)
target.search = location.search
target.searchParams.set('token', match[1])
location.replace(target)
}
</script>Then let Studio Lite read token from the query when the path is /studio.html; preserve the current full-PWA fallback for non-Studio routes.
| function ownerPosts(store: Store, roomId: string) { | ||
| return [ | ||
| ...store.studios.listPostsByKind(roomId, 'discussion', 200), | ||
| ...store.studios.listPostsByKind(roomId, 'suggestion', null) |
There was a problem hiding this comment.
[MAJOR] Paginate suggestions instead of returning the entire history
Passing null removes every bound. A guest can add 60 suggestions per minute indefinitely, while the owner page polls this endpoint every two seconds, so storage reads, response size, and DOM work grow for the lifetime of the room.
Suggested fix:
const suggestionPage = store.studios.listSuggestionPage(roomId, {
limit: 200,
before: cursor
})
return {
posts: [...discussions, ...suggestionPage.items],
nextSuggestionCursor: suggestionPage.nextCursor
}| const session = engine?.resolveSessionAccess(room.sessionId, room.namespace) | ||
| if (!engine || !session?.ok) return c.json({ error: 'Studio session unavailable' }, 503) | ||
|
|
||
| const messages = options.store.messages.getMessages(room.sessionId, 200) |
There was a problem hiding this comment.
[MAJOR] Apply the limit after projection and use display-position paging
This selects 200 raw sequence rows before filtering. Tool/meta/queued rows can consume the whole page, hiding older visible conversation; sequence order also misplaces scheduled prompts relative to their invokedAt display position.
Suggested fix:
const messages = await collectProjectedMessagesByPosition(
options.store.messages,
room.sessionId,
projectMessage,
200
)
// Page backward with getMessagesByPosition until 200 projected rows or EOF.There was a problem hiding this comment.
Findings
-
[Major] Public Studio writes remain durably unbounded — the in-memory limiter permits 60 posts/minute and resets on restart, while each unauthenticated post may contain 2,000 characters. A holder of one shared token can therefore append roughly 165 MiB/day of text to the owner's SQLite database indefinitely. Evidence:
hub/src/web/routes/studios.ts:130,hub/src/web/routes/studios.ts:208,hub/src/web/routes/studios.ts:211
Suggested fix:const post = options.store.studios.createPostWithinRoomLimit( { roomId: room.id, ...parsed.data }, MAX_STUDIO_POSTS_PER_ROOM ) if (!post) return c.json({ error: 'Studio post limit reached' }, 429)
Implement the limit atomically with the insert; add owner purge/retention for discussions and resolved suggestions.
-
[Major] Owners cannot moderate public discussion posts — discussions are returned immediately to every guest, but the only decision endpoint rejects every non-suggestion post. A guest can permanently vandalize the discussion, leaving revocation of the whole room as the owner's only removal mechanism. Evidence:
hub/src/web/routes/studios.ts:190,hub/src/web/routes/studios.ts:294
Suggested fix:if (!post || post.status !== 'open') return c.json({ error: 'Open post not found' }, 404) if (parsed.data.action === 'dismiss') { return c.json({ post: options.store.studios.decidePost(post.id, room.id, 'dismissed') }) } if (post.kind !== 'suggestion') return c.json({ error: 'Discussion posts cannot be submitted' }, 400) const publicPosts = options.store.studios .listPostsByKind(room.id, 'discussion', 200) .filter((post) => post.status === 'open')
-
[Minor] Public JSON exposes a stable cross-room guest identifier — both guest clients persist one global
hapi.studio.guestId, and the public GET serializes fullStudioPostrows. Anyone holding multiple room links can correlate the same browser's participation even though the UI never needsguestId. Evidence:hub/src/web/routes/studios.ts:190, related contextweb/src/studio/StudioLiteApp.tsx:53
Suggested fix:const publicPosts = options.store.studios.listPostsByKind(room.id, 'discussion', 200).map( ({ id, roomId, authorName, kind, text, createdAt }) => ({ id, roomId, authorName, kind, text, createdAt }) )
Define a separate
PublicStudioPostresponse type so private moderation fields cannot be reintroduced accidentally. -
[Minor] Paginated suggestion moderation leaves older rows stale and undercounts the queue — resolving a suggestion loaded beyond the first 200 only invalidates the first-page query; because its cursor is unchanged,
olderSuggestionskeeps the item asopenand permits a second action that returns 404. The badge also counts loaded rows instead of the server'sopenSuggestionCount. Evidence:web/src/routes/studios/owner.tsx:124,web/src/routes/studios/owner.tsx:216
Suggested fix:const decisionMutation = useMutation({ mutationFn: (input) => api.decideStudioPost(studioId, input.postId, input), onSuccess: ({ post }) => { if (post) setOlderSuggestions((items) => items.map((item) => item.id === post.id ? post : item)) void refresh() } }) const pendingCount = query.data?.openSuggestionCount ?? suggestions.filter((post) => post.status === 'open').length
Questions
- None.
Summary
Review mode: follow-up after new commits
- One durable storage-exhaustion risk, one public-content moderation gap, and two privacy/pagination correctness issues remain at the latest head.
Testing
- Not run (automation); PR code was not executed.
- GitHub
integrationpassed;testwas in progress at review time. - Missing coverage: durable post quota/retention, dismissing public discussions, public post-field redaction, and moderating/counting suggestions loaded after page 1.
HAPI Bot
| if (!allowPost(`room:${room.id}`)) { | ||
| return c.json({ error: 'Too many posts; try again shortly' }, 429) | ||
| } | ||
| const post = options.store.studios.createPost({ roomId: room.id, ...parsed.data }) |
There was a problem hiding this comment.
[MAJOR] Add a durable per-room storage bound
This limiter only throttles to 60 posts/minute in memory and resets on restart. With 2,000-character bodies, one shared token can append roughly 165 MiB/day of text to SQLite indefinitely.
Suggested fix:
const post = options.store.studios.createPostWithinRoomLimit(
{ roomId: room.id, ...parsed.data },
MAX_STUDIO_POSTS_PER_ROOM
)
if (!post) return c.json({ error: 'Studio post limit reached' }, 429)Make the quota check and insert one SQLite transaction; provide owner purge/retention for discussions and resolved suggestions.
| const body = await c.req.json().catch(() => null) | ||
| const parsed = decidePostSchema.safeParse(body) | ||
| if (!parsed.success) return c.json({ error: 'Invalid body' }, 400) | ||
| const post = options.store.studios.getPost(c.req.param('postId'), room.id) |
There was a problem hiding this comment.
[MAJOR] Allow owners to remove public discussions
Discussion posts are published immediately, but this guard makes only suggestions dismissible. There is no other delete/dismiss path, so one guest can leave permanent public content unless the owner revokes the entire room.
Suggested fix:
if (!post || post.status !== 'open') return c.json({ error: 'Open post not found' }, 404)
if (parsed.data.action === 'dismiss') {
return c.json({ post: options.store.studios.decidePost(post.id, room.id, 'dismissed') })
}
if (post.kind !== 'suggestion') return c.json({ error: 'Discussion posts cannot be submitted' }, 400)Also filter dismissed discussions out of the public response.
| if (page.length < 200) break | ||
| } | ||
| const publicMessages = messages.slice(-200) | ||
| const publicPosts = options.store.studios.listPostsByKind(room.id, 'discussion', 200) |
There was a problem hiding this comment.
[MINOR] Redact the stable guest ID from public JSON
Both guest clients persist one global hapi.studio.guestId, while this response serializes full stored rows. A reader with multiple room links can therefore correlate the same browser across Studios even though the UI only needs display fields.
Suggested fix:
const publicPosts = options.store.studios.listPostsByKind(room.id, 'discussion', 200).map(
({ id, roomId, authorName, kind, text, createdAt }) =>
({ id, roomId, authorName, kind, text, createdAt })
)Use a separate PublicStudioPost type to keep moderation-only fields private.
| mutationFn: (input: { accessMode?: 'view' | 'contribute'; rotateToken?: boolean }) => api.updateStudio(studioId, input), | ||
| onSuccess: refresh | ||
| }) | ||
| const decisionMutation = useMutation({ |
There was a problem hiding this comment.
[MINOR] Reconcile locally paged suggestions after moderation
When the owner acts on a suggestion loaded beyond page 1, invalidating the first-page query does not update olderSuggestions; its cursor usually stays unchanged, so the row remains open and a second click returns 404. The badge at line 216 also ignores the server's openSuggestionCount.
Suggested fix:
onSuccess: ({ post }) => {
if (post) setOlderSuggestions((items) => items.map((item) => item.id === post.id ? post : item))
void refresh()
}
const pendingCount = query.data?.openSuggestionCount
?? suggestions.filter((post) => post.status === 'open').lengthThere was a problem hiding this comment.
Findings
-
[Major] Lifetime quota is unrecoverable after a discussion flood - the cap counts every historical row, while the owner payload exposes only the latest 200 discussions and token rotation/reactivation preserves all posts. A guest can fill 2,000 discussion rows in about 34 minutes; after that every contribution returns 429, and dismissing the visible 200 still leaves the older rows unreachable and counted. Evidence:
hub/src/store/studioStore.ts:280, related contexthub/src/web/routes/studios.ts:116,hub/src/store/studioStore.ts:148
Suggested fix:clearPosts(roomId: string): void { this.db.prepare('DELETE FROM studio_posts WHERE room_id = ?').run(roomId) } // Expose through an authenticated owner-only route with confirmation. // Also query open discussions in SQL so moderation reveals the next page.
-
[Major] Public polling can rescan an entire tool-heavy transcript - the loop has no raw-row/page bound and stops only after finding 200 visible messages or reaching the beginning. A valid share-link holder can repeatedly force every historical row to be fetched and decoded; Studio Lite polls this endpoint every five seconds. Evidence:
hub/src/web/routes/studios.ts:192, related contextweb/src/studio/StudioLiteApp.tsx:146
Suggested fix:const MAX_RAW_MESSAGES_SCANNED = 2_000 let scanned = 0 while (messages.length < 200 && scanned < MAX_RAW_MESSAGES_SCANNED) { const page = options.store.messages.getMessagesByPosition(room.sessionId, 200, before) scanned += page.length // existing projection/cursor logic }
A persisted public-message projection would avoid rescanning entirely.
-
[Minor] Moderating a paged suggestion duplicates the resolved row -
olderSuggestionskeeps the updated row, then the refreshed owner payload also includes it among the 50 resolved suggestions. Because the first-page cursor is unchanged for a decision beyond page 1, the reset effect does not run and React receives duplicate keys/items. Evidence:web/src/routes/studios/owner.tsx:129, related contextweb/src/routes/studios/owner.tsx:88
Suggested fix:if (result.post) { setOlderSuggestions((current) => current.filter((post) => post.id !== result.post!.id) ) } void refresh()
-
[Minor] Studio links ignore the configured Vite base path - both the generated share URL and the static 404 redirect are rooted at
/. On the documented/<repo>/standalone deployment, links leave the app scope and the 404 handler does not recognize/<repo>/studio/<token>. Evidence:web/src/studio/studioUrl.ts:14, related contextweb/public/404.html:8,web/README.md:186
Suggested fix:const appBase = new URL(import.meta.env.BASE_URL, window.location.origin) const page = new URL(`studio/${encodeURIComponent(token)}`, appBase)
Derive the 404 redirect prefix from the segment before
/studio/and target<prefix>/studio.html.
Questions
- None.
Summary
Review mode: follow-up after new commits
- Two major availability risks and two minor correctness/deployment regressions remain at the latest head.
Testing
- Not run (automation); PR code was not executed.
- GitHub
integrationandtestchecks passed. - Missing coverage: quota exhaustion recovery, bounded transcript scanning, resolving suggestions beyond page 1 without duplicates, and Studio links under a non-root Vite base.
HAPI Bot
| const limit = Math.max(1, Math.floor(maxPosts)) | ||
| return this.db.transaction(() => { | ||
| const row = this.db.prepare( | ||
| 'SELECT COUNT(*) AS count FROM studio_posts WHERE room_id = ?' |
There was a problem hiding this comment.
[MAJOR] Make the durable quota recoverable
This counts every historical post forever. The owner only receives the latest 200 discussions, and dismissed rows stay in that window; rotating or revoking/reopening the token also preserves all rows. A guest can therefore fill 2,000 discussions and permanently force 429 responses without DB intervention.
Suggested fix:
clearPosts(roomId: string): void {
this.db.prepare('DELETE FROM studio_posts WHERE room_id = ?').run(roomId)
}Expose the purge through an authenticated owner-only action with confirmation, and query open discussions in SQL so dismissing one page reveals the next.
|
|
||
| const messages: PublicStudioMessage[] = [] | ||
| let before: { at: number; seq: number } | undefined | ||
| while (messages.length < 200) { |
There was a problem hiding this comment.
[MAJOR] Bound raw transcript scanning
This loop can walk the entire session history when most rows are tool calls/results. Since the public client polls every five seconds, a share-link holder can repeatedly force all rows to be fetched and decoded.
Suggested fix:
const MAX_RAW_MESSAGES_SCANNED = 2_000
let scanned = 0
while (messages.length < 200 && scanned < MAX_RAW_MESSAGES_SCANNED) {
const page = options.store.messages.getMessagesByPosition(room.sessionId, 200, before)
scanned += page.length
// existing projection/cursor logic
}A persisted visible-message projection would remove the repeated scan entirely.
| api.decideStudioPost(studioId, input.postId, input), | ||
| onSuccess: (result) => { | ||
| if (result.post) { | ||
| setOlderSuggestions((current) => current.map((post) => post.id === result.post!.id ? result.post! : post)) |
There was a problem hiding this comment.
[MINOR] Remove the handled item from the paged cache
For a suggestion loaded beyond page 1, the first-page cursor usually stays unchanged. This mapped row remains in olderSuggestions, while the refresh also returns it in the resolved-suggestion list, producing duplicate items and React keys.
Suggested fix:
if (result.post) {
setOlderSuggestions((current) =>
current.filter((post) => post.id !== result.post!.id)
)
}
void refresh()| } | ||
|
|
||
| export function buildStudioShareUrl(pageOrigin: string, token: string, hubBaseUrl: string): string { | ||
| const page = new URL(`/studio/${encodeURIComponent(token)}`, pageOrigin) |
There was a problem hiding this comment.
[MINOR] Preserve the Vite base path
Rooting the URL at /studio/ breaks the documented standalone /<repo>/ deployment. The accompanying 404 handler is also anchored to root, so the generated link cannot recover through /<repo>/404.html.
Suggested fix:
const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
const page = new URL(`studio/${encodeURIComponent(token)}`, appBase)Update the 404 redirect to derive the prefix before /studio/ and redirect to <prefix>/studio.html.
There was a problem hiding this comment.
Findings
-
[Major] Bound the public post body before parsing JSON — this unauthenticated capability endpoint calls
c.req.json()before either schema validation or the rate limiter. The shared Bun server accepts request bodies up to 68 MiB, so anyone holding a valid share link can repeatedly send oversized invalid JSON that consumes memory/CPU without ever consuming a rate-limit slot. Evidence:hub/src/web/routes/studios.ts:224, related contexthub/src/web/server.ts:486
Suggested fix:import { bodyLimit } from 'hono/body-limit' const publicPostBodyLimit = bodyLimit({ maxSize: 8 * 1024, onError: (c) => c.json({ error: 'Post body too large' }, 413) }) app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => { // existing handler })
-
[Minor] Filter open discussions before applying the 200-row limit — the query selects the newest 200 discussions and only then removes dismissed rows. Every dismissal therefore shrinks the public/owner window without backfilling older open posts; after dismissing one page, still-open discussions can become unreachable except through the destructive “clear all” action. Evidence:
hub/src/web/routes/studios.ts:208, related contexthub/src/web/routes/studios.ts:116
Suggested fix:listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] { return (this.db.prepare(` SELECT * FROM studio_posts WHERE room_id = ? AND kind = 'discussion' AND status = 'open' ORDER BY created_at DESC, id DESC LIMIT ? `).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost) }
-
[Minor] Standalone subpath support is still incomplete — the dedicated Studio build hardcodes
/, so/hapi/studio.htmlreferences/studio-assets/...instead of/hapi/studio-assets/...; creating a Studio also navigates to root/studios/.... Both paths break the documented/<repo>/deployment even though generated share URLs now preserve the base. Evidence:web/vite.studio.config.ts:7, related contextweb/src/components/SessionHeader.tsx:363,web/README.md:186
Suggested fix:// vite.studio.config.ts: use the same configured base as the main build const base = process.env.VITE_BASE_URL || '/' export default defineConfig({ base, /* existing config */ }) // SessionHeader.tsx: keep navigation inside the Vite base const appBase = new URL(import.meta.env.BASE_URL, window.location.origin) window.location.assign(new URL(`studios/${result.room.id}`, appBase))
Questions
- None.
Summary
Review mode: follow-up after new commits
- One major availability/security risk and two minor correctness/deployment regressions remain at the latest head.
Testing
- Not run (automation; PR content was not executed).
- GitHub
integrationpassed;testwas pending at review time. - Missing coverage: oversized public post rejection before JSON parsing, open-discussion backfill after dismissals, and a production Studio build/navigation under a non-root Vite base.
HAPI Bot
| if (room.accessMode !== 'contribute') { | ||
| return c.json({ error: 'This studio is view only' }, 403) | ||
| } | ||
| const body = await c.req.json().catch(() => null) |
There was a problem hiding this comment.
[MAJOR] Bound the request body before JSON parsing
This public endpoint parses the body before validation or rate limiting, while the shared Bun server accepts up to 68 MiB. A valid share-link holder can repeatedly submit oversized invalid bodies; those requests consume memory/CPU and never consume a rate-limit slot.
Suggested fix:
import { bodyLimit } from 'hono/body-limit'
const publicPostBodyLimit = bodyLimit({
maxSize: 8 * 1024,
onError: (c) => c.json({ error: 'Post body too large' }, 413)
})
app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => {
// existing handler
})| } | ||
| const publicMessages = messages.slice(-200) | ||
| const publicPosts = options.store.studios | ||
| .listPostsByKind(room.id, 'discussion', 200) |
There was a problem hiding this comment.
[MINOR] Apply the status filter before the limit
Selecting the newest 200 discussions and then filtering dismissed rows means each dismissal permanently consumes a window slot. Older open discussions do not backfill, so they can disappear from both public and owner views while still stored as open.
Suggested fix:
listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] {
return (this.db.prepare(`
SELECT * FROM studio_posts
WHERE room_id = ? AND kind = 'discussion' AND status = 'open'
ORDER BY created_at DESC, id DESC
LIMIT ?
`).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost)
}|
|
||
| export default defineConfig({ | ||
| appType: 'spa', | ||
| base: '/', |
There was a problem hiding this comment.
[MINOR] Thread the configured base into the Studio build
This hardcoded / makes a subpath deployment's /hapi/studio.html load assets from /studio-assets/.... The creation flow also uses window.location.assign('/studios/...'), leaving the Vite base. The documented /<repo>/ deployment therefore remains broken.
Suggested fix:
const base = process.env.VITE_BASE_URL || '/'
export default defineConfig({ base, /* existing config */ })
const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
window.location.assign(new URL(`studios/${result.room.id}`, appBase))There was a problem hiding this comment.
Findings
-
[Major] Exclude sidechain/subagent output from the public transcript — the existing message pipeline marks subagent assistant turns with
data.isSidechain, but the new projector only removes meta and compact-summary rows before extracting assistant text. A shared link can therefore expose intermediate subagent prose, including file paths and findings that belong to owner-only Agent execution details. Evidence:hub/src/web/routes/studios.ts:100, related contextweb/src/chat/normalizeAgent.ts:237
Suggested fix:if (isObject(record.content)) { const data = isObject(record.content.data) ? record.content.data : null if ( data?.isMeta === true || data?.isCompactSummary === true || data?.isSidechain === true ) return null }
-
[Major] Bound the public post body before parsing JSON — this unauthenticated capability endpoint calls
c.req.json()before schema validation or rate limiting. The shared Bun server accepts request bodies up to 68 MiB, so a valid link holder can repeatedly submit oversized invalid bodies that consume memory/CPU without consuming a rate-limit slot. Evidence:hub/src/web/routes/studios.ts:224, related contexthub/src/web/server.ts:486
Suggested fix:import { bodyLimit } from 'hono/body-limit' const publicPostBodyLimit = bodyLimit({ maxSize: 8 * 1024, onError: (c) => c.json({ error: 'Post body too large' }, 413) }) app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => { // existing handler })
-
[Minor] Filter open discussions before applying the 200-row limit — the query selects the newest 200 discussions and only then removes dismissed rows. Dismissed rows consume the window, so older open discussions can disappear from both public and owner views while remaining stored as open. Evidence:
hub/src/web/routes/studios.ts:208
Suggested fix:listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] { return (this.db.prepare(` SELECT * FROM studio_posts WHERE room_id = ? AND kind = 'discussion' AND status = 'open' ORDER BY created_at DESC, id DESC LIMIT ? `).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost) }
-
[Minor] Thread the configured base through the Studio build and owner navigation — the dedicated build hardcodes
/, and Studio creation navigates to root/studios/.... A documented subpath deployment such as/hapi/therefore loads Studio assets and owner routes from the wrong location. Evidence:web/vite.studio.config.ts:7, related contextweb/src/components/SessionHeader.tsx:363,web/README.md:186
Suggested fix:const base = process.env.VITE_BASE_URL || '/' export default defineConfig({ base, /* existing config */ }) const appBase = new URL(import.meta.env.BASE_URL, window.location.origin) window.location.assign(new URL(`studios/${result.room.id}`, appBase))
Questions
- None.
Summary
Review mode: follow-up after new commits
- Two major privacy/availability risks and two minor correctness/deployment regressions remain at the latest head. The new commit only changes the guest-facing “Owner” label to “Host”; it does not resolve the prior findings.
Testing
- Not run (automation; PR content was not executed).
- GitHub
integrationandtestchecks passed at review time. - Missing coverage: sidechain transcript redaction, oversized public post rejection before JSON parsing, open-discussion backfill after dismissals, and a production Studio build/navigation under a non-root Vite base.
HAPI Bot
| if (record.role === 'user' && message.invokedAt === null) return null | ||
| if (isObject(record.content)) { | ||
| const data = isObject(record.content.data) ? record.content.data : null | ||
| if (data?.isMeta === true || data?.isCompactSummary === true) return null |
There was a problem hiding this comment.
[MAJOR] Exclude sidechain/subagent output from the public transcript
The established message format marks subagent assistant turns with data.isSidechain (see web/src/chat/normalizeAgent.ts:237). This projector only filters meta and compact-summary rows, so sharees can receive intermediate subagent prose such as file paths and findings that belong to owner-only Agent execution details.
Suggested fix:
if (
data?.isMeta === true
|| data?.isCompactSummary === true
|| data?.isSidechain === true
) return null| if (room.accessMode !== 'contribute') { | ||
| return c.json({ error: 'This studio is view only' }, 403) | ||
| } | ||
| const body = await c.req.json().catch(() => null) |
There was a problem hiding this comment.
[MAJOR] Bound the request body before JSON parsing
This public endpoint parses the body before validation or rate limiting, while the shared Bun server accepts up to 68 MiB. A valid share-link holder can repeatedly submit oversized invalid bodies; those requests consume memory/CPU and never consume a rate-limit slot.
Suggested fix:
import { bodyLimit } from 'hono/body-limit'
const publicPostBodyLimit = bodyLimit({
maxSize: 8 * 1024,
onError: (c) => c.json({ error: 'Post body too large' }, 413)
})
app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => {
// existing handler
})| } | ||
| const publicMessages = messages.slice(-200) | ||
| const publicPosts = options.store.studios | ||
| .listPostsByKind(room.id, 'discussion', 200) |
There was a problem hiding this comment.
[MINOR] Apply the status filter before the limit
Selecting the newest 200 discussions and then filtering dismissed rows means each dismissal consumes a window slot. Older open discussions do not backfill, so they can disappear from both public and owner views while still stored as open.
Suggested fix:
listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] {
return (this.db.prepare(`
SELECT * FROM studio_posts
WHERE room_id = ? AND kind = 'discussion' AND status = 'open'
ORDER BY created_at DESC, id DESC
LIMIT ?
`).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost)
}|
|
||
| export default defineConfig({ | ||
| appType: 'spa', | ||
| base: '/', |
There was a problem hiding this comment.
[MINOR] Thread the configured base into the Studio build
This hardcoded / makes a subpath deployment's /hapi/studio.html load assets from /studio-assets/.... Studio creation also navigates to root /studios/..., so the documented /<repo>/ deployment remains broken.
Suggested fix:
const base = process.env.VITE_BASE_URL || '/'
export default defineConfig({ base, /* existing config */ })
const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
window.location.assign(new URL(`studios/${result.room.id}`, appBase))There was a problem hiding this comment.
Findings
-
[Major] Exclude sidechain/subagent output from the public transcript — the projector filters meta and compact-summary rows, but the established Claude message format marks subagent assistant turns with
data.isSidechain.extractAssistantPlainTextstill extracts those turns, so a share link can expose intermediate subagent prose and owner-only execution details. Evidence:hub/src/web/routes/studios.ts:100, related contextweb/src/chat/normalizeAgent.ts:237
Suggested fix:if ( data?.isMeta === true || data?.isCompactSummary === true || data?.isSidechain === true ) return null
-
[Major] Bound the public post body before parsing JSON — this unauthenticated capability endpoint calls
c.req.json()before validation or rate limiting, while the shared Bun server accepts request bodies up to 68 MiB. Oversized invalid requests therefore consume memory/CPU without consuming a rate-limit slot. Evidence:hub/src/web/routes/studios.ts:224, related contexthub/src/web/server.ts:486
Suggested fix:import { bodyLimit } from 'hono/body-limit' const publicPostBodyLimit = bodyLimit({ maxSize: 8 * 1024, onError: (c) => c.json({ error: 'Post body too large' }, 413) }) app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => { // existing handler })
-
[Minor] Filter open discussions before applying the 200-row limit —
listPostsByKindlimits all discussions first and the route removes dismissed rows afterward. Dismissed rows consume the window, so older open discussions can disappear from public and owner views while still stored as open. Evidence:hub/src/store/studioStore.ts:199, related contexthub/src/web/routes/studios.ts:208
Suggested fix:listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] { return (this.db.prepare(` SELECT * FROM studio_posts WHERE room_id = ? AND kind = 'discussion' AND status = 'open' ORDER BY created_at DESC, id DESC LIMIT ? `).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost) }
-
[Minor] Preserve the configured Vite base for Studio assets and owner navigation — the dedicated Studio build hardcodes
/, and creation navigates to root/studios/.... A documented subpath deployment such as/hapi/therefore requests Studio assets and the owner route from the wrong location. Evidence:web/vite.studio.config.ts:7, related contextweb/src/components/SessionHeader.tsx:363,web/README.md:186
Suggested fix:const base = process.env.VITE_BASE_URL || '/' export default defineConfig({ base, /* existing config */ }) const appBase = new URL(import.meta.env.BASE_URL, window.location.origin) window.location.assign(new URL(`studios/${result.room.id}`, appBase))
Questions
- None.
Summary
Review mode: follow-up after new commits
- Two major privacy/availability risks and two minor correctness/deployment regressions remain at the latest head. The new commit changes Studio scrolling and guest suggestion UX but does not resolve the prior findings.
Testing
- Not run (automation; PR content was not executed).
- GitHub
integrationpassed;testwas still pending at review time. - Missing coverage: sidechain transcript redaction, oversized public post rejection before JSON parsing, open-discussion backfill after dismissals, and a production Studio build/navigation under a non-root Vite base.
HAPI Bot
| if (record.role === 'user' && message.invokedAt === null) return null | ||
| if (isObject(record.content)) { | ||
| const data = isObject(record.content.data) ? record.content.data : null | ||
| if (data?.isMeta === true || data?.isCompactSummary === true) return null |
There was a problem hiding this comment.
[MAJOR] Exclude sidechain/subagent output from the public transcript
The established Claude message format marks subagent assistant turns with data.isSidechain (web/src/chat/normalizeAgent.ts:237). This projector filters only meta and compact-summary rows, while extractAssistantPlainText still extracts sidechain prose, exposing intermediate owner-only execution details through the share link.
Suggested fix:
if (
data?.isMeta === true
|| data?.isCompactSummary === true
|| data?.isSidechain === true
) return null| if (room.accessMode !== 'contribute') { | ||
| return c.json({ error: 'This studio is view only' }, 403) | ||
| } | ||
| const body = await c.req.json().catch(() => null) |
There was a problem hiding this comment.
[MAJOR] Bound the request body before JSON parsing
This unauthenticated endpoint parses the body before validation or rate limiting, while hub/src/web/server.ts:486 accepts up to 68 MiB. Oversized invalid requests consume memory/CPU and never consume a rate-limit slot.
Suggested fix:
import { bodyLimit } from 'hono/body-limit'
const publicPostBodyLimit = bodyLimit({
maxSize: 8 * 1024,
onError: (c) => c.json({ error: 'Post body too large' }, 413)
})
app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => {
// existing handler
})| const params = limit === null ? [roomId, kind] : [roomId, kind, limit] | ||
| const rows = this.db.prepare(` | ||
| SELECT * FROM studio_posts | ||
| WHERE room_id = ? AND kind = ? |
There was a problem hiding this comment.
[MINOR] Apply the status filter before the limit
The route requests the newest 200 discussions and then removes dismissed rows. Those dismissed rows consume the window, so older open discussions do not backfill and can disappear from both public and owner views.
Suggested fix:
listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] {
return (this.db.prepare(`
SELECT * FROM studio_posts
WHERE room_id = ? AND kind = 'discussion' AND status = 'open'
ORDER BY created_at DESC, id DESC
LIMIT ?
`).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost)
}|
|
||
| export default defineConfig({ | ||
| appType: 'spa', | ||
| base: '/', |
There was a problem hiding this comment.
[MINOR] Preserve the configured base in the Studio build
This hardcoded / makes a subpath deployment such as /hapi/ load Studio assets from /studio-assets/.... Creation also navigates to root /studios/... at web/src/components/SessionHeader.tsx:363, so the owner route is misplaced too.
Suggested fix:
const base = process.env.VITE_BASE_URL || '/'
export default defineConfig({ base, /* existing config */ })
const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
window.location.assign(new URL(`studios/${result.room.id}`, appBase))There was a problem hiding this comment.
Findings
-
[Major] Exclude sidechain/subagent output from the public transcript — the projector filters meta and compact-summary rows, but Claude marks subagent assistant turns with
data.isSidechain.extractAssistantPlainTextstill extracts those turns, so a share link can expose intermediate subagent prose and owner-only execution details. Evidence:hub/src/web/routes/studios.ts:100, related contextweb/src/chat/normalizeAgent.ts:237
Suggested fix:if ( data?.isMeta === true || data?.isCompactSummary === true || data?.isSidechain === true ) return null
-
[Major] Bound the public post body before parsing JSON — this unauthenticated capability endpoint calls
c.req.json()before validation or rate limiting, while the Bun server accepts request bodies up to 68 MiB. Oversized invalid requests therefore consume memory and CPU without consuming a rate-limit slot. Evidence:hub/src/web/routes/studios.ts:224, related contexthub/src/web/server.ts:486
Suggested fix:import { bodyLimit } from 'hono/body-limit' const publicPostBodyLimit = bodyLimit({ maxSize: 8 * 1024, onError: (c) => c.json({ error: 'Post body too large' }, 413) }) app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => { // existing handler })
-
[Major] Prevent guest names from adding hidden instructions to approved suggestions —
authorNameaccepts control characters, and the owner UI renders it as collapsed single-line text, but submission interpolates the raw value into the agent message. A guest can use a newline in the name to append prompt content that is not present in the editable suggestion textarea the owner approves. Evidence:hub/src/web/routes/studios.ts:348, related validationhub/src/web/routes/studios.ts:27
Suggested fix:const authorNameSchema = z.string() .trim() .min(1) .max(40) .refine((value) => [...value].every((character) => { const code = character.charCodeAt(0) return code >= 32 && code !== 127 })) const postSchema = z.object({ // ... authorName: authorNameSchema })
-
[Minor] Filter open discussions before applying the 200-row limit —
listPostsByKindlimits all discussions first and the route removes dismissed rows afterward. Dismissed rows consume the window, so older open discussions can disappear from public and owner views while still stored as open. Evidence:hub/src/store/studioStore.ts:199, related contexthub/src/web/routes/studios.ts:208
Suggested fix:listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] { return (this.db.prepare(` SELECT * FROM studio_posts WHERE room_id = ? AND kind = 'discussion' AND status = 'open' ORDER BY created_at DESC, id DESC LIMIT ? `).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost) }
-
[Minor] Preserve the configured Vite base for Studio assets and owner navigation — the dedicated Studio build hardcodes
/, and creation navigates to root/studios/.... A documented subpath deployment such as/hapi/therefore requests Studio assets and the owner route from the wrong location. Evidence:web/vite.studio.config.ts:7, related contextweb/src/components/SessionHeader.tsx:363,web/README.md:186
Suggested fix:const base = process.env.VITE_BASE_URL || '/' export default defineConfig({ base, /* existing config */ }) const appBase = new URL(import.meta.env.BASE_URL, window.location.origin) window.location.assign(new URL(`studios/${result.room.id}`, appBase))
Questions
- None.
Summary
Review mode: follow-up after new commits
- The new commit only adjusts Studio scrolling CSS. Four prior findings remain unresolved, and the latest full-diff review found one additional moderation-boundary issue in suggestion attribution.
Testing
- Not run (automation; PR content was not executed).
- GitHub
integrationpassed;testwas pending at review time. - Missing coverage: sidechain transcript redaction, oversized public post rejection before JSON parsing, control-character guest names, open-discussion backfill after dismissals, and a production Studio build/navigation under a non-root Vite base.
HAPI Bot
| if (record.role === 'user' && message.invokedAt === null) return null | ||
| if (isObject(record.content)) { | ||
| const data = isObject(record.content.data) ? record.content.data : null | ||
| if (data?.isMeta === true || data?.isCompactSummary === true) return null |
There was a problem hiding this comment.
[MAJOR] Exclude sidechain/subagent output from the public transcript
Claude marks subagent assistant turns with data.isSidechain; the shared extractor still returns their text. A Studio capability link can therefore expose intermediate subagent prose and execution details that the normal chat pipeline treats separately.
Suggested fix:
if (
data?.isMeta === true
|| data?.isCompactSummary === true
|| data?.isSidechain === true
) return null| if (room.accessMode !== 'contribute') { | ||
| return c.json({ error: 'This studio is view only' }, 403) | ||
| } | ||
| const body = await c.req.json().catch(() => null) |
There was a problem hiding this comment.
[MAJOR] Bound the public post body before parsing JSON
This unauthenticated endpoint parses the body before validation or rate limiting, while the shared Bun server permits requests up to 68 MiB. Oversized invalid bodies can consume memory and CPU without consuming a rate-limit slot.
Suggested fix:
import { bodyLimit } from 'hono/body-limit'
const publicPostBodyLimit = bodyLimit({
maxSize: 8 * 1024,
onError: (c) => c.json({ error: 'Post body too large' }, 413)
})
app.post('/public/studios/:token/posts', publicPostBodyLimit, async (c) => {
// existing handler
})| if (!claimed) return c.json({ error: 'Suggestion already handled' }, 409) | ||
| try { | ||
| await engine.sendMessage(room.sessionId, { | ||
| text: `[Studio suggestion from ${post.authorName}]\n${text}`, |
There was a problem hiding this comment.
[MAJOR] Prevent guest names from adding hidden prompt lines
authorName accepts control characters. The owner UI collapses the name to a single line, but this interpolation preserves newlines, so a guest can append agent instructions that are absent from the editable suggestion textarea the owner approves.
Suggested fix:
const authorNameSchema = z.string()
.trim()
.min(1)
.max(40)
.refine((value) => [...value].every((character) => {
const code = character.charCodeAt(0)
return code >= 32 && code !== 127
}))
const postSchema = z.object({
// ...
authorName: authorNameSchema
})| const params = limit === null ? [roomId, kind] : [roomId, kind, limit] | ||
| const rows = this.db.prepare(` | ||
| SELECT * FROM studio_posts | ||
| WHERE room_id = ? AND kind = ? |
There was a problem hiding this comment.
[MINOR] Filter open discussions before applying the limit
The route filters dismissed discussions only after this query has selected the newest 200 rows. Dismissed rows consume that window, causing older still-open discussions to disappear from public and owner views.
Suggested fix:
listOpenDiscussions(roomId: string, limit = 200): StoredStudioPost[] {
return (this.db.prepare(`
SELECT * FROM studio_posts
WHERE room_id = ? AND kind = 'discussion' AND status = 'open'
ORDER BY created_at DESC, id DESC
LIMIT ?
`).all(roomId, limit) as StudioPostRow[]).reverse().map(mapPost)
}|
|
||
| export default defineConfig({ | ||
| appType: 'spa', | ||
| base: '/', |
There was a problem hiding this comment.
[MINOR] Preserve the configured base in the Studio build
This hardcoded root makes documented subpath deployments such as /hapi/ load Studio assets from /studio-assets/.... Creation also navigates to root /studios/... in SessionHeader.tsx.
Suggested fix:
const base = process.env.VITE_BASE_URL || '/'
export default defineConfig({ base, /* existing config */ })
const appBase = new URL(import.meta.env.BASE_URL, window.location.origin)
window.location.assign(new URL(`studios/${result.room.id}`, appBase))
Summary
/studio/<token>through a dedicated Studio Lite entry instead of the full HAPI PWAValidation
/sessionsentry remains unchangedNotes
The full web typecheck in the local PR worktree was affected by incomplete dependency installation; the only known upstream baseline issue in the deployment worktree is the existing duplicate property in
web/src/components/assistant-ui/markdown-a.test.tsx:64.