@voltro/plugin-chat 0.0.0-stage → 0.87.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 (50) hide show
  1. package/CHANGELOG.md +24329 -0
  2. package/LICENSE +61 -0
  3. package/README.md +516 -2
  4. package/SECURITY.md +56 -0
  5. package/THIRD-PARTY-NOTICES.md +14931 -0
  6. package/dist/admissions.entity.js +11 -0
  7. package/dist/channels.entity.js +20 -0
  8. package/dist/chat-app/events/chat.mentioned.event.ts +1 -0
  9. package/dist/chat-app/events/chat.messageChanged.event.ts +1 -0
  10. package/dist/chat-app/events/chat.typing.event.ts +1 -0
  11. package/dist/chat-app/schedules/chat.retention.cron.tsx +16 -0
  12. package/dist/chat-app/subscribers/chat.messages.subscribe.ts +4 -0
  13. package/dist/components.js +432 -0
  14. package/dist/contract.js +92 -0
  15. package/dist/errors.d.ts +13 -0
  16. package/dist/errors.js +21 -0
  17. package/dist/eventProducer.d.ts +18 -0
  18. package/dist/eventProducer.js +46 -0
  19. package/dist/events.d.ts +38 -0
  20. package/dist/events.js +36 -0
  21. package/dist/evidence.entity.js +25 -0
  22. package/dist/index.d.ts +623 -0
  23. package/dist/index.js +181 -0
  24. package/dist/inspect.js +38 -0
  25. package/dist/media.entity.js +15 -0
  26. package/dist/members.entity.js +22 -0
  27. package/dist/mentionDraft.js +21 -0
  28. package/dist/mentionNotifications.js +110 -0
  29. package/dist/messages.entity.js +28 -0
  30. package/dist/reportEvidence.js +16 -0
  31. package/dist/retention.entity.js +11 -0
  32. package/dist/rpc.d.ts +295 -0
  33. package/dist/rpc.js +267 -0
  34. package/dist/rpcRoutes.js +97 -0
  35. package/dist/server.d.ts +200 -0
  36. package/dist/server.js +7 -0
  37. package/dist/service.js +1412 -0
  38. package/dist/strategies.d.ts +148 -0
  39. package/dist/strategies.js +90 -0
  40. package/dist/testing.d.ts +139 -0
  41. package/dist/testing.js +111 -0
  42. package/dist/typing.js +88 -0
  43. package/dist/typingInvalidation.js +86 -0
  44. package/dist/typingLease.js +59 -0
  45. package/dist/typingPublisher.js +59 -0
  46. package/dist/useChatTypingPublisher.js +33 -0
  47. package/dist/useTyping.js +24 -0
  48. package/dist/web.d.ts +354 -0
  49. package/dist/web.js +19 -0
  50. package/package.json +98 -4
