feat(ai): anonymous AI assistant on public shares #14

Merged
Ghost merged 6 commits from feat/public-share-assistant into develop 2026-06-20 18:41:17 +03:00

Implements docs/public-share-assistant-plan.md (fixed scope).

What

An anonymous AI assistant on public shares: an unauthenticated viewer of a published share can ask an AI that answers strictly from that share's page tree ("chat with these docs"). The authenticated agent is untouched; no DB / no migration / nothing persisted.

How

Server

  • Gating: workspace toggle settings.ai.publicShareAssistant (default off) + optional settings.ai.provider.publicShareChatModel (a cheap model id only — driver/baseUrl/apiKey reuse the main chat provider; empty → falls back to chatModel). getChatModel(workspaceId, override) substitutes only the model id.
  • POST /api/shares/ai/stream (@Public, SSE via pipeUIMessageStreamToResponse). Workspace resolved from the host (DomainMiddleware) — no main.ts change.
  • Guardrail funnel (each exits before streaming, uniform 404s avoid confirming private pages): toggle off → 404; share missing / wrong workspace / sharing disabled → 404; pageId not in the share tree → 404; provider unconfigured → 503; rate limited → 429.
  • forShare read-only toolset (in-process, no identity, no loopback token): searchSharePages (the existing shareId && !spaceId && !userId FTS branch — restricted descendants excluded), getSharePage (gated by getShareForPage + share.id check, content via the public sanitizer: comment marks stripped, attachments tokenized), listSharePages. No write/comment/history/cross-space/external-MCP tools.
  • Locked share system prompt + immutable safety block; stepCountIs(5).
  • /shares/page-info exposes an aiAssistant flag (only after the isSharingAllowed gate).

Client: an ephemeral, text-only Ask-AI widget on the public shared page (shown only when the flag is set), useChat/api/shares/ai/stream, credentials: 'omit'. Admin toggle + model field in Settings → AI.

Reasoning / decisions

  • The boundary is tool scope, not identity (anonymous has none) — every tool re-derives scope server-side from shareId/workspaceId, so a malicious transcript can't widen it.
  • Cheap separate model because the workspace owner pays for anonymous tokens; read-only Q&A doesn't need the flagship model.
  • Ephemeral storage sidesteps ai_chats.creator_id NOT NULL → no migration.

Review findings & fixes (adversarial security review)

The review confirmed no leakage outside the share tree (verified each tool/tenant/funnel gate with code evidence; tried shareId/pageId swaps, slugId of another share, injected messages — all rejected) and approved the jest mapper. Fixed its findings:

  • WARNING (token-bill DoS): the per-IP throttle (5/min) is evadable via X-Forwarded-For spoofing under trustProxy, letting an attacker run up the owner's AI bill. Added a second, IP-independent per-workspace cap (300/hour, in-memory, checked after access gates / before streaming → 429) so the owner's bill is bounded even if per-IP is evaded; documented the trusted-proxy requirement. Unit-tested.
  • Nits: funnel step-comment renumbering; listSharePages root title fixed; self-documenting comment that the model override is id-only.

Verification

  • pnpm --filter server build + pnpm --filter client build — clean.
  • pnpm --filter server test -- public-share-chat16 pass (funnel ordering + 404/503 uniformity, prompt lock, model fallback, forShare scoping, per-workspace limiter threshold/window).
  • Browser (headless Chromium, live z.ai), anonymous context (no cookies): published a share for a page with a unique fact; the share rendered without login and showed the Ask-AI widget; asked a question → POST /api/shares/ai/stream 200, streamed a reply containing the page's exact facts ("42 kelvins"/"moonstone") → grounded in the shared content; a "list all workspace pages" probe was refused (no leak); with the toggle OFF the widget is hidden and the endpoint returns 404. No app errors. Screenshot captured.

🤖 Generated with Claude Code

