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

6 Commits

Author SHA1 Message Date
vvzvlad
0c46f60ddf Merge gitea/develop into feat/public-share-assistant
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>
2026-06-20 18:40:58 +03:00
vvzvlad
90e9b0a3f4 docs(public-share): document trusted-proxy XFF requirement + cost cap
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>
2026-06-20 18:34:16 +03:00
claude code agent 227
9aff427ad8 harden(public-share): sliding cluster-wide token cap; testable access seam
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>
2026-06-20 15:04:26 +03:00
claude code agent 227
932a4080f7 fix(public-share): block restricted descendants in the anonymous assistant
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>
2026-06-20 13:16:32 +03:00
claude code agent 227
b7b1fb773e docs: remove implemented public-share-assistant plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:59:56 +03:00
claude code agent 227
acf3df9e9d feat(ai): anonymous AI assistant on public shares
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>
2026-06-20 07:59:56 +03:00