package/LICENSE ADDED
@@ -0,0 +1,61 @@
1
+ Voltro — Proprietary Software License
2
+
3
+ Copyright (c) 2026 Voltro UG. All rights reserved.
4
+
5
+ This software and all associated source code, documentation, and other
6
+ materials (the "Software") are the proprietary property of Voltro UG
7
+ ("Voltro"). This is NOT open-source software.
8
+
9
+ Your access to and use of the Software is governed exclusively by the Voltro
10
+ Terms of Service at:
11
+
12
+ https://voltro.cloud/legal/terms
13
+
14
+ By downloading, installing, or using the Software you agree to those Terms.
15
+ Subject to and conditioned on your continued compliance with them, Voltro
16
+ grants you a limited, non-exclusive, non-transferable, non-sublicensable,
17
+ revocable license to use the Software. The license covers use by your
18
+ authorised named users only, up to the number of seats (or other billing metric)
19
+ your plan or order covers; each named user must be an identified individual
20
+ using their own personal account, and accounts, access credentials and seats
21
+ must not be shared. All rights not expressly granted in the
22
+ Terms are reserved by Voltro. If you do not agree to the Terms, you have no
23
+ license and may not use the Software.
24
+
25
+ The Terms of Service — including the scope of permitted use, plan
26
+ entitlements, fees, and pricing — may change from time to time. The version in
27
+ force is the one published at the URL above. Commercial terms and pricing are
28
+ NOT part of this notice and are not fixed by, or granted in perpetuity by, the
29
+ version of the Software in which this file appears.
30
+
31
+ Permitted use — your own software. Voltro is a development framework. You may
32
+ use the Software to build, run, and commercially distribute your own
33
+ applications and services, and to charge your own customers for them, without
34
+ owing Voltro any share of that revenue or any further permission beyond the
35
+ Terms. Applications you build with the Software are yours.
36
+
37
+ Prohibited use — reselling Voltro. You may NOT redistribute, resell,
38
+ sublicense, publish, or otherwise provide the Software itself — in whole or in
39
+ part, in source or compiled form — to third parties as a library, framework,
40
+ SDK, template, starter, or development tool for them to build upon, whether
41
+ standalone or repackaged. Shipping the Software as a non-separable, bundled
42
+ dependency inside an application you deliver (for example, compiled into your
43
+ deployed app) is permitted; extracting or re-exposing it as a reusable
44
+ component for others is not.
45
+
46
+ Except as expressly permitted above or by the Terms, you may not copy, modify,
47
+ merge, publish, sublicense, sell, distribute, or create derivative works of the
48
+ Software, in whole or in part.
49
+
50
+ Third-party open-source components distributed alongside the Software are the
51
+ property of their respective owners and are licensed under their own terms; see
52
+ THIRD-PARTY-NOTICES.md where provided. Nothing in this notice limits your
53
+ rights, or extends Voltro's, in those components.
54
+
55
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
56
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
57
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL
58
+ VOLTRO BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM,
59
+ OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR ITS USE.
60
+
61
+ For licensing inquiries, contact Voltro UG.
package/README.md CHANGED
@@ -1,3 +1,517 @@
1
- # Temporary Holding Version
1
+ # @voltro/plugin-chat
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ## Take
4
+
5
+ ```ts
6
+ import { chatPlugin } from '@voltro/plugin-chat'
7
+
8
+ const plugin = chatPlugin()
9
+ ```
10
+
11
+ The factory contributes `_voltro_chat_channels`, `_voltro_chat_members` and
12
+ `_voltro_chat_messages` plus server-owned `_voltro_chat_retention` sweep progress, eighteen typed RPCs, seventeen client hooks, committed message
13
+ metadata events, configuration inspection and user/guest export/erase hooks.
14
+ SQL owns membership, history, slow-mode admission and read receipts. Verified
15
+ human/guest subjects use stable IDs; tenant and acting identity come from the
16
+ request. Anonymous and machine self calls are refused.
17
+
18
+ Public actions are `chat.setTyping`, `dm`, `send`, `edit`, `remove`, `markRead`, `mute` and
19
+ `configure`, `original`, `leave`, `transferOwnership`; queries are `chat.messages`, `channel`, `membership`, `channels`, `unread`. A message is an
20
+ intention with `channelId`, `requestId`, `body`, optional `replyToId`; it never
21
+ accepts an author, tenant or arbitrary stored row. The request ID deterministically
22
+ identifies the committed sender intention. An exact retry reads its SQL receipt
23
+ before consuming rates again; changed content with the same ID refuses `conflict`.
24
+ Edits/deletes require `baseVersion`; only the author edits and an author or
25
+ same-channel mod/owner deletes. Normal reads hide deleted bodies and server-only
26
+ original text. The role-gated original action is written; moderator history UI remains open.
27
+
28
+ ## Configure
29
+
30
+ | Option | Default | Meaning |
31
+ |---|---|---|
32
+ | `maxBodyLength` | 2,000 code points | Message input budget |
33
+ | `pageSize` | 50 | Maximum raw history rows per page |
34
+ | `maxMembers` | 500 | Server channel-creation member limit |
35
+ | `maxChannelsPerSubject` | 100 | Channel-list read budget, not global membership admission |
36
+ | `retentionMs` | 7 days | Default channel history window; null means no configured expiry |
37
+ | `perChannelRate` | 2/s | Sender/channel attempt budget |
38
+ | `globalRate` | 10/s | Sender attempt budget across channels |
39
+ | `maxMentions` | 20 | Resolved mention budget |
40
+ | `maxExportRows` | 10,000 | Complete bounded export; overflow refuses |
41
+ | `denyWords` | empty | Explicit keyword policy; empty is no denylist coverage |
42
+ | `moderationAction` | `block` | Refuse flagged content, or persist under `flag` |
43
+ | `strategies` | four defaults | Named replacement rule slots |
44
+ | `rateLimit` | Registry Redis | Existing limiter connection/HotStore options |
45
+ | `now` | `Date.now` | Clock for rules and observations |
46
+
47
+ These are configuration budgets, not measured throughput. The limiter reuses the
48
+ Registry-backed HotStore adapter from `plugin-ratelimit`; the app owns connections.
49
+ Missing activation, backend errors and fail-open evidence refuse sends rather
50
+ than silently allowing them. Explicit injected HotStore state refuses activation
51
+ when deployment evidence declares multiple nodes. Use the Registry Redis path
52
+ for such deployments. This plugin opens no independent Redis connection.
53
+
54
+ Channel `slowModeMs` and member `mutedUntil` are authoritative SQL values.
55
+ Send admission writes the channel fence before reading membership/time, serializing
56
+ same-channel writes across stores. Mod/owner roles control mute/configure; ordinary
57
+ members cannot freeze a channel or mute another person. This conservative SQL
58
+ serialization has not been measured under load.
59
+
60
+ ## Hook
61
+
62
+ The shipped source directory discovers a guarded `chat.messageChanged` event and
63
+ post-commit producer. Its routing key is `channelId`; payload carries only
64
+ `messageId`, `version`, `change`. Text, author identity and media are absent.
65
+ The listener scope is `chat:events:read`, for operators, not an authorization
66
+ shortcut for players. The producer selects tenant from committed rows and
67
+ suppresses unchanged-version admission writes. CDC delivery is best-effort,
68
+ not a durable archive or business event.
69
+
70
+ Normal player messages use a live query over message, channel, membership and
71
+ Social-relation triggers. The handler verifies actual membership/tenant and
72
+ filters authors blocked in either direction through `SocialBlockPort` before
73
+ returning rows. Without a Social port there is no block policy to evaluate;
74
+ DM creation refuses without that port. `canMessage` admits an existing friend
75
+ or the recipient request policy, after block checks. Party/guild/lobby channels
76
+ must be provisioned by trusted server integrations.
77
+
78
+ `dataStoreChat(...).createChannel` permits only an internal system subject or a
79
+ same-tenant service account with `chat:channels:write`. Its input owns kind, ref,
80
+ name, members and roles. A cold create inserts transactionally; after a lost race
81
+ or reply, only the exact SQL creation claim matches. Direct DM intentions construct
82
+ one canonical pair channel after recipient admission. Retention uses bounded persisted sweep steps through `ChatAdministration.prune`
83
+ and the contributed `chat.pruneHistory` schedule; details below. Install `plugin-audit` with its durable sink to audit
84
+ RPC actions. Chat does not claim to have built an independent audit trail.
85
+
86
+ ## Channel lifecycle and match archive
87
+
88
+ This additional source is built. `ChatAdministration` in
89
+ `@voltro/plugin-chat/server` is provided by the installing plugin's normal
90
+ service Layer. It borrows the actual bound SQL store; no independent connection
91
+ is opened. `dataStoreChat` exposes the same administration methods for explicit
92
+ server adapters.
93
+
94
+ `addMember` and `removeMember` accept only an internal system identity or a
95
+ same-tenant service account holding `chat:members:write`. Their inputs contain
96
+ `tenantId`, `channelId`, `subjectId`; adding also requires `role: 'member' | 'mod'`.
97
+ The transaction acquires the channel writer fence before reading the roster cap.
98
+ An exact existing role is a no-op; role replacement refuses `conflict`. Adding
99
+ an owner is not allowed. DM membership cannot change; archived match rosters do
100
+ not admit new members. Existing owners cannot be removed through this operation.
101
+ The global `maxChannelsPerSubject` option remains a read budget; it does not
102
+ claim to enforce concurrent admission across different channels.
103
+
104
+ `chat.leave({ channelId })` uses the authenticated human/guest identity; it is
105
+ repeatable after removal. Owners must first call
106
+ `chat.transferOwnership({ channelId, targetId })`, which requires an existing
107
+ unmuted member and exactly one current owner. Demotion and promotion share the
108
+ same SQL transaction. DM leave/transfer refuses. `useChatLeave` and
109
+ `useChatTransferOwnership` expose these intentions through the existing mutation
110
+ client. Subsequent channel/history/send calls re-check membership. This source
111
+ has not established revocation of already buffered queries or active event streams.
112
+
113
+ `archiveMatch(subject, input)` requires a system identity or a same-tenant service
114
+ account holding `chat:archive:write`. Only trusted Room disposal/server integration
115
+ may supply `tenantId`, `matchId`, `name`, `members`, and `messages` containing
116
+ `id`, `authorId`, `body`, `at`, plus optional `retentionMs`. A browser cannot call
117
+ this operation. The transcript is detached at invocation, validates every author
118
+ against the supplied server roster and refuses future timestamps, duplicate IDs
119
+ and configured budgets. It uses `maxExportRows` as the transcript entry budget;
120
+ that number is not measured archive capacity. Every cold entry passes the actual
121
+ moderation strategy before any SQL write. Archived text never enters a Room tick.
122
+
123
+ Channel, memberships and messages commit together; the resulting match channel
124
+ starts frozen. A deterministic complete-transcript claim and per-message SQL
125
+ receipts admit exact retries, including different roster/message input order.
126
+ An existing exact archive is read before calling moderation again. Changed input,
127
+ conflicting IDs or incomplete/erased/retained-out receipts refuse rather than
128
+ recreate history. An insert failure rolls back the whole new archive. These
129
+ transaction and successor expectations are authored for all six native dialects,
130
+ with memory and installed-Layer controls; none has run. This is not replay,
131
+ report-evidence ingestion, ordinary boot or a process-restart proof.
132
+
133
+ Private `chat.typing`, listener membership revocation, Party/Guild automatic
134
+ synchronization and Room-disposal wiring remain open. The existing operator
135
+ metadata event is not a substitute for private player delivery.
136
+
137
+ ## Override
138
+
139
+ `./strategies` exports each single default:
140
+ `keywordChatModeration` (`moderate`), `fixedChatRateBudgets` (`rateLimit`),
141
+ `noResolvedChatMentions` (`mentionResolver`), `channelChatRetention` (`retention`).
142
+ Moderation composes the existing `plugin-moderation` keyword provider before SQL.
143
+ A custom Effect may reject, flag or replace text within the input budget; provider
144
+ failures become an unavailable refusal. The empty default denylist performs no
145
+ profanity classification. AI/image/provider coverage is not implied.
146
+
147
+ A rate slot changes budgets, not the HotStore counter authority. Mention slots
148
+ resolve trusted subject IDs outside SQL, then membership/block validation occurs
149
+ before storing refs. The default resolves nothing; text is not treated as an
150
+ identity proof. Edited text clears earlier mention refs. Retention slots return
151
+ a bounded duration or null. The configuration panel identifies custom/default slots.
152
+
153
+ ## Extend or replace
154
+
155
+ Chat is a leaf under the system contract: it supplies events and strategies,
156
+ not its own replacement port. Dependencies on Social use `SocialBlockPort`,
157
+ including a studio's alternate Layer. The three server table handles are exported;
158
+ JSON `metadata` fields persist small extensions and exports include actual row
159
+ columns. Static wire schemas do not yet propagate arbitrary mixin columns.
160
+ Complete S4a propagation, custom-column UI and the system-removal command remain open.
161
+
162
+ In-match chat belongs to a Room channel and never this SQL history path.
163
+ The trusted archive adapter is written below. Storage/scan/media variants,
164
+ sanctions reports, search, automatic party/guild membership synchronization and
165
+ retention scheduling remain to integrate.
166
+ No store read/write belongs in a Room tick.
167
+
168
+ ## Panel and GM
169
+
170
+ `GET /status`, shared `ChatPage`, Local HTTP provider and Cloud authorized-app/
171
+ replica proxy are the four authored layers. They report dated configured budgets,
172
+ backend activation and binding, with no message/author text and no health/capacity
173
+ claim. Channel views, rate/flag metrics, moderator history and inspect write controls
174
+ remain to build; mute and freeze RPC admission is already authored.
175
+
176
+ `./web` exports `useChannel`, `useChannelList`, `useChatMessages`, `useChatSend`,
177
+ `useChatEdit`, `useChatRemove`. Pages use stable `(createdAt,id)` cursors in reverse
178
+ order. A blocked raw page can return fewer visible rows while retaining its cursor.
179
+ Typing delivery, actual server author projections, emote/media rendering, mentions completion, reports integration and moderator history UI remain open. Written cursor/components are described below.
180
+
181
+ ## Tests
182
+
183
+ They encode guest identity, exact retry after rate exhaustion, changed intent
184
+ refusal, ownership, mute/slow mode, moderation, block- filtered server delivery,
185
+ stable timestamp pagination and erase/retry. Native
186
+ PostgreSQL/MySQL/MariaDB/MSSQL/SQLite/Turso cases exercise all three tables,
187
+ Unicode/JSON/date fields, two independent pool slow-mode and version admission,
188
+ successor readback and privacy. Missing native connection settings fail; they do
189
+ not silently skip acceptance. Four strategy fixtures and real generic
190
+ `systemContractConformance` registration cover declared exports, metadata
191
+ extension, events, table privacy and the inspect handler. Panel/proxy fixtures
192
+ are also written.
193
+
194
+ None ran.
195
+
196
+ ## Privacy and limits
197
+
198
+ Export includes own memberships, authored messages including original content,
199
+ own evidence captures, the identity admission row and channels where the target
200
+ has owner membership. Erase removes membership, anonymizes/redacts authored
201
+ message content/refs, removes the subject from structured `payload.mentions` of
202
+ peer messages (peer text stays), clears owned channel names/metadata and
203
+ preserves peer history. User/guest stable identity and retry are encoded by
204
+ fixtures. Owner succession, media removal, volatile counters/event erasure and
205
+ cross-system privacy remain to finish.
206
+
207
+ ### Read cursor and components
208
+
209
+ SQL read receipts store (lastReadAt,lastReadId) derived from a real channel
210
+ message. Older acknowledgements never regress, including equal timestamps.
211
+ The chat.unread live query excludes self-authored, deleted, expired and blocked
212
+ messages. Each channel scans at most maxExportRows raw candidates;
213
+ truncated:true means an incomplete count. Membership lists retain the channel
214
+ read budget. chat.original is an action restricted to same-channel mod/owner
215
+ membership, returning retained original text. It is no general player history.
216
+
217
+ The web entry additionally exports useUnread, useChatMarkRead and useChatOriginal.
218
+ ChatPanel, variable-height windowed MessageList, controlled Composer and
219
+ ChannelTabs reuse ui-shadcn with en/de labels, escaped text, unknown/deleted
220
+ identity, report-ID callbacks, character budget and loading/error/empty states.
221
+ The parent owns trusted Players projections, draft, request ID and uncertain
222
+ pending state. Credential/tenant session scopeKey and matching dataScopeKey fence
223
+ old data and draft/actions. Nothing auto-replays or clears after submission.
224
+ Focused rows remain mounted; semantic row positions and a keyboard-scrollable
225
+ viewport are provided. History updates do not auto-scroll the reader. Callers
226
+ bound retained page caches. No visual/accessibility performance is measured.
227
+
228
+ Typing, actual server author joins, emote/media rendering, mentions completion,
229
+ reports integration and moderator history UI remain open. Native
230
+ cursor/successor cases cover all six dialects. None ran; no acceptance evidence
231
+ is created by this addition.
232
+
233
+ ## Sanctions admission
234
+
235
+ When installed, Chat consults SanctionsPort for the exact channel before new
236
+ sends and edits. A denied decision returns sanctioned; unavailable enforcement
237
+ refuses the write. An already committed exact send retry returns its receipt;
238
+ deletes remain allowed by normal ownership/moderation. No sanctions table is
239
+ read directly by Chat.
240
+
241
+ Production activation now source-checks the actual deployment-wide plugin grant.
242
+ Declare actual Licensing first; development/test remains free. See the bilingual
243
+ Licensing contract and manual update migration.
244
+
245
+ `chat.membership({ channelId })` and `useChatMembership` read only the authenticated request subject’s own SQL membership. Channel and membership are checked in one transaction with exact tenant/identity predicates; role, mute and own read cursor are authoritative values. Foreign identity fields are not input. Membership and channel changes are query sources. A successful snapshot does not replace server authorization of the next action or prove immediate revocation of buffered data.
246
+
247
+ The actual membership supplies `subjectId`; active hints expire after five seconds. Typing uses separate HotStore rate keys with configured channel/global budgets, writes no SQL rows and does not belong in Room ticks. Missing publisher refuses. The installed private policy checks membership at attach, then current memberships, sender state, expiry and Social blocking before each payload. Blocked/expired hints are withheld server-side; revoked recipient membership ends the stream on its next delivery.
248
+
249
+
250
+ `useTyping(channelId | null, apiName?)` returns `{ channelId, subjects, status }`. It consumes the private event through the existing event hook, keeps at most 128 subject IDs and uses a single monotonic timer. A hint lives for at most five local seconds and never beyond its remaining server expiry at receipt. Stop hints remove a peer; gaps, non-live state, channel/API/transport changes and unmount clear the display. Future wall-clock skew cannot create an indefinite lease. The hook does not publish hints or trust display names from events; resolve names from authorized player projections.
251
+
252
+
253
+ `ChatPanel` accepts the `useTyping` result as `typing` and checks its channel and live status before displaying it. `TypingIndicator` resolves a single name only from the supplied authorized `authors`; unknown identities use “Someone is typing”, multiple peers use a bounded count. `chatTypingLabels` supplies English/German copy. The status region stays present without moving keyboard focus. `Composer.onTyping` reports actual editable input and stops on blur, submit, empty draft, disable and unmount.
254
+
255
+ Connect that callback to `useChatTypingPublisher(channelId | null, apiName?).activity`. This separate ephemeral sender captures the actual transport, has one shared in-flight slot across channel changes, a two-second active-hint interval and a one-second interruptible request timeout. It has no active queue or automatic replay; one explicit stop may follow an in-flight active hint. Channel/subject/unmount cleanup aborts the request and keeps the slot occupied through its actual finalizer. It exposes `pending` and `error`; a typing failure does not resend a message.
256
+
257
+
258
+ Private event policies can provide a scoped `monitor`. Registration happens before admission; its completion or failure ends the stream with a redacted access error and releases the bus subscription. The callback owns no detached worker. Chat uses one shared DataStore change listener while any typing monitors exist, capped at 4096 monitors per adapter. Relevant channel/membership/block changes and reported change-stream gaps invalidate subscriptions even when no typing payload arrives. Proven unchanged read receipts and foreign-tenant changes are ignored; incomplete row images invalidate conservatively. This uses the existing external-change injection path and performs no polling. Payload visibility checks remain in place.
259
+
260
+ ## Authorized author names
261
+
262
+ `chat.authors({ channelId, subjectIds })` and `useChatAuthors(channelId, subjectIds)`
263
+ resolve at most 128 distinct subject IDs through the installed `PlayersPort`.
264
+ The requester and each returned identity must be current members of this exact
265
+ channel/tenant; Social blocks are checked in both directions. Authorization is
266
+ rechecked after asynchronous identity resolution. This is a bounded roster
267
+ projection, not public profile access: it returns only subject ID, the Players
268
+ roster display name (already including `#Tag`) and an avatar storage reference.
269
+ Missing Players/profile, former members and blocked peers produce no identity.
270
+ The UI keeps its unknown-author fallback for retained messages from former members.
271
+
272
+ Pass the result as `authors` to ChatPanel/MessageList/TypingIndicator only with
273
+ matching channel and current subject scope. `displayName` is the complete roster
274
+ name; do not append another tag. Names render as text; avatar references are not
275
+ minted URLs. Profile, member, channel and Social changes are live-query sources.
276
+ No names travel in typing payloads.
277
+
278
+ ## Mentions from Players
279
+
280
+ The default `playerChatMentions` strategy resolves bounded `@Name#1234` tokens
281
+ through `PlayersPort.resolveName`. Names may contain spaces; tokens start at the
282
+ beginning of text or after whitespace/opening punctuation. Repeated resolved
283
+ subjects collapse to one ID. `maxMentions` bounds distinct lookup intentions before
284
+ any Players call. Missing Players/unknown names produce no mention; unavailable
285
+ providers refuse the operation. Chat verifies sender membership before resolving
286
+ names, and every target's actual channel membership and Social blocks inside send
287
+ admission. Client text cannot supply an authoritative subject ID. A rejected
288
+ nonmember/blocked mention refuses the send; it is not silently delivered.
289
+
290
+ `chat.mentionCandidates({ channelId, after? })` and
291
+ `useChatMentionCandidates(channelId, after?)` return `{ authors, after }`. Each
292
+ page scans at most 129 roster rows and resolves at most 128 identities through
293
+ the same authorized author projection. Use `after` even if filtering leaves
294
+ `authors` empty. There is no whole-roster preload or unbounded prefix scan.
295
+ Live-query sources include profiles, members, channel and Social relations. This
296
+ is implemented; committed mention notifications remain unfinished integration
297
+ work.
298
+
299
+ ## Composer mention suggestions
300
+
301
+ Pass a current authorized candidate page as `mentionCandidates` to Composer or
302
+ ChatPanel. After an `@` token, up to eight matching names from at most 128 input
303
+ candidates appear as ordinary focusable buttons. Arrow Down moves from the draft
304
+ to the first choice, Tab traverses controls, and Escape closes suggestions.
305
+ Selection inserts the full roster label as text, retains the suffix and restores
306
+ the textarea cursor. It never sends or clears the draft. Selection ranges, email
307
+ substrings, mid-token cursors and edits exceeding the message budget are refused.
308
+
309
+ `mentionsLoading` hides stale candidates. `onLoadMoreMentions` exposes an
310
+ explicit next-page button, including empty filtered pages; the parent owns
311
+ paging and must replace candidates on channel/identity changes. ChatPanel's
312
+ existing scope gate hides suggestions and callbacks while data belongs to
313
+ another subject. Disabled or pending input exposes no suggestions.
314
+ `chatMentionLabels` provides en/de copy.
315
+
316
+ ## Committed mention metadata and delivery admission
317
+
318
+ `chat.mentioned` is an operator-scoped event (`chat:events:read`), keyed by channel
319
+ and recipient subject, carrying only the message ID. The existing committed SQL
320
+ subscriber publishes it for inserted mention recipients (max100), deduplicates
321
+ recipients and skips the sender. Edits/deletes do not resend mentions. This is
322
+ best-effort CDC, not a durable notification or an exactly-once delivery promise.
323
+ Consumers must deduplicate by tenant/message/recipient if necessary.
324
+
325
+ `ChatAdministration.canNotifyMention(subject, { tenantId, channelId, messageId,
326
+ recipientId })` checks the actual retained message/mention, current channel
327
+ members, recipient notify flag and Social blocks. Only system or same-tenant
328
+ service account with `chat:notifications:send` may call it.
329
+ Missing/deleted/erased/expired state is ineligible; provider failures refuse.
330
+ Recheck at actual delivery, including after quiet-hours/digest deferral. No
331
+ transcript/name is needed in the queued payload.
332
+
333
+ ## Optional mention notifications
334
+
335
+ `createChatMentionNotifications` from `@voltro/plugin-chat/server` composes the
336
+ existing ChatAdministration, Notifications dispatcher and Outbox. Supply a trusted
337
+ server `subject`, the owning app runtime's `run` executor, a getter for its actual
338
+ NotificationService, and `outboxForTenant` returning that app's existing facade
339
+ bound to the requested tenant. A missing/foreign durable tenant refuses delivery.
340
+ An optional `localeFor(recipientId, tenantId)` selects generic en/de copy; neither
341
+ message text nor author names enter the queued notification.
342
+
343
+ Register the returned `deliveryGuard` under `CHAT_MENTION_NOTIFICATION_GUARD` in
344
+ Notifications `deliveryGuards`. Export `subscriber` from an app `*.subscribe.ts`
345
+ and `outboxHandler` from an app `*.outbox.ts`. The subscriber interprets the same
346
+ committed recipients as `chat.mentioned`, checks current SQL admission and enqueues
347
+ through the existing facade. Explicit trusted consumers may call `enqueue(intent)`.
348
+ There is no automatic installation, independent connection, or fallback that skips
349
+ missing services. The bound app runtime must include ChatAdministration and the
350
+ same Social policy used by Chat.
351
+
352
+ The existing outbox key deduplicates pending sequential repeats; retries and
353
+ post-delivery repeats can still duplicate external effects. Subscriber delivery
354
+ is best-effort until enqueue, not atomic with the originating message commit.
355
+ The outbox handler rechecks admission, then uses the normal Notifications
356
+ renderer, preferences and delivery path. The persisted delivery guard checks
357
+ again before channels and after quiet/digest delays. Missing guards fail closed.
358
+ Failed channels fail the outbox attempt; already delivered channels can repeat
359
+ on retry. No exactly- once claim.
360
+
361
+ ## Bounded retention schedule
362
+
363
+ The contributed `chat.pruneHistory` schedule runs every minute in UTC through the
364
+ normal scheduler (`onOverlap: skip`, `backfill: latest`). Each firing performs at
365
+ most ten `ChatAdministration.prune` steps. A step selects at most two channel IDs,
366
+ reads at most 201 expired message identities and deletes at most 200 messages from
367
+ one channel. It does not load message bodies. SQL owns `_voltro_chat_retention`
368
+ progress; the cursor and deletions commit together after writer/channel admission.
369
+ A busy channel advances to let other channels progress, then returns on a later
370
+ sweep. `cycleComplete` means the cursor wrapped, not that every expired row is gone.
371
+
372
+ System principals sweep app channels; service accounts require
373
+ `chat:retention:write` and are limited to their exact tenant with a separate
374
+ cursor. A null retention policy preserves history. Successor adapters resume
375
+ persisted progress. The result is `{ deleted, channelId, remainingInChannel,
376
+ cycleComplete }`. These are work budgets, not measured capacity or a
377
+ deletion-latency SLA. Retention removes history/receipts; replay of a
378
+ retained-out match archive must still refuse.
379
+
380
+ ## Verified report context
381
+
382
+ Configure Sanctions' existing `strategies.evidenceCollector` with
383
+ `chatSanctionEvidenceCollector` from `@voltro/plugin-chat/server`. The ordinary
384
+ `sanctions.report` / `useReport` input supplies target, category, request ID, note
385
+ and `{ channelId, messageIds: [anchorId] }`; no new reporting transport is needed.
386
+ The collector borrows actual ChatAdministration from the app runtime, checks
387
+ reporter membership, tenant and the anchor's actual author/retention, then derives
388
+ at most 20 earlier + anchor + 20 later SQL references. Extra client context IDs,
389
+ wrong targets and unrelated anchors refuse. Human reports remain review-only.
390
+
391
+ The current `chat.context` evidence records server-observed message IDs,
392
+ versions, timestamps and deletion markers. It contains no transcript or author
393
+ identity. Each reference now carries a private Chat-owned evidence ID when
394
+ author content is available. The snapshot lifecycle is described below.
395
+
396
+ ## Private evidence snapshots
397
+
398
+ Report collection captures up to 41 actual message versions in `_voltro_chat_evidence`
399
+ under the channel fence. Cases retain only references; author IDs, current text and
400
+ original text stay in this server-only table. An unexpired capture of the same
401
+ message/version is reused without extending its expiry. After expiry, a new opaque
402
+ ID prevents old case references from pointing to a later capture. `evidenceRetentionMs`
403
+ defaults to 30 days (maximum 365 days), independently of ordinary history retention.
404
+
405
+ `ChatAdministration.readReportEvidence(subject, { tenantId, evidenceIds })` reads at
406
+ most 41 references for system or exact-tenant `sanctions:manage`/`admin:full` operators.
407
+ Ordinary channel membership grants no access. Expired IDs return no record; erased
408
+ captures return redacted author/body/original fields. Captured content survives
409
+ ordinary message edit/removal/history pruning until evidence expiry or author erasure.
410
+ It does not copy arbitrary message payloads or media attachments.
411
+
412
+ Author export includes owned captures. Erasure locks relevant channels in stable
413
+ order, re-reads bounded ownership after admission and retries changed channel
414
+ sets (max4), then redacts source text and snapshots together. The retention step
415
+ also reads at most201 evidence identities/deletes at most200 expired captures;
416
+ its result adds `evidenceDeleted` and `evidenceRemainingInChannel`. No bodies
417
+ are loaded by the sweep.
418
+
419
+ ## Joining open global channels
420
+
421
+ `kind: 'global'` channels may be created with an empty `members` list. Signed-in
422
+ users and guests then join them themselves with `chat.join({ channelId })`
423
+ (`useChatJoin` in `./web`). Join is idempotent, passes the Sanctions checkpoint,
424
+ respects `maxMembers` (`member_limit`) and `frozen`, refuses an erased identity
425
+ (`identity_required`) and every other channel kind (`forbidden`). Leave with
426
+ `chat.leave`. All other channel kinds stay server-administered through
427
+ `ChatAdministration`.
428
+
429
+ ## Guild and party channels
430
+
431
+ Chat keeps Players, Social, Guild and Party observers in its complete query
432
+ `source` lists and marks only these optional integration observations with
433
+ `optionalSource`. Offline checks and both boot paths report absent providers
434
+ visibly without treating them as stale required tables. Installing a provider
435
+ retains all original invalidation triggers. Membership, block and roster
436
+ authorization continue through the existing ports on every applicable read or
437
+ write; the annotation grants no access and installs no provider.
438
+
439
+ `kind: 'guild'` channels (with `refId` = guild ID) may also start without
440
+ members. When `@voltro/plugin-guilds` is installed, its `GuildMembershipPort`
441
+ decides access (for `kind: 'party'` with `refId` = party ID, Matchmaking's
442
+ `PartyMembershipPort`): current guild members may `chat.join` the guild channel,
443
+ and sending (including every mention target), reading history and media URLs
444
+ require current guild membership, so a kicked or departed member loses access at
445
+ once even though the Chat membership row remains. Without Guilds, guild channels
446
+ stay server-administered through `ChatAdministration`. `chat.channel` and
447
+ `chat.membership` refuse a departed member; `chat.channels` and `chat.unread`
448
+ leave such guild channels out; author and mention-candidate projections skip
449
+ departed members; typing is neither accepted from nor delivered to them.
450
+
451
+ ## Media messages
452
+
453
+ `chat.send` accepts an optional `mediaId`: the ID of a `_voltro_storage_refs` row the
454
+ sender uploaded through `@voltro/plugin-storage` in the same tenant. The body is then
455
+ an optional caption. Chat refuses a ref that belongs to someone else, another tenant,
456
+ a derivative or a password-protected file (`forbidden`/`not_found`). Without the
457
+ storage plugin, media sends are `forbidden`.
458
+
459
+ The `moderateMedia` slot decides admission. Its default, `typedChatMedia`, checks
460
+ `mediaContentTypes` (default PNG, JPEG, WebP, GIF) and `maxMediaBytes` (default
461
+ 10 MiB, max 100 MiB). Image classification is an integration: replace the slot to
462
+ call a provider; `{ allowed: false }` is `content_rejected`, `flagged: true` marks
463
+ the message. Virus scanning stays in plugin-storage's upload scanner. Chat cannot
464
+ tell whether a ref skipped that scanner (for example a resumable upload); if your
465
+ app allows such paths, check them in `moderateMedia`.
466
+
467
+ The message stores `kind: 'media'` and `payload.media` (`id`, `contentType`, `size`,
468
+ `width`, `height`); bytes stay in storage. Readers call `chat.mediaUrl({ messageId })`.
469
+ It checks membership, Social blocks and retention, refuses removed media except for
470
+ channel mods/owners, and returns a storage URL valid for `mediaUrlTtlSec` (default
471
+ 300 s, max 3600 s) with `expiresAt`. Chat does this authorization itself, so keep
472
+ chat uploads private; a public ref yields a URL that never expires. `MessageList`
473
+ and `ChatPanel` label media rows and call `onOpenMedia(messageId)`; they never
474
+ render a URL themselves. Editing a caption keeps the attachment.
475
+
476
+ `_voltro_chat_media` records one row per media message. Erasure and history
477
+ retention mark rows `deleting` (owner ID cleared) in the same transaction as the
478
+ message change. `ChatAdministration.sweepMedia(subject)` then deletes at most 50
479
+ queued blobs per step through plugin-storage, outside SQL, and keeps a blob
480
+ while another active row references it. A storage 404 counts as done; other
481
+ failures stay queued and are reported as `failed`. The contributed retention
482
+ schedule runs one sweep step per minute. Known limits: if the owner attaches the
483
+ same upload again while the sweep is deleting it, the new message can lose its
484
+ file; failing rows are retried first, so persistent storage errors can hold back
485
+ later rows. Export includes owned media rows within `maxExportRows`.
486
+
487
+ ## Erasure admission tombstones
488
+
489
+ `_voltro_chat_admissions` holds one server-only row per tenant and Chat identity. Its
490
+ ID is derived from a SHA-256 hash of tenant and subject. Every path that adds a
491
+ membership — `createChannel` (including DMs), `addMember` and `archiveMatch` — seeds
492
+ that row, locks it before the channel and refuses an erased identity with
493
+ `identity_required`. Erasure locks the same row first, then the affected channels in
494
+ stable order, and in the same transaction sets `subjectId` to null and `erased` to
495
+ true. History retention never deletes these rows.
496
+
497
+ Consequences:
498
+
499
+ - An erased identity cannot rejoin Chat in that tenant. Use a new subject ID for a
500
+ new account. Other tenants are independent.
501
+ - The tombstone ID is hash-derived from tenant and subject. Anyone who already
502
+ knows both can recompute it; treat it as pseudonymous data in your records of
503
+ processing.
504
+ - Work prepared before erasure (a queued `archiveMatch` Effect, archive moderation
505
+ still running, a create whose reply was lost) re-checks the tombstone before it
506
+ writes or recovers, so it cannot recreate memberships or content.
507
+ - Ordinary send/edit/capture take only the channel fence; erasure shares those
508
+ fences, so it never takes locks in the reverse order.
509
+ - Export counts the admission row in `maxExportRows` and omits its internal fence
510
+ counter.
511
+ - Message versions are SQL `INT` columns. Edit and remove refuse
512
+ `baseVersion >= CHAT_MAX_MESSAGE_VERSION` (2147483647); erasure redaction
513
+ saturates at that ceiling instead of failing.
514
+
515
+ Mention redaction scans up to 100 `payload.mentions` array slots (the
516
+ `maxMentions` ceiling) per message with `jsonField` predicates. Its query cost
517
+ per dialect is not measured.
package/SECURITY.md ADDED
@@ -0,0 +1,56 @@
1
+ # Security Policy
2
+
3
+ We take the security of Voltro and the applications built on it seriously. This
4
+ document explains how to report a vulnerability and what to expect in return.
5
+
6
+ ## Reporting a vulnerability
7
+
8
+ **Please do not open public issues or discuss suspected vulnerabilities in
9
+ public channels.** Report them privately by email:
10
+
11
+ **security contact: [support@voltro.dev](mailto:support@voltro.dev)**
12
+ (subject line: `SECURITY`).
13
+
14
+ Include, where you can:
15
+
16
+ - the affected package(s) and version(s) (e.g. `@voltro/runtime@x.y.z`);
17
+ - a description of the issue and its impact;
18
+ - a minimal reproduction or proof of concept;
19
+ - any suggested remediation.
20
+
21
+ If you'd like to encrypt your report, ask in a first plain email and we'll
22
+ arrange a key.
23
+
24
+ ## What to expect
25
+
26
+ - **Acknowledgement** of your report within **3 business days**.
27
+ - An initial assessment (severity, affected surface) and a remediation plan as
28
+ soon as we've reproduced the issue.
29
+ - **Coordinated disclosure:** we'll agree a disclosure timeline with you and
30
+ credit you in the release notes if you wish. Please give us reasonable time to
31
+ ship a fix before any public disclosure.
32
+
33
+ We do not currently run a paid bug-bounty program, but we genuinely appreciate
34
+ responsible disclosure and will credit reporters.
35
+
36
+ ## Software Bill of Materials (SBOM)
37
+
38
+ A CycloneDX SBOM of the third-party runtime dependency graph is generated for the
39
+ published packages (`pnpm gen:sbom` → `sbom.cdx.json`). If your procurement or
40
+ security team needs it, request it at
41
+ [support@voltro.dev](mailto:support@voltro.dev).
42
+
43
+ ## Supported versions
44
+
45
+ Voltro is pre-1.0 and ships in lockstep: **only the latest published minor of
46
+ each `@voltro/*` package receives security fixes.** Please upgrade to the latest
47
+ release before reporting, and pin exact versions in production.
48
+
49
+ ## Scope
50
+
51
+ In scope: the published `@voltro/*` packages and the framework's own code.
52
+
53
+ Out of scope: vulnerabilities in third-party dependencies that are already
54
+ tracked upstream (we monitor these via a production-dependency audit gate in
55
+ CI), and issues that require a non-default, explicitly-unsafe configuration. If
56
+ you're unsure, report it anyway — we'd rather hear about it.