Implements `docs/public-share-assistant-plan.md` (fixed scope). ## What An anonymous AI assistant on **public shares**: an unauthenticated viewer of a published share can ask an AI that answers strictly from that share's page tree ("chat with these docs"). The authenticated agent is untouched; **no DB / no migration / nothing persisted**. ## How **Server** - Gating: workspace toggle `settings.ai.publicShareAssistant` (default off) + optional `settings.ai.provider.publicShareChatModel` (a cheap **model id only** — driver/baseUrl/apiKey reuse the main chat provider; empty → falls back to `chatModel`). `getChatModel(workspaceId, override)` substitutes only the model id. - `POST /api/shares/ai/stream` (`@Public`, SSE via `pipeUIMessageStreamToResponse`). Workspace resolved from the host (`DomainMiddleware`) — no `main.ts` change. - **Guardrail funnel** (each exits before streaming, uniform 404s avoid confirming private pages): toggle off → 404; share missing / wrong workspace / sharing disabled → 404; `pageId` not in the share tree → 404; provider unconfigured → 503; rate limited → 429. - **`forShare` read-only toolset** (in-process, no identity, no loopback token): `searchSharePages` (the existing `shareId && !spaceId && !userId` FTS branch — restricted descendants excluded), `getSharePage` (gated by `getShareForPage` + `share.id` check, content via the public sanitizer: comment marks stripped, attachments tokenized), `listSharePages`. No write/comment/history/cross-space/external-MCP tools. - Locked share system prompt + immutable safety block; `stepCountIs(5)`. - `/shares/page-info` exposes an `aiAssistant` flag (only after the `isSharingAllowed` gate). **Client:** an ephemeral, **text-only** Ask-AI widget on the public shared page (shown only when the flag is set), `useChat` → `/api/shares/ai/stream`, `credentials: 'omit'`. Admin toggle + model field in Settings → AI. ## Reasoning / decisions - The boundary is **tool scope, not identity** (anonymous has none) — every tool re-derives scope server-side from `shareId`/`workspaceId`, so a malicious transcript can't widen it. - **Cheap separate model** because the workspace owner pays for anonymous tokens; read-only Q&A doesn't need the flagship model. - Ephemeral storage sidesteps `ai_chats.creator_id NOT NULL` → no migration. ## Review findings & fixes (adversarial security review) The review **confirmed no leakage** outside the share tree (verified each tool/tenant/funnel gate with code evidence; tried shareId/pageId swaps, slugId of another share, injected messages — all rejected) and approved the jest mapper. Fixed its findings: - **WARNING (token-bill DoS):** the per-IP throttle (5/min) is evadable via `X-Forwarded-For` spoofing under `trustProxy`, letting an attacker run up the owner's AI bill. Added a **second, IP-independent per-workspace cap** (300/hour, in-memory, checked after access gates / before streaming → 429) so the owner's bill is bounded even if per-IP is evaded; documented the trusted-proxy requirement. Unit-tested. - Nits: funnel step-comment renumbering; `listSharePages` root title fixed; self-documenting comment that the model override is id-only. ## Verification - `pnpm --filter server build` + `pnpm --filter client build` — clean. - `pnpm --filter server test -- public-share-chat` — **16 pass** (funnel ordering + 404/503 uniformity, prompt lock, model fallback, `forShare` scoping, per-workspace limiter threshold/window). - Browser (headless Chromium, live z.ai), **anonymous context (no cookies)**: published a share for a page with a unique fact; the share rendered without login and showed the Ask-AI widget; asked a question → `POST /api/shares/ai/stream` 200, streamed a reply containing the page's exact facts ("42 kelvins"/"moonstone") → grounded in the shared content; a "list all workspace pages" probe was refused (no leak); with the toggle OFF the widget is hidden and the endpoint returns 404. No app errors. Screenshot captured. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 2 commits 2026-06-20 08:00:31 +03:00
Lets an unauthenticated viewer of a published share ask an AI scoped strictly
to that share's page tree. The authenticated agent is untouched; the security
boundary is the tool scope (no identity), and nothing is persisted.

Server:
- workspace toggle settings.ai.publicShareAssistant (default off) +
  optional settings.ai.provider.publicShareChatModel (cheap model id; reuses
  the chat driver/baseUrl/key). getChatModel(workspaceId, override) substitutes
  only the model id, falling back to chatModel.
