@kodelyth/synology-chat 2026.5.39 → 2026.5.42

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 (48) hide show
  1. package/api.ts +3 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/contract-api.ts +1 -0
  4. package/dist/api.js +3 -0
  5. package/dist/channel-DL2_2tLQ.js +1233 -0
  6. package/dist/channel-plugin-api.js +2 -0
  7. package/dist/contract-api.js +2 -0
  8. package/dist/index.js +18 -0
  9. package/dist/security-audit-Zu_nkF2x.js +14 -0
  10. package/dist/setup-api.js +2 -0
  11. package/dist/setup-entry.js +11 -0
  12. package/dist/setup-surface-BHDzBWdx.js +334 -0
  13. package/index.ts +16 -0
  14. package/klaw.plugin.json +1 -22
  15. package/package.json +3 -3
  16. package/setup-api.ts +1 -0
  17. package/setup-entry.ts +9 -0
  18. package/src/accounts.ts +151 -0
  19. package/src/approval-auth.test.ts +17 -0
  20. package/src/approval-auth.ts +22 -0
  21. package/src/channel.integration.test.ts +204 -0
  22. package/src/channel.test-mocks.ts +176 -0
  23. package/src/channel.test.ts +693 -0
  24. package/src/channel.ts +435 -0
  25. package/src/client.test.ts +399 -0
  26. package/src/client.ts +326 -0
  27. package/src/config-schema.ts +11 -0
  28. package/src/core.test.ts +427 -0
  29. package/src/gateway-runtime.ts +212 -0
  30. package/src/inbound-context.ts +10 -0
  31. package/src/inbound-event.ts +175 -0
  32. package/src/runtime.ts +8 -0
  33. package/src/security-audit.test.ts +72 -0
  34. package/src/security-audit.ts +28 -0
  35. package/src/security.ts +107 -0
  36. package/src/session-key.ts +21 -0
  37. package/src/setup-surface.ts +334 -0
  38. package/src/test-http-utils.ts +75 -0
  39. package/src/types.ts +59 -0
  40. package/src/webhook-handler.test.ts +644 -0
  41. package/src/webhook-handler.ts +652 -0
  42. package/tsconfig.json +16 -0
  43. package/api.js +0 -7
  44. package/channel-plugin-api.js +0 -7
  45. package/contract-api.js +0 -7
  46. package/index.js +0 -7
  47. package/setup-api.js +0 -7
  48. package/setup-entry.js +0 -7
