@prmichaelsen/acp-visualizer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/agent/commands/acp.clarification-address.md +417 -0
- package/agent/commands/acp.clarification-capture.md +386 -0
- package/agent/commands/acp.clarification-create.md +437 -0
- package/agent/commands/acp.clarifications-research.md +326 -0
- package/agent/commands/acp.command-create.md +432 -0
- package/agent/commands/acp.design-create.md +286 -0
- package/agent/commands/acp.design-reference.md +355 -0
- package/agent/commands/acp.handoff.md +270 -0
- package/agent/commands/acp.index.md +423 -0
- package/agent/commands/acp.init.md +546 -0
- package/agent/commands/acp.package-create.md +895 -0
- package/agent/commands/acp.package-info.md +212 -0
- package/agent/commands/acp.package-install.md +539 -0
- package/agent/commands/acp.package-list.md +280 -0
- package/agent/commands/acp.package-publish.md +541 -0
- package/agent/commands/acp.package-remove.md +293 -0
- package/agent/commands/acp.package-search.md +307 -0
- package/agent/commands/acp.package-update.md +361 -0
- package/agent/commands/acp.package-validate.md +540 -0
- package/agent/commands/acp.pattern-create.md +386 -0
- package/agent/commands/acp.plan.md +587 -0
- package/agent/commands/acp.proceed.md +882 -0
- package/agent/commands/acp.project-create.md +675 -0
- package/agent/commands/acp.project-info.md +312 -0
- package/agent/commands/acp.project-list.md +226 -0
- package/agent/commands/acp.project-remove.md +379 -0
- package/agent/commands/acp.project-set.md +227 -0
- package/agent/commands/acp.project-update.md +307 -0
- package/agent/commands/acp.projects-restore.md +228 -0
- package/agent/commands/acp.projects-sync.md +347 -0
- package/agent/commands/acp.report.md +407 -0
- package/agent/commands/acp.resume.md +239 -0
- package/agent/commands/acp.sessions.md +301 -0
- package/agent/commands/acp.status.md +293 -0
- package/agent/commands/acp.sync.md +364 -0
- package/agent/commands/acp.task-create.md +500 -0
- package/agent/commands/acp.update.md +302 -0
- package/agent/commands/acp.validate.md +466 -0
- package/agent/commands/acp.version-check-for-updates.md +276 -0
- package/agent/commands/acp.version-check.md +191 -0
- package/agent/commands/acp.version-update.md +289 -0
- package/agent/commands/command.template.md +339 -0
- package/agent/commands/git.commit.md +526 -0
- package/agent/commands/git.init.md +514 -0
- package/agent/commands/tanstack-cloudflare.deploy.md +272 -0
- package/agent/commands/tanstack-cloudflare.tail.md +275 -0
- package/agent/design/.gitkeep +0 -0
- package/agent/design/design.template.md +154 -0
- package/agent/design/local.dashboard-layout-routing.md +288 -0
- package/agent/design/local.data-model-yaml-parsing.md +310 -0
- package/agent/design/local.search-filtering.md +331 -0
- package/agent/design/local.server-api-auto-refresh.md +235 -0
- package/agent/design/local.table-tree-views.md +299 -0
- package/agent/design/local.visualizer-requirements.md +349 -0
- package/agent/design/requirements.template.md +387 -0
- package/agent/index/.gitkeep +0 -0
- package/agent/index/acp.core.yaml +137 -0
- package/agent/index/local.main.template.yaml +37 -0
- package/agent/manifest.template.yaml +13 -0
- package/agent/manifest.yaml +302 -0
- package/agent/milestones/.gitkeep +0 -0
- package/agent/milestones/milestone-1-project-scaffold-data-pipeline.md +67 -0
- package/agent/milestones/milestone-1-{title}.template.md +206 -0
- package/agent/milestones/milestone-2-dashboard-views-interaction.md +79 -0
- package/agent/package.template.yaml +86 -0
- package/agent/patterns/.gitkeep +0 -0
- package/agent/patterns/bootstrap.template.md +1237 -0
- package/agent/patterns/pattern.template.md +382 -0
- package/agent/patterns/tanstack-cloudflare.acl-permissions.md +332 -0
- package/agent/patterns/tanstack-cloudflare.action-bar-item.md +416 -0
- package/agent/patterns/tanstack-cloudflare.api-route-handlers.md +401 -0
- package/agent/patterns/tanstack-cloudflare.auth-session-management.md +387 -0
- package/agent/patterns/tanstack-cloudflare.card-and-list.md +271 -0
- package/agent/patterns/tanstack-cloudflare.chat-engine.md +353 -0
- package/agent/patterns/tanstack-cloudflare.confirmation-tokens.md +346 -0
- package/agent/patterns/tanstack-cloudflare.durable-objects-websocket.md +516 -0
- package/agent/patterns/tanstack-cloudflare.email-service.md +431 -0
- package/agent/patterns/tanstack-cloudflare.expander.md +98 -0
- package/agent/patterns/tanstack-cloudflare.fcm-push.md +115 -0
- package/agent/patterns/tanstack-cloudflare.firebase-anonymous-sessions.md +441 -0
- package/agent/patterns/tanstack-cloudflare.firebase-auth.md +348 -0
- package/agent/patterns/tanstack-cloudflare.firebase-firestore.md +550 -0
- package/agent/patterns/tanstack-cloudflare.firebase-storage.md +369 -0
- package/agent/patterns/tanstack-cloudflare.form-controls.md +145 -0
- package/agent/patterns/tanstack-cloudflare.global-search-context.md +93 -0
- package/agent/patterns/tanstack-cloudflare.image-carousel.md +126 -0
- package/agent/patterns/tanstack-cloudflare.library-services.md +553 -0
- package/agent/patterns/tanstack-cloudflare.lightbox.md +169 -0
- package/agent/patterns/tanstack-cloudflare.markdown-content.md +115 -0
- package/agent/patterns/tanstack-cloudflare.mention-suggestions.md +98 -0
- package/agent/patterns/tanstack-cloudflare.modal.md +156 -0
- package/agent/patterns/tanstack-cloudflare.nextjs-to-tanstack-routing.md +461 -0
- package/agent/patterns/tanstack-cloudflare.notifications-engine.md +151 -0
- package/agent/patterns/tanstack-cloudflare.oauth-token-refresh.md +90 -0
- package/agent/patterns/tanstack-cloudflare.og-metadata.md +296 -0
- package/agent/patterns/tanstack-cloudflare.pagination.md +442 -0
- package/agent/patterns/tanstack-cloudflare.pill-input.md +220 -0
- package/agent/patterns/tanstack-cloudflare.provider-adapter.md +401 -0
- package/agent/patterns/tanstack-cloudflare.rate-limiting.md +323 -0
- package/agent/patterns/tanstack-cloudflare.scheduled-tasks.md +338 -0
- package/agent/patterns/tanstack-cloudflare.searchable-settings.md +375 -0
- package/agent/patterns/tanstack-cloudflare.slide-over.md +129 -0
- package/agent/patterns/tanstack-cloudflare.ssr-preload.md +571 -0
- package/agent/patterns/tanstack-cloudflare.third-party-api-integration.md +508 -0
- package/agent/patterns/tanstack-cloudflare.toast-system.md +142 -0
- package/agent/patterns/tanstack-cloudflare.unified-header.md +280 -0
- package/agent/patterns/tanstack-cloudflare.user-scoped-collections.md +628 -0
- package/agent/patterns/tanstack-cloudflare.websocket-manager.md +237 -0
- package/agent/patterns/tanstack-cloudflare.wrangler-configuration.md +358 -0
- package/agent/patterns/tanstack-cloudflare.zod-schema-validation.md +336 -0
- package/agent/progress.template.yaml +161 -0
- package/agent/progress.yaml +145 -0
- package/agent/schemas/package.schema.yaml +276 -0
- package/agent/scripts/acp.common.sh +1781 -0
- package/agent/scripts/acp.install.sh +333 -0
- package/agent/scripts/acp.package-create.sh +924 -0
- package/agent/scripts/acp.package-info.sh +288 -0
- package/agent/scripts/acp.package-install.sh +893 -0
- package/agent/scripts/acp.package-list.sh +311 -0
- package/agent/scripts/acp.package-publish.sh +420 -0
- package/agent/scripts/acp.package-remove.sh +348 -0
- package/agent/scripts/acp.package-search.sh +156 -0
- package/agent/scripts/acp.package-update.sh +517 -0
- package/agent/scripts/acp.package-validate.sh +1018 -0
- package/agent/scripts/acp.uninstall.sh +85 -0
- package/agent/scripts/acp.version-check-for-updates.sh +98 -0
- package/agent/scripts/acp.version-check.sh +47 -0
- package/agent/scripts/acp.version-update.sh +176 -0
- package/agent/scripts/acp.yaml-parser.sh +985 -0
- package/agent/scripts/acp.yaml-validate.sh +205 -0
- package/agent/tasks/.gitkeep +0 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-1-initialize-tanstack-start-project.md +210 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-2-implement-data-model-yaml-parser.md +294 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-3-build-server-api-data-loading.md +193 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-4-add-auto-refresh-sse.md +262 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-10-polish-integration-testing.md +156 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-5-build-dashboard-layout-routing.md +178 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-6-build-overview-page.md +141 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-7-implement-milestone-table-view.md +153 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-8-implement-milestone-tree-view.md +174 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-9-implement-search-filtering.md +233 -0
- package/agent/tasks/task-1-{title}.template.md +244 -0
- package/bin/visualize.mjs +84 -0
- package/package.json +48 -0
- package/src/components/ExtraFieldsBadge.tsx +15 -0
- package/src/components/FilterBar.tsx +33 -0
- package/src/components/Header.tsx +23 -0
- package/src/components/MilestoneTable.tsx +167 -0
- package/src/components/MilestoneTree.tsx +84 -0
- package/src/components/ProgressBar.tsx +20 -0
- package/src/components/SearchInput.tsx +22 -0
- package/src/components/Sidebar.tsx +54 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/StatusDot.tsx +12 -0
- package/src/components/TaskList.tsx +36 -0
- package/src/components/ViewToggle.tsx +31 -0
- package/src/lib/config.ts +8 -0
- package/src/lib/file-watcher.ts +43 -0
- package/src/lib/search.ts +48 -0
- package/src/lib/types.ts +73 -0
- package/src/lib/useAutoRefresh.ts +31 -0
- package/src/lib/useCollapse.ts +31 -0
- package/src/lib/useFilteredData.ts +55 -0
- package/src/lib/yaml-loader-real.spec.ts +47 -0
- package/src/lib/yaml-loader.spec.ts +201 -0
- package/src/lib/yaml-loader.ts +265 -0
- package/src/routeTree.gen.ts +140 -0
- package/src/router.tsx +10 -0
- package/src/routes/__root.tsx +75 -0
- package/src/routes/api/watch.ts +29 -0
- package/src/routes/index.tsx +115 -0
- package/src/routes/milestones.tsx +50 -0
- package/src/routes/search.tsx +84 -0
- package/src/routes/tasks.tsx +63 -0
- package/src/services/progress-database.service.ts +46 -0
- package/src/styles.css +25 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +16 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# Chat Engine
|
|
2
|
+
|
|
3
|
+
**Category**: Architecture
|
|
4
|
+
**Applicable To**: Adding AI chat bots to TanStack + Cloudflare applications with tool calling, streaming, MCP integration, and message persistence
|
|
5
|
+
**Status**: Stable
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
ChatEngine is a provider-agnostic chat orchestration layer that coordinates AI models, message storage, MCP tool servers, and vision processing through dependency-injected interfaces. It handles the complete message lifecycle: token budgeting, system prompt building with prompt injectors, MCP tool discovery and caching, multi-turn tool execution with persistence, streaming responses, message ACL, and conversation management. Designed to be extracted into a standalone package — clients of the `tanstack-cloudflare` ACP package can use this as a clear path to adding chat bots to their application.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## When to Use This Pattern
|
|
16
|
+
|
|
17
|
+
**Use this pattern when:**
|
|
18
|
+
- Adding an AI chat bot to a TanStack + Cloudflare application
|
|
19
|
+
- Building a multi-tool AI assistant with MCP server integration
|
|
20
|
+
- Implementing streaming chat with tool call persistence
|
|
21
|
+
- Creating a white-label chat experience with pluggable AI providers
|
|
22
|
+
|
|
23
|
+
**Don't use this pattern when:**
|
|
24
|
+
- Building a simple form-based AI query (use direct API call)
|
|
25
|
+
- The application doesn't need real-time streaming or tool calling
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Core Principles
|
|
30
|
+
|
|
31
|
+
1. **Dependency Injection**: All external services (AI, storage, MCP, vision) are injected as interfaces — swap providers without changing orchestration logic
|
|
32
|
+
2. **MCP Caching**: Server connections and tool definitions cached per-user with 24h TTL — avoids expensive RPC on every message
|
|
33
|
+
3. **Tool Persistence**: Tool calls saved as intermediate messages (`is_tool_interaction: true`) so the AI sees prior tool output on continuation
|
|
34
|
+
4. **Token Budgeting**: Heuristic estimation + optional preflight check via `countTokens` API prevents "prompt too long" errors
|
|
35
|
+
5. **Fire-and-Forget Non-Critical Ops**: Analytics, usage tracking, and title generation don't block the response stream
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Implementation
|
|
40
|
+
|
|
41
|
+
### Provider Interfaces
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
interface ChatEngineDependencies {
|
|
45
|
+
aiProvider: IAIProvider
|
|
46
|
+
storageProvider: IStorageProvider
|
|
47
|
+
mcpProvider: IMCPProvider
|
|
48
|
+
visionProvider: IVisionProvider
|
|
49
|
+
logger: ILogger
|
|
50
|
+
env?: Record<string, unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface IAIProvider {
|
|
54
|
+
streamChat(params: {
|
|
55
|
+
messages: ChatMessage[]
|
|
56
|
+
systemPrompt: string
|
|
57
|
+
tools: Tool[]
|
|
58
|
+
onMessage: (msg: ChatEngineMessage) => void
|
|
59
|
+
executeTool: (name: string, input: any, id?: string) => Promise<any>
|
|
60
|
+
signal?: AbortSignal
|
|
61
|
+
}): Promise<void>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface IStorageProvider {
|
|
65
|
+
saveMessage(params): Promise<Message>
|
|
66
|
+
loadMessages(params): Promise<Message[]>
|
|
67
|
+
ensureConversation(params): Promise<string>
|
|
68
|
+
updateConversation(params): Promise<void>
|
|
69
|
+
addToolCall(params): Promise<string> // Persist tool invocation
|
|
70
|
+
updateToolCall(params): Promise<void> // Update with result
|
|
71
|
+
getToolCallsForMessages(params): Promise<Map<string, PersistedToolCall[]>>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface IMCPProvider {
|
|
75
|
+
getAvailableServers(params): Promise<MCPServer[]>
|
|
76
|
+
connectToServers(params): Promise<MCPConnection[]>
|
|
77
|
+
getTools(connections): Promise<Tool[]> // Cached after first call
|
|
78
|
+
executeTool(params): Promise<any>
|
|
79
|
+
disconnect(connections): Promise<void>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface IVisionProvider {
|
|
83
|
+
processImagesInMessage(params): Promise<MessageContent>
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Instantiation in ChatRoom DO
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
class ChatRoom extends DurableObject {
|
|
91
|
+
private chatEngine: ChatEngine
|
|
92
|
+
private mcpProvider: MCPProvider // Persisted for caching
|
|
93
|
+
|
|
94
|
+
constructor(state: DurableObjectState, env: Env) {
|
|
95
|
+
super(state, env)
|
|
96
|
+
this.mcpProvider = new MCPProvider()
|
|
97
|
+
this.chatEngine = new ChatEngine({
|
|
98
|
+
aiProvider: new AnthropicAIProvider(),
|
|
99
|
+
storageProvider: new FirebaseStorageProvider(),
|
|
100
|
+
mcpProvider: this.mcpProvider, // Reused across messages for caching
|
|
101
|
+
visionProvider: new GoogleVisionProvider(),
|
|
102
|
+
logger: chatLogger,
|
|
103
|
+
env: env as unknown as Record<string, unknown>,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Message Processing Pipeline
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
processMessage(userId, conversationId, message, onMessage, signal)
|
|
113
|
+
|
|
114
|
+
1. Token limit check → reject if subscription exceeded
|
|
115
|
+
2. Ensure conversation exists
|
|
116
|
+
3. Detect @agent mention → determine if agent should respond
|
|
117
|
+
4. Resolve @username mentions → @uid:userId
|
|
118
|
+
5. Assign message ACL (visible_to_user_ids)
|
|
119
|
+
6. Save user message → emit user_message_saved
|
|
120
|
+
7. Process images via vision provider
|
|
121
|
+
8. Load message history (ACL-filtered)
|
|
122
|
+
9. Get MCP servers + connect (cached 24h)
|
|
123
|
+
10. Fetch tools (cached) + build system prompt (parallel)
|
|
124
|
+
11. Apply tool filters from prompt injectors
|
|
125
|
+
12. Format messages with timestamps + locations
|
|
126
|
+
13. Token-based truncation (60K budget, oldest first)
|
|
127
|
+
14. Preflight check if estimate > 180K (countTokens API)
|
|
128
|
+
15. Stream AI response with tool execution
|
|
129
|
+
16. Save assistant message + tool call records
|
|
130
|
+
17. Generate conversation title (fire-and-forget, 2nd message)
|
|
131
|
+
18. Update conversation metadata
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Tool Execution Flow
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
executeTool: async (toolName, toolInput, toolCallId) => {
|
|
138
|
+
// 1. Create persistent record (status: 'pending')
|
|
139
|
+
const id = await storageProvider.addToolCall({ toolName, status: 'pending', inputs: toolInput })
|
|
140
|
+
|
|
141
|
+
// 2. Execute (local tool registry or MCP provider)
|
|
142
|
+
const result = isLocalTool(toolName)
|
|
143
|
+
? await executeLocalTool(toolName, userId, toolInput, env)
|
|
144
|
+
: await mcpProvider.executeTool({ toolName, toolInput, connections })
|
|
145
|
+
|
|
146
|
+
// 3. Update record (status: 'success', output: result)
|
|
147
|
+
storageProvider.updateToolCall({ id, status: 'success', output: result })
|
|
148
|
+
|
|
149
|
+
// 4. Track analytics (fire-and-forget)
|
|
150
|
+
AnalyticsService.trackServerEvent(userId, 'tool_executed', { tool: toolName })
|
|
151
|
+
|
|
152
|
+
return result // Returned to AI for next turn
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### MCP Caching Strategy
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
class MCPProvider implements IMCPProvider {
|
|
160
|
+
private serverCache: MCPServer[] = []
|
|
161
|
+
private connectionCache: MCPConnection[] = []
|
|
162
|
+
private toolDefsCache: Tool[] = []
|
|
163
|
+
private toolMapCache: Map<string, MCPConnection> = new Map()
|
|
164
|
+
private cacheExpiry: number = 0
|
|
165
|
+
private cacheUserId: string = ''
|
|
166
|
+
private readonly CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
|
|
167
|
+
|
|
168
|
+
// Cache invalidated when: user changes, TTL expires, or OAuth connect/disconnect
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Token Budgeting
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// Heuristic estimation
|
|
176
|
+
const MESSAGE_TOKEN_BUDGET = 60_000 // ~120K after formatting
|
|
177
|
+
const PREFLIGHT_THRESHOLD = 180_000 // 90% of 200K API limit
|
|
178
|
+
const SAFE_TARGET = 170_000 // Leave room for output
|
|
179
|
+
|
|
180
|
+
// Estimation ratios
|
|
181
|
+
Text: ~4 chars/token
|
|
182
|
+
JSON: ~2.5 chars/token
|
|
183
|
+
Base64 img: ~1 token/300 bytes
|
|
184
|
+
Per-message: 50 token overhead
|
|
185
|
+
|
|
186
|
+
// TokenRatioService learns from actual token counts
|
|
187
|
+
// Adjusts future estimates based on preflight feedback
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### System Prompt Building
|
|
191
|
+
|
|
192
|
+
System prompt assembled from:
|
|
193
|
+
1. Anti-hallucination preamble (tool call requirements)
|
|
194
|
+
2. Markdown formatting rules
|
|
195
|
+
3. Web tool instructions
|
|
196
|
+
4. **Prompt injectors** (modular, priority-ordered extensions):
|
|
197
|
+
- Ghost persona injector (priority 0.9, mutex: ghost)
|
|
198
|
+
- Agent memory injector (priority 0.7, mutex: memory-context)
|
|
199
|
+
- Space/group context injector
|
|
200
|
+
- Each returns: `{ content: string, toolFilters?: { allow?, deny? }[] }`
|
|
201
|
+
5. Conversation type context (chat/DM/group behavior)
|
|
202
|
+
|
|
203
|
+
### Message ACL
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
// Group/DM: @agent responses visible only to sender
|
|
207
|
+
MessageAclService.assignACL('group', hasAgentMention, userId)
|
|
208
|
+
// → { visible_to_user_ids: [userId], created_for_user_id: userId }
|
|
209
|
+
|
|
210
|
+
// History filtering: only load messages the user can see
|
|
211
|
+
MessageAclService.filterMessagesByACL(allMessages, userId)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### ChatEngineMessage Types (Streaming Events)
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
type ChatEngineMessage =
|
|
218
|
+
| { type: 'chunk'; content: string }
|
|
219
|
+
| { type: 'tool_call'; toolCall: ToolCall; persistedToolCallId?: string }
|
|
220
|
+
| { type: 'tool_result'; toolResult: ToolResult }
|
|
221
|
+
| { type: 'user_message_saved'; message: Message }
|
|
222
|
+
| { type: 'assistant_message_saved'; message: Message }
|
|
223
|
+
| { type: 'complete' }
|
|
224
|
+
| { type: 'cancelled' }
|
|
225
|
+
| { type: 'error'; error: string }
|
|
226
|
+
| { type: 'usage'; input_tokens: number; output_tokens: number }
|
|
227
|
+
| { type: 'token_limit_warning'; percentage: number }
|
|
228
|
+
| { type: 'progress_start' | 'progress_update' | 'progress_complete' | 'progress_error' }
|
|
229
|
+
| { type: 'status'; status: string }
|
|
230
|
+
| { type: 'debug'; message: string }
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Adding Chat to a New Application
|
|
236
|
+
|
|
237
|
+
### Step 1: Implement Provider Interfaces
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
// Minimal: just AI + storage
|
|
241
|
+
const engine = new ChatEngine({
|
|
242
|
+
aiProvider: new AnthropicAIProvider(), // Or OpenAI, etc.
|
|
243
|
+
storageProvider: new MyDatabaseStorageProvider(), // Firestore, Postgres, etc.
|
|
244
|
+
mcpProvider: new NoOpMCPProvider(), // No tools initially
|
|
245
|
+
visionProvider: new NoOpVisionProvider(), // No images initially
|
|
246
|
+
logger: console,
|
|
247
|
+
})
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Step 2: Create a Durable Object Host
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
class MyChatRoom extends DurableObject {
|
|
254
|
+
private engine: ChatEngine
|
|
255
|
+
|
|
256
|
+
constructor(state, env) {
|
|
257
|
+
super(state, env)
|
|
258
|
+
this.engine = new ChatEngine({ /* providers */ })
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async fetch(request: Request) {
|
|
262
|
+
// WebSocket upgrade → session management → message routing
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Step 3: Connect Client via WebSocket
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
const ws = new ChatWebSocket({
|
|
271
|
+
userId: user.uid,
|
|
272
|
+
conversationId: 'main',
|
|
273
|
+
onMessage: (msg) => {
|
|
274
|
+
switch (msg.type) {
|
|
275
|
+
case 'chunk': /* append streaming text */ break
|
|
276
|
+
case 'tool_call': /* show tool badge */ break
|
|
277
|
+
case 'complete': /* finalize message */ break
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
ws.connect()
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Step 4: Add Tools (Optional)
|
|
285
|
+
|
|
286
|
+
Register local tools or connect MCP servers:
|
|
287
|
+
```typescript
|
|
288
|
+
// Local tools
|
|
289
|
+
registerLocalTool('my_search', { description: '...', input_schema: {...} }, handler)
|
|
290
|
+
|
|
291
|
+
// MCP servers
|
|
292
|
+
mcpProvider = new MCPProvider() // Auto-discovers user's connected servers
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Anti-Patterns
|
|
298
|
+
|
|
299
|
+
### Creating New MCPProvider Per Message
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
// Bad: Loses tool/connection cache on every message
|
|
303
|
+
async handleMessage(data) {
|
|
304
|
+
const mcp = new MCPProvider() // New instance = no cache
|
|
305
|
+
await this.engine.processMessage({ ... })
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Good: Reuse instance across messages (persist in DO)
|
|
309
|
+
constructor() {
|
|
310
|
+
this.mcpProvider = new MCPProvider() // Cached connections/tools
|
|
311
|
+
this.engine = new ChatEngine({ mcpProvider: this.mcpProvider })
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Blocking on Non-Critical Operations
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
// Bad: Analytics blocks the response stream
|
|
319
|
+
await AnalyticsService.trackServerEvent(userId, 'tool_executed', {...})
|
|
320
|
+
onMessage({ type: 'complete' })
|
|
321
|
+
|
|
322
|
+
// Good: Fire-and-forget
|
|
323
|
+
AnalyticsService.trackServerEvent(userId, 'tool_executed', {...}).catch(() => {})
|
|
324
|
+
onMessage({ type: 'complete' })
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Checklist
|
|
330
|
+
|
|
331
|
+
- [ ] All providers injected via constructor (no hard dependencies)
|
|
332
|
+
- [ ] MCPProvider instance persisted across messages for caching
|
|
333
|
+
- [ ] Tool calls persisted with `addToolCall`/`updateToolCall`
|
|
334
|
+
- [ ] Message history truncated before AI call (60K token budget)
|
|
335
|
+
- [ ] Preflight check runs if estimate > 180K tokens
|
|
336
|
+
- [ ] System prompt built with prompt injectors (priority-ordered)
|
|
337
|
+
- [ ] Message ACL applied on save and load
|
|
338
|
+
- [ ] Non-critical operations (analytics, titles) are fire-and-forget
|
|
339
|
+
- [ ] AbortSignal passed through for cancellation support
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Related Patterns
|
|
344
|
+
|
|
345
|
+
- **[WebSocket Manager](./tanstack-cloudflare.websocket-manager.md)**: Client-side WebSocket that connects to ChatRoom DO
|
|
346
|
+
- **[Firebase Firestore](./tanstack-cloudflare.firebase-firestore.md)**: IStorageProvider implementation
|
|
347
|
+
- **[Firebase Auth](./tanstack-cloudflare.firebase-auth.md)**: User auth for message ownership
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
**Status**: Stable
|
|
352
|
+
**Last Updated**: 2026-03-14
|
|
353
|
+
**Contributors**: Community
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# Confirmation Tokens Pattern
|
|
2
|
+
|
|
3
|
+
**Category**: Architecture
|
|
4
|
+
**Applicable To**: TanStack Start + Cloudflare Workers applications with AI-initiated mutations
|
|
5
|
+
**Status**: Stable
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
The Confirmation Token pattern provides a two-step execution flow for dangerous or irreversible operations, especially when initiated by AI tools. Instead of executing a mutation immediately, the system generates a preview of the action along with a single-use confirmation token. The user (or AI confirmation tool) must explicitly confirm the action by presenting the token, which is then consumed to execute the operation.
|
|
12
|
+
|
|
13
|
+
This pattern prevents accidental mutations, provides a human-in-the-loop checkpoint for AI operations, and ensures that the exact operation previewed is the one that executes (no TOCTOU race conditions).
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## When to Use This Pattern
|
|
18
|
+
|
|
19
|
+
✅ **Use this pattern when:**
|
|
20
|
+
- AI agents can trigger mutations (create, update, delete operations)
|
|
21
|
+
- Operations are irreversible or have significant side effects
|
|
22
|
+
- Want a human-in-the-loop confirmation step
|
|
23
|
+
- Need to prevent accidental execution of dangerous operations
|
|
24
|
+
- Building tools that the AI calls which modify user data
|
|
25
|
+
|
|
26
|
+
❌ **Don't use this pattern when:**
|
|
27
|
+
- Operations are read-only (no mutations)
|
|
28
|
+
- The user is directly clicking a button (standard UI confirmation is sufficient)
|
|
29
|
+
- Operations are trivially reversible
|
|
30
|
+
- Low-risk operations where speed matters more than safety
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Core Principles
|
|
35
|
+
|
|
36
|
+
1. **Two-Step Execution**: Preview + confirm, never direct execution
|
|
37
|
+
2. **Single-Use Tokens**: Each token can only be consumed once — prevents replay
|
|
38
|
+
3. **User-Scoped**: Tokens validate that the confirming user matches the initiating user
|
|
39
|
+
4. **TTL Expiration**: Tokens expire after a configurable time (default: 5 minutes)
|
|
40
|
+
5. **Tamper-Proof**: Token encodes the exact operation parameters — confirming executes exactly what was previewed
|
|
41
|
+
6. **In-Memory Store**: Tokens stored in memory within the Durable Object session (no database overhead)
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Implementation
|
|
46
|
+
|
|
47
|
+
### Step 1: Define the Token Service
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// src/services/confirmation-token.service.ts
|
|
51
|
+
import { randomBytes } from 'node:crypto'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A pending action stored against a confirmation token.
|
|
55
|
+
* Encodes the exact operation parameters to prevent tampering.
|
|
56
|
+
*/
|
|
57
|
+
export interface PendingAction {
|
|
58
|
+
type: 'create_group' | 'update_group' | 'generate_group_link' | 'delete_resource'
|
|
59
|
+
userId: string
|
|
60
|
+
params: Record<string, unknown>
|
|
61
|
+
summary: string
|
|
62
|
+
createdAt: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Module-level singleton — persists across calls within the same Durable Object session.
|
|
69
|
+
*/
|
|
70
|
+
let _instance: ConfirmationTokenService | null = null
|
|
71
|
+
export function getConfirmationTokenService(): ConfirmationTokenService {
|
|
72
|
+
if (!_instance) _instance = new ConfirmationTokenService()
|
|
73
|
+
return _instance
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class ConfirmationTokenService {
|
|
77
|
+
private pending = new Map<string, PendingAction>()
|
|
78
|
+
private ttlMs: number
|
|
79
|
+
|
|
80
|
+
constructor(ttlMs: number = DEFAULT_TTL_MS) {
|
|
81
|
+
this.ttlMs = ttlMs
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Generate a confirmation token for a pending action.
|
|
86
|
+
* Returns a 32-char hex string.
|
|
87
|
+
*/
|
|
88
|
+
generateToken(action: PendingAction): string {
|
|
89
|
+
this.cleanup()
|
|
90
|
+
const token = randomBytes(16).toString('hex')
|
|
91
|
+
this.pending.set(token, action)
|
|
92
|
+
return token
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Consume a confirmation token.
|
|
97
|
+
* Returns the pending action if valid, null otherwise.
|
|
98
|
+
* Tokens are single-use — consumed on retrieval.
|
|
99
|
+
* Validates userId matches the original initiator.
|
|
100
|
+
*/
|
|
101
|
+
consumeToken(token: string, userId: string): PendingAction | null {
|
|
102
|
+
const action = this.pending.get(token)
|
|
103
|
+
if (!action) return null
|
|
104
|
+
|
|
105
|
+
// Always delete — single use
|
|
106
|
+
this.pending.delete(token)
|
|
107
|
+
|
|
108
|
+
// Check TTL
|
|
109
|
+
if (Date.now() - action.createdAt > this.ttlMs) return null
|
|
110
|
+
|
|
111
|
+
// Validate userId matches
|
|
112
|
+
if (action.userId !== userId) return null
|
|
113
|
+
|
|
114
|
+
return action
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Lazy cleanup of expired tokens.
|
|
119
|
+
*/
|
|
120
|
+
private cleanup(): void {
|
|
121
|
+
const now = Date.now()
|
|
122
|
+
for (const [token, action] of this.pending) {
|
|
123
|
+
if (now - action.createdAt > this.ttlMs) {
|
|
124
|
+
this.pending.delete(token)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Step 2: Create a Mutating Tool (Preview + Token)
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
// src/lib/chat/tools/create-group.ts
|
|
135
|
+
import { getConfirmationTokenService } from '@/services/confirmation-token.service'
|
|
136
|
+
|
|
137
|
+
export const createGroupTool = {
|
|
138
|
+
name: 'create_group',
|
|
139
|
+
description: 'Create a new group conversation. Returns a preview and confirmation token.',
|
|
140
|
+
input_schema: {
|
|
141
|
+
type: 'object' as const,
|
|
142
|
+
properties: {
|
|
143
|
+
name: { type: 'string', description: 'Group name' },
|
|
144
|
+
description: { type: 'string', description: 'Group description' },
|
|
145
|
+
},
|
|
146
|
+
required: ['name'],
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async execute(input: { name: string; description?: string }, userId: string) {
|
|
150
|
+
const tokenService = getConfirmationTokenService()
|
|
151
|
+
|
|
152
|
+
// Generate preview + token (don't execute yet)
|
|
153
|
+
const token = tokenService.generateToken({
|
|
154
|
+
type: 'create_group',
|
|
155
|
+
userId,
|
|
156
|
+
params: { name: input.name, description: input.description },
|
|
157
|
+
summary: `Create group "${input.name}"`,
|
|
158
|
+
createdAt: Date.now(),
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
preview: {
|
|
163
|
+
action: 'create_group',
|
|
164
|
+
name: input.name,
|
|
165
|
+
description: input.description || '(none)',
|
|
166
|
+
},
|
|
167
|
+
confirmation_token: token,
|
|
168
|
+
message: `I'll create a group called "${input.name}". Please confirm to proceed.`,
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Step 3: Create a Confirm Tool (Execute with Token)
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
// src/lib/chat/tools/confirm.ts
|
|
178
|
+
import { getConfirmationTokenService } from '@/services/confirmation-token.service'
|
|
179
|
+
import { GroupConversationDatabaseService } from '@/services/group-conversation-database.service'
|
|
180
|
+
|
|
181
|
+
export const confirmTool = {
|
|
182
|
+
name: 'confirm_action',
|
|
183
|
+
description: 'Confirm and execute a previously previewed action using its confirmation token.',
|
|
184
|
+
input_schema: {
|
|
185
|
+
type: 'object' as const,
|
|
186
|
+
properties: {
|
|
187
|
+
confirmation_token: { type: 'string', description: 'The token from the preview step' },
|
|
188
|
+
},
|
|
189
|
+
required: ['confirmation_token'],
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async execute(input: { confirmation_token: string }, userId: string) {
|
|
193
|
+
const tokenService = getConfirmationTokenService()
|
|
194
|
+
const action = tokenService.consumeToken(input.confirmation_token, userId)
|
|
195
|
+
|
|
196
|
+
if (!action) {
|
|
197
|
+
return {
|
|
198
|
+
success: false,
|
|
199
|
+
error: 'Invalid, expired, or already-used confirmation token.',
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Execute the confirmed action
|
|
204
|
+
switch (action.type) {
|
|
205
|
+
case 'create_group': {
|
|
206
|
+
const { name, description } = action.params as { name: string; description?: string }
|
|
207
|
+
const group = await GroupConversationDatabaseService.createGroupConversation(
|
|
208
|
+
userId,
|
|
209
|
+
{ name, description }
|
|
210
|
+
)
|
|
211
|
+
return { success: true, group, message: `Group "${name}" created successfully.` }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
case 'delete_resource': {
|
|
215
|
+
// ... handle deletion
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
default:
|
|
219
|
+
return { success: false, error: `Unknown action type: ${action.type}` }
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Flow Diagram
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
User: "Create a group called Book Club"
|
|
231
|
+
↓
|
|
232
|
+
AI calls create_group tool
|
|
233
|
+
↓
|
|
234
|
+
Tool returns: { preview: {...}, confirmation_token: "abc123", message: "Please confirm" }
|
|
235
|
+
↓
|
|
236
|
+
AI shows preview to user: "I'll create a group called Book Club. Please confirm."
|
|
237
|
+
↓
|
|
238
|
+
User: "Yes, go ahead"
|
|
239
|
+
↓
|
|
240
|
+
AI calls confirm_action tool with token "abc123"
|
|
241
|
+
↓
|
|
242
|
+
Token consumed → action executed → group created
|
|
243
|
+
↓
|
|
244
|
+
AI: "Done! Group 'Book Club' has been created."
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Security Properties
|
|
250
|
+
|
|
251
|
+
| Property | How It's Enforced |
|
|
252
|
+
|----------|-------------------|
|
|
253
|
+
| **Single-use** | Token deleted on consumption |
|
|
254
|
+
| **Time-limited** | TTL check (default 5 min) |
|
|
255
|
+
| **User-scoped** | userId verified on consumption |
|
|
256
|
+
| **Tamper-proof** | Action params encoded in token, not in confirm request |
|
|
257
|
+
| **No replay** | Consumed tokens return null |
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Benefits
|
|
262
|
+
|
|
263
|
+
### 1. Human-in-the-Loop
|
|
264
|
+
AI shows a preview before executing, giving users a chance to cancel.
|
|
265
|
+
|
|
266
|
+
### 2. Prevents Accidental Mutations
|
|
267
|
+
No direct execution path — always preview first.
|
|
268
|
+
|
|
269
|
+
### 3. TOCTOU Safety
|
|
270
|
+
The exact operation previewed is what executes (params stored in token, not re-sent).
|
|
271
|
+
|
|
272
|
+
### 4. Zero Database Overhead
|
|
273
|
+
In-memory token store within the Durable Object session — no database writes.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Trade-offs
|
|
278
|
+
|
|
279
|
+
### 1. In-Memory Volatility
|
|
280
|
+
**Downside**: If the Durable Object restarts, all pending tokens are lost.
|
|
281
|
+
**Mitigation**: 5-minute TTL means most tokens are consumed quickly. Users can retry.
|
|
282
|
+
|
|
283
|
+
### 2. Extra Round Trip
|
|
284
|
+
**Downside**: Two tool calls instead of one for every mutation.
|
|
285
|
+
**Mitigation**: Only use for high-impact operations. Read-only tools execute directly.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Anti-Patterns
|
|
290
|
+
|
|
291
|
+
### ❌ Anti-Pattern 1: Executing Without Token
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
// ❌ BAD: Tool directly executes mutation
|
|
295
|
+
async execute(input, userId) {
|
|
296
|
+
const group = await service.createGroup(userId, input) // No confirmation!
|
|
297
|
+
return { group }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ✅ GOOD: Tool returns preview + token
|
|
301
|
+
async execute(input, userId) {
|
|
302
|
+
const token = tokenService.generateToken({ type: 'create_group', userId, params: input })
|
|
303
|
+
return { preview: input, confirmation_token: token }
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### ❌ Anti-Pattern 2: Re-Sending Params in Confirm
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
// ❌ BAD: Confirm re-sends params (can be tampered)
|
|
311
|
+
confirm_action({ token: 'abc', name: 'Evil Group' }) // Name changed!
|
|
312
|
+
|
|
313
|
+
// ✅ GOOD: Params come from the stored token
|
|
314
|
+
const action = tokenService.consumeToken(token, userId)
|
|
315
|
+
const { name } = action.params // Params from original preview
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Related Patterns
|
|
321
|
+
|
|
322
|
+
- **[ACL Permissions](./tanstack-cloudflare.acl-permissions.md)**: Permission checks happen before token generation
|
|
323
|
+
- **[Durable Objects WebSocket](./tanstack-cloudflare.durable-objects-websocket.md)**: Token service lives within DO session
|
|
324
|
+
- **[API Route Handlers](./tanstack-cloudflare.api-route-handlers.md)**: Can also be used in HTTP API flows
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Checklist for Implementation
|
|
329
|
+
|
|
330
|
+
- [ ] `ConfirmationTokenService` with `generateToken` and `consumeToken`
|
|
331
|
+
- [ ] Tokens are cryptographically random (32-char hex)
|
|
332
|
+
- [ ] Single-use: deleted on consumption
|
|
333
|
+
- [ ] TTL enforced (default 5 minutes)
|
|
334
|
+
- [ ] User ID validated on consumption
|
|
335
|
+
- [ ] Action params stored in token, not re-sent on confirm
|
|
336
|
+
- [ ] Mutating tools return preview + token
|
|
337
|
+
- [ ] Confirm tool consumes token and executes stored action
|
|
338
|
+
- [ ] Expired/invalid tokens return clear error messages
|
|
339
|
+
- [ ] Lazy cleanup of expired tokens on new generation
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
**Status**: Stable - Proven pattern for AI-initiated mutations
|
|
344
|
+
**Recommendation**: Use for all mutating operations triggered by AI tools
|
|
345
|
+
**Last Updated**: 2026-02-28
|
|
346
|
+
**Contributors**: Patrick Michaelsen
|