- POST /api/shares/ai/stream (@Public, SSE). Guardrail funnel, each failing
  before streaming: toggle off -> 404; share missing/wrong-workspace/sharing
  off -> 404; pageId not in share tree -> 404; provider unconfigured -> 503;
  per-IP (5/min) and per-workspace (300/h, IP-independent) rate limits -> 429.
  Uniform 404s never confirm a private page's existence.
- forShare read-only in-process toolset: searchSharePages (existing shareId
  FTS branch, no spaceId/userId), getSharePage (getShareForPage gate +
  share.id check, content via the public sanitizer), listSharePages. No write/
  comment/history/cross-space/external-MCP tools.
- Locked share system prompt + immutable safety block; stepCountIs(5).
- /shares/page-info exposes an aiAssistant flag (gated behind isSharingAllowed).

Client: an ephemeral, text-only Ask-AI widget on the public shared page,
shown only when the flag is set; useChat -> /api/shares/ai/stream,
credentials omit. Admin toggle + model field in Settings -> AI.

Also adds a jest moduleNameMapper for src/-rooted imports (fixes pre-existing
unresolvable specs; additive).

Implements docs/public-share-assistant-plan.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-20 13:16:44 +03:00
Release-cycle red-team found getShareForPage joins only the shares table, so it
does not exclude restricted descendants. The public share VIEW (getSharedPage)
compensates with hasRestrictedAncestor, but the assistant's getSharePage tool
and the controller funnel did not — so an anonymous caller could read a
restricted descendant's content (tool) or surface its title into the system
prompt (funnel) within an includeSubPages share.

- getSharePage: after the share-membership check and before returning content,
  reject with the generic 'not part of this published share' message when
  hasRestrictedAncestor(page.id) is true (page.id is the resolved UUID, so
  slugId inputs work). Inject PagePermissionRepo.
- funnel: resolve the OPENED page to its UUID and treat a restricted opened page
  as not-in-share (same uniform 404, fail closed if unresolvable) so its title
  never reaches buildShareSystemPrompt.
search/list already exclude restricted subtrees (getPageAndDescendantsExcludingRestricted),
so these were the only two bypasses. Generic messages keep restricted
indistinguishable from not-in-share.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-20 15:04:38 +03:00
Release-cycle review: the per-workspace cost cap was fixed-window + per-instance
(allowed ~2x at a window boundary and K*cap behind K instances) on an anonymous
endpoint that spends the owner's provider budget. Rewrite it as a sliding-window,
CLUSTER-WIDE Redis limiter: one atomic Lua EVAL does ZREMRANGEBYSCORE (age out)
-> ZCARD -> ZADD with PEXPIRE, so concurrent instances share one budget and the
true rate over any trailing window is <= cap. Fails OPEN on a Redis error (logged)
— it's a cost backstop, not access control (the funnel gates + per-IP throttle
still apply), so a Redis blip must not take the assistant offline. Per-IP @Throttle
kept; commented that it needs an XFF-rewriting trusted proxy to be meaningful.

Extract deriveShareAccess (resolvedShareId===requestedShareId + isSharingAllowed +
!restricted, equality-only, never widening) and filterShareTranscript into pure
helpers, and add tests: limiter sliding-window + boundary-burst + fail-open;
access derivation; and red-team boundary locks (cross-share/cross-workspace swap
rejected, forged shareId can't widen tool scope, transcript injection filtered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad added 1 commit 2026-06-20 18:34:29 +03:00
The anonymous public-share AI assistant's per-IP rate limit is only
effective behind a trusted reverse proxy that overwrites X-Forwarded-For
with the real client IP (the app runs with trustProxy). Document this
deployment requirement and the per-workspace cost backstop env var
(SHARE_AI_WORKSPACE_MAX_PER_HOUR, default 300) in .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad added 1 commit 2026-06-20 18:41:11 +03:00
Resolve conflicts with the independently-merged ai-agent-roles feature:
- ai-chat.module.ts: keep BOTH AiAgentRolesModule and the public-share
  wiring (Share/Search modules, PublicShareChatController, services).
- ai.service.ts: take develop's getChatModel ChatModelOverride superset,
  which already covers the public-share model-id-only override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost merged commit c718b2a6de into develop 2026-06-20 18:41:17 +03:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#14