@@ -0,0 +1,175 @@
1
+ import type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
2
+ import { sendMessage } from "./client.js";
3
+ import type { SynologyInboundMessage } from "./inbound-context.js";
4
+ import { getSynologyRuntime } from "./runtime.js";
5
+ import { buildSynologyChatInboundSessionKey } from "./session-key.js";
6
+ import type { ResolvedSynologyChatAccount } from "./types.js";
7
+
8
+ const CHANNEL_ID = "synology-chat";
9
+
10
+ type SynologyChannelLog = {
11
+ info?: (...args: unknown[]) => void;
12
+ };
13
+
14
+ function resolveSynologyChatInboundRoute(params: {
15
+ cfg: KlawConfig;
16
+ account: ResolvedSynologyChatAccount;
17
+ userId: string;
18
+ }) {
19
+ const rt = getSynologyRuntime();
20
+ const route = rt.channel.routing.resolveAgentRoute({
21
+ cfg: params.cfg,
22
+ channel: CHANNEL_ID,
23
+ accountId: params.account.accountId,
24
+ peer: {
25
+ kind: "direct",
26
+ id: params.userId,
27
+ },
28
+ });
29
+ return {
30
+ rt,
31
+ route,
32
+ sessionKey: buildSynologyChatInboundSessionKey({
33
+ agentId: route.agentId,
34
+ accountId: params.account.accountId,
35
+ userId: params.userId,
36
+ identityLinks: params.cfg.session?.identityLinks,
37
+ }),
38
+ };
39
+ }
40
+
41
+ async function deliverSynologyChatReply(params: {
42
+ account: ResolvedSynologyChatAccount;
43
+ sendUserId: string;
44
+ payload: { text?: string; body?: string };
45
+ }): Promise<{ visibleReplySent: boolean }> {
46
+ const text = params.payload.text ?? params.payload.body;
47
+ if (!text) {
48
+ return { visibleReplySent: false };
49
+ }
50
+ const ok = await sendMessage(
51
+ params.account.incomingUrl,
52
+ text,
53
+ params.sendUserId,
54
+ params.account.allowInsecureSsl,
55
+ );
56
+ return { visibleReplySent: ok };
57
+ }
58
+
59
+ export async function dispatchSynologyChatInboundEvent(params: {
60
+ account: ResolvedSynologyChatAccount;
61
+ msg: SynologyInboundMessage;
62
+ log?: SynologyChannelLog;
63
+ }): Promise<null> {
64
+ const rt = getSynologyRuntime();
65
+ const currentCfg = rt.config.current() as KlawConfig;
66
+
67
+ // The Chat API user_id (for sending) may differ from the webhook
68
+ // user_id (used for sessions/pairing). Use chatUserId for API calls.
69
+ const sendUserId = params.msg.chatUserId ?? params.msg.from;
70
+ const resolved = resolveSynologyChatInboundRoute({
71
+ cfg: currentCfg,
72
+ account: params.account,
73
+ userId: params.msg.from,
74
+ });
75
+
76
+ await resolved.rt.channel.turn.run({
77
+ channel: CHANNEL_ID,
78
+ accountId: params.account.accountId,
79
+ raw: params.msg,
80
+ adapter: {
81
+ ingest: (msg) => ({
82
+ id: `${params.account.accountId}:${msg.from}`,
83
+ timestamp: Date.now(),
84
+ rawText: msg.body,
85
+ textForAgent: msg.body,
86
+ textForCommands: msg.body,
87
+ raw: msg,
88
+ }),
89
+ resolveTurn: (input) => {
90
+ const chatKind =
91
+ params.msg.chatType === "group" || params.msg.chatType === "channel"
92
+ ? params.msg.chatType
93
+ : "direct";
94
+ const msgCtx = resolved.rt.channel.turn.buildContext({
95
+ channel: CHANNEL_ID,
96
+ accountId: params.account.accountId,
97
+ timestamp: input.timestamp,
98
+ from: `synology-chat:${params.msg.from}`,
99
+ sender: {
100
+ id: params.msg.from,
101
+ name: params.msg.senderName,
102
+ },
103
+ conversation: {
104
+ kind: chatKind,
105
+ id: params.msg.from,
106
+ label: params.msg.senderName || params.msg.from,
107
+ routePeer: {
108
+ kind: "direct",
109
+ id: params.msg.from,
110
+ },
111
+ },
112
+ route: {
113
+ agentId: resolved.route.agentId,
114
+ accountId: params.account.accountId,
115
+ routeSessionKey: resolved.sessionKey,
116
+ dispatchSessionKey: resolved.sessionKey,
117
+ },
118
+ reply: {
119
+ to: `synology-chat:${params.msg.from}`,
120
+ originatingTo: `synology-chat:${params.msg.from}`,
121
+ },
122
+ message: {
123
+ rawBody: input.rawText,
124
+ commandBody: input.textForCommands,
125
+ bodyForAgent: input.textForAgent,
126
+ envelopeFrom: params.msg.senderName,
127
+ },
128
+ extra: {
129
+ ChatType: params.msg.chatType,
130
+ CommandAuthorized: params.msg.commandAuthorized,
131
+ },
132
+ });
133
+ const storePath = resolved.rt.channel.session.resolveStorePath(currentCfg.session?.store, {
134
+ agentId: resolved.route.agentId,
135
+ });
136
+ return {
137
+ cfg: currentCfg,
138
+ channel: CHANNEL_ID,
139
+ accountId: params.account.accountId,
140
+ agentId: resolved.route.agentId,
141
+ routeSessionKey: resolved.route.sessionKey,
142
+ storePath,
143
+ ctxPayload: msgCtx,
144
+ recordInboundSession: resolved.rt.channel.session.recordInboundSession,
145
+ dispatchReplyWithBufferedBlockDispatcher:
146
+ resolved.rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
147
+ delivery: {
148
+ durable: () => ({
149
+ to: sendUserId,
150
+ }),
151
+ deliver: async (payload) => {
152
+ return await deliverSynologyChatReply({
153
+ account: params.account,
154
+ sendUserId,
155
+ payload,
156
+ });
157
+ },
158
+ },
159
+ dispatcherOptions: {
160
+ onReplyStart: () => {
161
+ params.log?.info?.(`Agent reply started for ${params.msg.from}`);
162
+ },
163
+ },
164
+ record: {
165
+ onRecordError: (err) => {
166
+ params.log?.info?.(`Session metadata update failed for ${params.msg.from}`, err);
167
+ },
168
+ },
169
+ };
170
+ },
171
+ },
172
+ });
173
+
174
+ return null;
175
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { createPluginRuntimeStore, type PluginRuntime } from "klaw/plugin-sdk/runtime-store";
2
+
3
+ const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =
4
+ createPluginRuntimeStore<PluginRuntime>({
5
+ pluginId: "synology-chat",
6
+ errorMessage: "Synology Chat runtime not initialized - plugin not registered",
7
+ });
8
+ export { getSynologyRuntime, setSynologyRuntime };
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { collectSynologyChatSecurityAuditFindings } from "./security-audit.js";
3
+ import type { ResolvedSynologyChatAccount } from "./types.js";
4
+
5
+ function createAccount(params: {
6
+ accountId: string;
7
+ dangerouslyAllowNameMatching?: boolean;
8
+ }): ResolvedSynologyChatAccount {
9
+ return {
10
+ accountId: params.accountId,
11
+ enabled: true,
12
+ token: "t",
13
+ incomingUrl: "https://nas.example.com/incoming",
14
+ nasHost: "https://nas.example.com",
15
+ webhookPath: "/webapi/entry.cgi",
16
+ webhookPathSource: "explicit",
17
+ dangerouslyAllowNameMatching: params.dangerouslyAllowNameMatching ?? false,
18
+ dangerouslyAllowInheritedWebhookPath: false,
19
+ dmPolicy: "allowlist",
20
+ allowedUserIds: [],
21
+ rateLimitPerMinute: 30,
22
+ botName: "Klaw",
23
+ allowInsecureSsl: false,
24
+ };
25
+ }
26
+
27
+ describe("Synology Chat security audit findings", () => {
28
+ it.each([
29
+ {
30
+ name: "audits base dangerous name matching",
31
+ accountId: "default",
32
+ orderedAccountIds: [] as string[],
33
+ hasExplicitAccountPath: false,
34
+ expectedFinding: {
35
+ checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
36
+ severity: "info",
37
+ title: "Synology Chat dangerous name matching is enabled",
38
+ detail:
39
+ "dangerouslyAllowNameMatching=true re-enables mutable username/nickname matching for reply delivery. This is a break-glass compatibility mode, not a hardened default.",
40
+ remediation:
41
+ "Prefer stable numeric Synology Chat user IDs for reply delivery, then disable dangerouslyAllowNameMatching.",
42
+ },
43
+ },
44
+ {
45
+ name: "audits non-default accounts for dangerous name matching",
46
+ accountId: "beta",
47
+ orderedAccountIds: ["alpha", "beta"],
48
+ hasExplicitAccountPath: true,
49
+ expectedFinding: {
50
+ checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
51
+ severity: "info",
52
+ title: "Synology Chat dangerous name matching is enabled (account: beta)",
53
+ detail:
54
+ "dangerouslyAllowNameMatching=true re-enables mutable username/nickname matching for reply delivery. This is a break-glass compatibility mode, not a hardened default.",
55
+ remediation:
56
+ "Prefer stable numeric Synology Chat user IDs for reply delivery, then disable dangerouslyAllowNameMatching.",
57
+ },
58
+ },
59
+ ])("$name", (testCase) => {
60
+ const findings = collectSynologyChatSecurityAuditFindings({
61
+ account: createAccount({
62
+ accountId: testCase.accountId,
63
+ dangerouslyAllowNameMatching: true,
64
+ }),
65
+ accountId: testCase.accountId,
66
+ orderedAccountIds: testCase.orderedAccountIds,
67
+ hasExplicitAccountPath: testCase.hasExplicitAccountPath,
68
+ });
69
+
70
+ expect(findings).toEqual([testCase.expectedFinding]);
71
+ });
72
+ });
@@ -0,0 +1,28 @@
1
+ import type { ResolvedSynologyChatAccount } from "./types.js";
2
+
3
+ export function collectSynologyChatSecurityAuditFindings(params: {
4
+ accountId?: string | null;
5
+ account: ResolvedSynologyChatAccount;
6
+ orderedAccountIds: string[];
7
+ hasExplicitAccountPath: boolean;
8
+ }) {
9
+ if (!params.account.dangerouslyAllowNameMatching) {
10
+ return [];
11
+ }
12
+ const accountId = params.accountId?.trim() || params.account.accountId || "default";
13
+ const accountNote =
14
+ params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
15
+ ? ` (account: ${accountId})`
16
+ : "";
17
+ return [
18
+ {
19
+ checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
20
+ severity: "info" as const,
21
+ title: `Synology Chat dangerous name matching is enabled${accountNote}`,
22
+ detail:
23
+ "dangerouslyAllowNameMatching=true re-enables mutable username/nickname matching for reply delivery. This is a break-glass compatibility mode, not a hardened default.",
24
+ remediation:
25
+ "Prefer stable numeric Synology Chat user IDs for reply delivery, then disable dangerouslyAllowNameMatching.",
26
+ },
27
+ ];
28
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Security module: token validation, rate limiting, input sanitization, user allowlist.
3
+ */
4
+
5
+ import { resolveStableChannelMessageIngress } from "klaw/plugin-sdk/channel-ingress-runtime";
6
+ import { safeEqualSecret } from "klaw/plugin-sdk/security-runtime";
7
+ import {
8
+ createFixedWindowRateLimiter,
9
+ type FixedWindowRateLimiter,
10
+ } from "klaw/plugin-sdk/webhook-ingress";
11
+
12
+ /**
13
+ * Validate webhook token using constant-time comparison.
14
+ * Reject empty tokens explicitly; use shared constant-time comparison otherwise.
15
+ */
16
+ export function validateToken(received: string, expected: string): boolean {
17
+ if (!received || !expected) {
18
+ return false;
19
+ }
20
+ return safeEqualSecret(received, expected);
21
+ }
22
+
23
+ export async function authorizeUserForDmWithIngress(params: {
24
+ accountId: string;
25
+ userId: string;
26
+ dmPolicy: "open" | "allowlist" | "disabled";
27
+ allowedUserIds: string[];
28
+ }) {
29
+ return await resolveStableChannelMessageIngress({
30
+ channelId: "synology-chat",
31
+ accountId: params.accountId,
32
+ identity: {
33
+ key: "sender-id",
34
+ entryIdPrefix: "synology-chat-entry",
35
+ },
36
+ subject: { stableId: params.userId },
37
+ conversation: {
38
+ kind: "direct",
39
+ id: "direct",
40
+ },
41
+ event: { mayPair: false },
42
+ dmPolicy: params.dmPolicy,
43
+ allowFrom: params.allowedUserIds,
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Sanitize user input to prevent prompt injection attacks.
49
+ * Filters known dangerous patterns and truncates long messages.
50
+ */
51
+ export function sanitizeInput(text: string): string {
52
+ const dangerousPatterns = [
53
+ /ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|prompts?)/gi,
54
+ /you\s+are\s+now\s+/gi,
55
+ /system:\s*/gi,
56
+ /<\|.*?\|>/g, // special tokens
57
+ ];
58
+
59
+ let sanitized = text;
60
+ for (const pattern of dangerousPatterns) {
61
+ sanitized = sanitized.replace(pattern, "[FILTERED]");
62
+ }
63
+
64
+ const maxLength = 4000;
65
+ if (sanitized.length > maxLength) {
66
+ sanitized = sanitized.slice(0, maxLength) + "... [truncated]";
67
+ }
68
+
69
+ return sanitized;
70
+ }
71
+
72
+ /**
73
+ * Sliding window rate limiter per user ID.
74
+ */
75
+ export class RateLimiter {
76
+ private readonly limiter: FixedWindowRateLimiter;
77
+ private readonly limit: number;
78
+
79
+ constructor(limit = 30, windowSeconds = 60, maxTrackedUsers = 5_000) {
80
+ this.limit = limit;
81
+ this.limiter = createFixedWindowRateLimiter({
82
+ windowMs: Math.max(1, Math.floor(windowSeconds * 1000)),
83
+ maxRequests: Math.max(1, Math.floor(limit)),
84
+ maxTrackedKeys: Math.max(1, Math.floor(maxTrackedUsers)),
85
+ });
86
+ }
87
+
88
+ /** Returns true if the request is allowed, false if rate-limited. */
89
+ check(userId: string): boolean {
90
+ return !this.limiter.isRateLimited(userId);
91
+ }
92
+
93
+ /** Exposed for tests and diagnostics. */
94
+ size(): number {
95
+ return this.limiter.size();
96
+ }
97
+
98
+ /** Exposed for tests and account lifecycle cleanup. */
99
+ clear(): void {
100
+ this.limiter.clear();
101
+ }
102
+
103
+ /** Exposed for tests. */
104
+ maxRequests(): number {
105
+ return this.limit;
106
+ }
107
+ }
@@ -0,0 +1,21 @@
1
+ import { buildAgentSessionKey } from "klaw/plugin-sdk/routing";
2
+
3
+ const CHANNEL_ID = "synology-chat";
4
+
5
+ export function buildSynologyChatInboundSessionKey(params: {
6
+ agentId: string;
7
+ accountId: string;
8
+ userId: string;
9
+ identityLinks?: Record<string, string[]>;
10
+ }): string {
11
+ return buildAgentSessionKey({
12
+ agentId: params.agentId,
13
+ channel: CHANNEL_ID,
14
+ accountId: params.accountId,
15
+ peer: { kind: "direct", id: params.userId },
16
+ // Synology Chat supports multiple independent accounts on one gateway.
17
+ // Keep direct-message sessions isolated per account and user.
18
+ dmScope: "per-account-channel-peer",
19
+ identityLinks: params.identityLinks,
20
+ });
21
+ }