@openclaw/nextcloud-talk 2026.3.12 → 2026.5.1-beta.2
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/api.ts +1 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +4 -0
- package/doctor-contract-api.ts +1 -0
- package/index.ts +15 -12
- package/openclaw.plugin.json +804 -1
- package/package.json +31 -4
- package/runtime-api.ts +33 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.ts +25 -42
- package/src/approval-auth.test.ts +17 -0
- package/src/approval-auth.ts +27 -0
- package/src/channel-api.ts +5 -0
- package/src/channel.adapters.ts +52 -0
- package/src/channel.core.test.ts +75 -0
- package/src/channel.lifecycle.test.ts +81 -0
- package/src/channel.ts +157 -388
- package/src/config-schema.ts +27 -22
- package/src/core.test.ts +397 -0
- package/src/doctor-contract.ts +9 -0
- package/src/doctor.test.ts +40 -0
- package/src/doctor.ts +10 -0
- package/src/gateway.ts +109 -0
- package/src/inbound.authz.test.ts +87 -22
- package/src/inbound.behavior.test.ts +202 -0
- package/src/inbound.ts +28 -26
- package/src/monitor-runtime.ts +138 -0
- package/src/monitor.replay.test.ts +238 -0
- package/src/monitor.test-harness.ts +2 -2
- package/src/monitor.ts +125 -155
- package/src/normalize.ts +7 -2
- package/src/policy.ts +23 -31
- package/src/replay-guard.ts +74 -11
- package/src/room-info.test.ts +116 -0
- package/src/room-info.ts +11 -3
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +103 -0
- package/src/secret-input.ts +1 -10
- package/src/send.cfg-threading.test.ts +153 -0
- package/src/send.runtime.ts +8 -0
- package/src/send.ts +126 -106
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +248 -0
- package/src/setup-surface.ts +190 -0
- package/src/setup.test.ts +422 -0
- package/src/signature.ts +20 -10
- package/src/types.ts +19 -16
- package/tsconfig.json +16 -0
- package/src/accounts.test.ts +0 -30
- package/src/channel.startup.test.ts +0 -83
- package/src/config-schema.test.ts +0 -36
- package/src/format.ts +0 -79
- package/src/monitor.auth-order.test.ts +0 -28
- package/src/monitor.backend.test.ts +0 -27
- package/src/monitor.read-body.test.ts +0 -16
- package/src/onboarding.ts +0 -302
- package/src/policy.test.ts +0 -138
- package/src/replay-guard.test.ts +0 -70
- package/src/send.test.ts +0 -104
|
@@ -1,9 +1,112 @@
|
|
|
1
|
+
import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
|
|
1
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
NextcloudTalkRetryableWebhookError,
|
|
5
|
+
processNextcloudTalkReplayGuardedMessage,
|
|
6
|
+
readNextcloudTalkWebhookBody,
|
|
7
|
+
} from "./monitor.js";
|
|
2
8
|
import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js";
|
|
3
9
|
import { startWebhookServer } from "./monitor.test-harness.js";
|
|
10
|
+
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
|
|
11
|
+
import { generateNextcloudTalkSignature } from "./signature.js";
|
|
4
12
|
import type { NextcloudTalkInboundMessage } from "./types.js";
|
|
5
13
|
|
|
14
|
+
describe("readNextcloudTalkWebhookBody", () => {
|
|
15
|
+
it("reads valid body within max bytes", async () => {
|
|
16
|
+
const req = createMockIncomingRequest(['{"type":"Create"}']);
|
|
17
|
+
const body = await readNextcloudTalkWebhookBody(req, 1024);
|
|
18
|
+
expect(body).toBe('{"type":"Create"}');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("rejects when payload exceeds max bytes", async () => {
|
|
22
|
+
const req = createMockIncomingRequest(["x".repeat(300)]);
|
|
23
|
+
await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("createNextcloudTalkWebhookServer auth order", () => {
|
|
28
|
+
it("rejects missing signature headers before reading request body", async () => {
|
|
29
|
+
const readBody = vi.fn(async () => {
|
|
30
|
+
throw new Error("should not be called for missing signature headers");
|
|
31
|
+
});
|
|
32
|
+
const harness = await startWebhookServer({
|
|
33
|
+
path: "/nextcloud-auth-order",
|
|
34
|
+
maxBodyBytes: 128,
|
|
35
|
+
readBody,
|
|
36
|
+
onMessage: vi.fn(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const response = await fetch(harness.webhookUrl, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
"content-type": "application/json",
|
|
43
|
+
},
|
|
44
|
+
body: "{}",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(response.status).toBe(400);
|
|
48
|
+
expect(await response.json()).toEqual({ error: "Missing signature headers" });
|
|
49
|
+
expect(readBody).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("createNextcloudTalkWebhookServer backend allowlist", () => {
|
|
54
|
+
it("rejects requests from unexpected backend origins", async () => {
|
|
55
|
+
const onMessage = vi.fn(async () => {});
|
|
56
|
+
const harness = await startWebhookServer({
|
|
57
|
+
path: "/nextcloud-backend-check",
|
|
58
|
+
isBackendAllowed: (backend) => backend === "https://nextcloud.expected",
|
|
59
|
+
onMessage,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const { body, headers } = createSignedCreateMessageRequest({
|
|
63
|
+
backend: "https://nextcloud.unexpected",
|
|
64
|
+
});
|
|
65
|
+
const response = await fetch(harness.webhookUrl, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers,
|
|
68
|
+
body,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(response.status).toBe(401);
|
|
72
|
+
expect(await response.json()).toEqual({ error: "Invalid backend" });
|
|
73
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
6
77
|
describe("createNextcloudTalkWebhookServer replay handling", () => {
|
|
78
|
+
function createReplayGuardedProcess(params: {
|
|
79
|
+
stateDir?: string;
|
|
80
|
+
accountId?: string;
|
|
81
|
+
handleMessage: () => Promise<void>;
|
|
82
|
+
}) {
|
|
83
|
+
const replayGuard = createNextcloudTalkReplayGuard(
|
|
84
|
+
params.stateDir ? { stateDir: params.stateDir } : {},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return (message: NextcloudTalkInboundMessage) =>
|
|
88
|
+
processNextcloudTalkReplayGuardedMessage({
|
|
89
|
+
replayGuard,
|
|
90
|
+
accountId: params.accountId ?? "acct",
|
|
91
|
+
message,
|
|
92
|
+
handleMessage: params.handleMessage,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildInboundMessage(): NextcloudTalkInboundMessage {
|
|
97
|
+
return {
|
|
98
|
+
messageId: "msg-1",
|
|
99
|
+
roomToken: "room-token",
|
|
100
|
+
roomName: "Room 1",
|
|
101
|
+
senderId: "alice",
|
|
102
|
+
senderName: "Alice",
|
|
103
|
+
text: "hello",
|
|
104
|
+
mediaType: "text/plain",
|
|
105
|
+
timestamp: 1_700_000_000_000,
|
|
106
|
+
isGroupChat: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
7
110
|
it("acknowledges replayed requests and skips onMessage side effects", async () => {
|
|
8
111
|
const seen = new Set<string>();
|
|
9
112
|
const onMessage = vi.fn(async () => {});
|
|
@@ -38,4 +141,139 @@ describe("createNextcloudTalkWebhookServer replay handling", () => {
|
|
|
38
141
|
expect(shouldProcessMessage).toHaveBeenCalledTimes(2);
|
|
39
142
|
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
40
143
|
});
|
|
144
|
+
|
|
145
|
+
it("allows a retry after replay-guarded processing fails before commit", async () => {
|
|
146
|
+
let attempts = 0;
|
|
147
|
+
const handleMessage = vi.fn(async () => {
|
|
148
|
+
attempts += 1;
|
|
149
|
+
if (attempts === 1) {
|
|
150
|
+
throw new NextcloudTalkRetryableWebhookError("transient nextcloud failure");
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
const processMessage = createReplayGuardedProcess({
|
|
154
|
+
handleMessage,
|
|
155
|
+
});
|
|
156
|
+
const message = buildInboundMessage();
|
|
157
|
+
|
|
158
|
+
await expect(processMessage(message)).rejects.toThrow("transient nextcloud failure");
|
|
159
|
+
await expect(processMessage(message)).resolves.toBe("processed");
|
|
160
|
+
|
|
161
|
+
expect(handleMessage).toHaveBeenCalledTimes(2);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("keeps replay committed after a non-retryable replay-guarded processing failure", async () => {
|
|
165
|
+
const visibleSideEffect = vi.fn();
|
|
166
|
+
const handleMessage = vi.fn(async () => {
|
|
167
|
+
visibleSideEffect();
|
|
168
|
+
throw new Error("post-send failure");
|
|
169
|
+
});
|
|
170
|
+
const processMessage = createReplayGuardedProcess({
|
|
171
|
+
handleMessage,
|
|
172
|
+
});
|
|
173
|
+
const message = buildInboundMessage();
|
|
174
|
+
|
|
175
|
+
await expect(processMessage(message)).rejects.toThrow("post-send failure");
|
|
176
|
+
await expect(processMessage(message)).resolves.toBe("duplicate");
|
|
177
|
+
|
|
178
|
+
expect(handleMessage).toHaveBeenCalledTimes(1);
|
|
179
|
+
expect(visibleSideEffect).toHaveBeenCalledTimes(1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("createNextcloudTalkWebhookServer payload validation", () => {
|
|
184
|
+
it("rejects malformed webhook payloads after signature verification", async () => {
|
|
185
|
+
const payload = {
|
|
186
|
+
type: "Create",
|
|
187
|
+
actor: { type: "Person", id: "alice", name: "Alice" },
|
|
188
|
+
object: {
|
|
189
|
+
type: "Note",
|
|
190
|
+
id: "msg-1",
|
|
191
|
+
name: "hello",
|
|
192
|
+
content: "hello",
|
|
193
|
+
mediaType: "text/plain",
|
|
194
|
+
},
|
|
195
|
+
target: { type: "Collection", id: "", name: "Room 1" },
|
|
196
|
+
};
|
|
197
|
+
const body = JSON.stringify(payload);
|
|
198
|
+
const { random, signature } = generateNextcloudTalkSignature({
|
|
199
|
+
body,
|
|
200
|
+
secret: "nextcloud-secret", // pragma: allowlist secret
|
|
201
|
+
});
|
|
202
|
+
const harness = await startWebhookServer({
|
|
203
|
+
path: "/nextcloud-invalid-payload",
|
|
204
|
+
onMessage: vi.fn(),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const response = await fetch(harness.webhookUrl, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: {
|
|
210
|
+
"content-type": "application/json",
|
|
211
|
+
"x-nextcloud-talk-random": random,
|
|
212
|
+
"x-nextcloud-talk-signature": signature,
|
|
213
|
+
"x-nextcloud-talk-backend": "https://nextcloud.example",
|
|
214
|
+
},
|
|
215
|
+
body,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(response.status).toBe(400);
|
|
219
|
+
expect(await response.json()).toEqual({ error: "Invalid payload format" });
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("createNextcloudTalkWebhookServer auth rate limiting", () => {
|
|
224
|
+
it("rate limits repeated invalid signature attempts from the same source", async () => {
|
|
225
|
+
const maxRequests = 1;
|
|
226
|
+
const harness = await startWebhookServer({
|
|
227
|
+
path: "/nextcloud-auth-rate-limit",
|
|
228
|
+
authRateLimit: { maxRequests },
|
|
229
|
+
onMessage: vi.fn(),
|
|
230
|
+
});
|
|
231
|
+
const { body, headers } = createSignedCreateMessageRequest();
|
|
232
|
+
const invalidHeaders = {
|
|
233
|
+
...headers,
|
|
234
|
+
"x-nextcloud-talk-signature": "invalid-signature",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
let firstResponse: Response | undefined;
|
|
238
|
+
let lastResponse: Response | undefined;
|
|
239
|
+
for (let attempt = 0; attempt <= maxRequests; attempt += 1) {
|
|
240
|
+
const response = await fetch(harness.webhookUrl, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: invalidHeaders,
|
|
243
|
+
body,
|
|
244
|
+
});
|
|
245
|
+
if (attempt === 0) {
|
|
246
|
+
firstResponse = response;
|
|
247
|
+
}
|
|
248
|
+
lastResponse = response;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
expect(firstResponse).toBeDefined();
|
|
252
|
+
expect(firstResponse?.status).toBe(401);
|
|
253
|
+
expect(lastResponse).toBeDefined();
|
|
254
|
+
expect(lastResponse?.status).toBe(429);
|
|
255
|
+
expect(await lastResponse?.text()).toBe("Too Many Requests");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not rate limit valid signed webhook bursts from the same source", async () => {
|
|
259
|
+
const maxRequests = 1;
|
|
260
|
+
const harness = await startWebhookServer({
|
|
261
|
+
path: "/nextcloud-auth-rate-limit-valid",
|
|
262
|
+
authRateLimit: { maxRequests },
|
|
263
|
+
onMessage: vi.fn(),
|
|
264
|
+
});
|
|
265
|
+
const { body, headers } = createSignedCreateMessageRequest();
|
|
266
|
+
|
|
267
|
+
let lastResponse: Response | undefined;
|
|
268
|
+
for (let attempt = 0; attempt <= maxRequests; attempt += 1) {
|
|
269
|
+
lastResponse = await fetch(harness.webhookUrl, {
|
|
270
|
+
method: "POST",
|
|
271
|
+
headers,
|
|
272
|
+
body,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
expect(lastResponse).toBeDefined();
|
|
277
|
+
expect(lastResponse?.status).toBe(200);
|
|
278
|
+
});
|
|
41
279
|
});
|
|
@@ -3,7 +3,7 @@ import { afterEach } from "vitest";
|
|
|
3
3
|
import { createNextcloudTalkWebhookServer } from "./monitor.js";
|
|
4
4
|
import type { NextcloudTalkWebhookServerOptions } from "./types.js";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
type WebhookHarness = {
|
|
7
7
|
webhookUrl: string;
|
|
8
8
|
stop: () => Promise<void>;
|
|
9
9
|
};
|
|
@@ -19,7 +19,7 @@ afterEach(async () => {
|
|
|
19
19
|
}
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
type StartWebhookServerParams = Omit<
|
|
23
23
|
NextcloudTalkWebhookServerOptions,
|
|
24
24
|
"port" | "host" | "path" | "secret"
|
|
25
25
|
> & {
|
package/src/monitor.ts
CHANGED
|
@@ -1,31 +1,47 @@
|
|
|
1
1
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
-
import
|
|
2
|
+
import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
|
5
|
+
createAuthRateLimiter,
|
|
6
6
|
isRequestBodyLimitError,
|
|
7
7
|
readRequestBodyWithLimit,
|
|
8
8
|
requestBodyErrorToText,
|
|
9
|
-
} from "openclaw/plugin-sdk/
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
|
|
13
|
-
import { getNextcloudTalkRuntime } from "./runtime.js";
|
|
9
|
+
} from "openclaw/plugin-sdk/webhook-ingress";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import type { NextcloudTalkReplayGuard } from "./replay-guard.js";
|
|
14
12
|
import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
|
|
15
13
|
import type {
|
|
16
|
-
CoreConfig,
|
|
17
14
|
NextcloudTalkInboundMessage,
|
|
18
15
|
NextcloudTalkWebhookHeaders,
|
|
19
16
|
NextcloudTalkWebhookPayload,
|
|
20
17
|
NextcloudTalkWebhookServerOptions,
|
|
21
18
|
} from "./types.js";
|
|
22
19
|
|
|
23
|
-
const DEFAULT_WEBHOOK_PORT = 8788;
|
|
24
|
-
const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
|
|
25
|
-
const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
|
|
26
20
|
const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
|
27
|
-
const
|
|
21
|
+
const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
|
|
22
|
+
const PREAUTH_WEBHOOK_BODY_TIMEOUT_MS = 5_000;
|
|
28
23
|
const HEALTH_PATH = "/healthz";
|
|
24
|
+
const WEBHOOK_AUTH_RATE_LIMIT_SCOPE = "nextcloud-talk-webhook-auth";
|
|
25
|
+
const NextcloudTalkWebhookPayloadSchema: z.ZodType<NextcloudTalkWebhookPayload> = z.object({
|
|
26
|
+
type: z.enum(["Create", "Update", "Delete"]),
|
|
27
|
+
actor: z.object({
|
|
28
|
+
type: z.literal("Person"),
|
|
29
|
+
id: z.string().min(1),
|
|
30
|
+
name: z.string(),
|
|
31
|
+
}),
|
|
32
|
+
object: z.object({
|
|
33
|
+
type: z.literal("Note"),
|
|
34
|
+
id: z.string().min(1),
|
|
35
|
+
name: z.string(),
|
|
36
|
+
content: z.string(),
|
|
37
|
+
mediaType: z.string(),
|
|
38
|
+
}),
|
|
39
|
+
target: z.object({
|
|
40
|
+
type: z.literal("Collection"),
|
|
41
|
+
id: z.string().min(1),
|
|
42
|
+
name: z.string(),
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
29
45
|
const WEBHOOK_ERRORS = {
|
|
30
46
|
missingSignatureHeaders: "Missing signature headers",
|
|
31
47
|
invalidBackend: "Invalid backend",
|
|
@@ -35,41 +51,68 @@ const WEBHOOK_ERRORS = {
|
|
|
35
51
|
internalServerError: "Internal server error",
|
|
36
52
|
} as const;
|
|
37
53
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
54
|
+
export class NextcloudTalkRetryableWebhookError extends Error {
|
|
55
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
56
|
+
super(message, options);
|
|
57
|
+
this.name = "NextcloudTalkRetryableWebhookError";
|
|
41
58
|
}
|
|
42
|
-
return typeof err === "string" ? err : JSON.stringify(err);
|
|
43
59
|
}
|
|
44
60
|
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
61
|
+
export async function processNextcloudTalkReplayGuardedMessage(params: {
|
|
62
|
+
replayGuard: NextcloudTalkReplayGuard;
|
|
63
|
+
accountId: string;
|
|
64
|
+
message: NextcloudTalkInboundMessage;
|
|
65
|
+
handleMessage: () => Promise<void>;
|
|
66
|
+
}): Promise<"processed" | "duplicate"> {
|
|
67
|
+
const claim = await params.replayGuard.claimMessage({
|
|
68
|
+
accountId: params.accountId,
|
|
69
|
+
roomToken: params.message.roomToken,
|
|
70
|
+
messageId: params.message.messageId,
|
|
71
|
+
});
|
|
72
|
+
if (claim !== "claimed") {
|
|
73
|
+
return "duplicate";
|
|
50
74
|
}
|
|
51
|
-
}
|
|
52
75
|
|
|
53
|
-
function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
|
|
54
76
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
await params.handleMessage();
|
|
78
|
+
await params.replayGuard.commitMessage({
|
|
79
|
+
accountId: params.accountId,
|
|
80
|
+
roomToken: params.message.roomToken,
|
|
81
|
+
messageId: params.message.messageId,
|
|
82
|
+
});
|
|
83
|
+
return "processed";
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof NextcloudTalkRetryableWebhookError) {
|
|
86
|
+
params.replayGuard.releaseMessage({
|
|
87
|
+
accountId: params.accountId,
|
|
88
|
+
roomToken: params.message.roomToken,
|
|
89
|
+
messageId: params.message.messageId,
|
|
90
|
+
error,
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
// Generic failures are treated as non-retryable because the handler may already
|
|
94
|
+
// have produced a visible side effect, and replaying the webhook would duplicate it.
|
|
95
|
+
await params.replayGuard.commitMessage({
|
|
96
|
+
accountId: params.accountId,
|
|
97
|
+
roomToken: params.message.roomToken,
|
|
98
|
+
messageId: params.message.messageId,
|
|
99
|
+
});
|
|
66
100
|
}
|
|
67
|
-
|
|
68
|
-
} catch {
|
|
69
|
-
return null;
|
|
101
|
+
throw error;
|
|
70
102
|
}
|
|
71
103
|
}
|
|
72
104
|
|
|
105
|
+
function formatError(err: unknown): string {
|
|
106
|
+
if (err instanceof Error) {
|
|
107
|
+
return err.message;
|
|
108
|
+
}
|
|
109
|
+
return typeof err === "string" ? err : JSON.stringify(err);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
|
|
113
|
+
return safeParseJsonWithSchema(NextcloudTalkWebhookPayloadSchema, body);
|
|
114
|
+
}
|
|
115
|
+
|
|
73
116
|
function writeJsonResponse(
|
|
74
117
|
res: ServerResponse,
|
|
75
118
|
status: number,
|
|
@@ -115,6 +158,8 @@ function verifyWebhookSignature(params: {
|
|
|
115
158
|
body: string;
|
|
116
159
|
secret: string;
|
|
117
160
|
res: ServerResponse;
|
|
161
|
+
clientIp: string;
|
|
162
|
+
authRateLimiter: ReturnType<typeof createAuthRateLimiter>;
|
|
118
163
|
}): boolean {
|
|
119
164
|
const isValid = verifyNextcloudTalkSignature({
|
|
120
165
|
signature: params.headers.signature,
|
|
@@ -123,9 +168,11 @@ function verifyWebhookSignature(params: {
|
|
|
123
168
|
secret: params.secret,
|
|
124
169
|
});
|
|
125
170
|
if (!isValid) {
|
|
171
|
+
params.authRateLimiter.recordFailure(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
|
|
126
172
|
writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature);
|
|
127
173
|
return false;
|
|
128
174
|
}
|
|
175
|
+
params.authRateLimiter.reset(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
|
|
129
176
|
return true;
|
|
130
177
|
}
|
|
131
178
|
|
|
@@ -154,7 +201,7 @@ function payloadToInboundMessage(
|
|
|
154
201
|
const isGroupChat = true;
|
|
155
202
|
|
|
156
203
|
return {
|
|
157
|
-
messageId:
|
|
204
|
+
messageId: payload.object.id,
|
|
158
205
|
roomToken: payload.target.id,
|
|
159
206
|
roomName: payload.target.name,
|
|
160
207
|
senderId: payload.actor.id,
|
|
@@ -171,8 +218,10 @@ export function readNextcloudTalkWebhookBody(
|
|
|
171
218
|
maxBodyBytes: number,
|
|
172
219
|
): Promise<string> {
|
|
173
220
|
return readRequestBodyWithLimit(req, {
|
|
174
|
-
|
|
175
|
-
|
|
221
|
+
// This read happens before signature verification, so keep the unauthenticated
|
|
222
|
+
// body budget bounded even if the operator-configured post-parse limit is larger.
|
|
223
|
+
maxBytes: Math.min(maxBodyBytes, PREAUTH_WEBHOOK_MAX_BODY_BYTES),
|
|
224
|
+
timeoutMs: PREAUTH_WEBHOOK_BODY_TIMEOUT_MS,
|
|
176
225
|
});
|
|
177
226
|
}
|
|
178
227
|
|
|
@@ -191,6 +240,22 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
|
|
191
240
|
const readBody = opts.readBody ?? readNextcloudTalkWebhookBody;
|
|
192
241
|
const isBackendAllowed = opts.isBackendAllowed;
|
|
193
242
|
const shouldProcessMessage = opts.shouldProcessMessage;
|
|
243
|
+
const processMessage = opts.processMessage;
|
|
244
|
+
const authRateLimitMaxRequests =
|
|
245
|
+
typeof opts.authRateLimit?.maxRequests === "number"
|
|
246
|
+
? opts.authRateLimit.maxRequests
|
|
247
|
+
: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests;
|
|
248
|
+
const authRateLimitWindowMs =
|
|
249
|
+
typeof opts.authRateLimit?.windowMs === "number"
|
|
250
|
+
? opts.authRateLimit.windowMs
|
|
251
|
+
: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs;
|
|
252
|
+
const webhookAuthRateLimiter = createAuthRateLimiter({
|
|
253
|
+
maxAttempts: authRateLimitMaxRequests,
|
|
254
|
+
windowMs: authRateLimitWindowMs,
|
|
255
|
+
lockoutMs: authRateLimitWindowMs,
|
|
256
|
+
exemptLoopback: false,
|
|
257
|
+
pruneIntervalMs: authRateLimitWindowMs,
|
|
258
|
+
});
|
|
194
259
|
|
|
195
260
|
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
196
261
|
if (req.url === HEALTH_PATH) {
|
|
@@ -205,6 +270,13 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
|
|
205
270
|
return;
|
|
206
271
|
}
|
|
207
272
|
|
|
273
|
+
const clientIp = req.socket.remoteAddress ?? "unknown";
|
|
274
|
+
if (!webhookAuthRateLimiter.check(clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE).allowed) {
|
|
275
|
+
res.writeHead(429);
|
|
276
|
+
res.end("Too Many Requests");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
208
280
|
try {
|
|
209
281
|
const headers = validateWebhookHeaders({
|
|
210
282
|
req,
|
|
@@ -222,6 +294,8 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
|
|
222
294
|
body,
|
|
223
295
|
secret,
|
|
224
296
|
res,
|
|
297
|
+
clientIp,
|
|
298
|
+
authRateLimiter: webhookAuthRateLimiter,
|
|
225
299
|
});
|
|
226
300
|
if (!hasValidSignature) {
|
|
227
301
|
return;
|
|
@@ -240,6 +314,16 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
|
|
240
314
|
}
|
|
241
315
|
|
|
242
316
|
const message = decoded.message;
|
|
317
|
+
if (processMessage) {
|
|
318
|
+
writeJsonResponse(res, 200);
|
|
319
|
+
try {
|
|
320
|
+
await processMessage(message);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
onError?.(err instanceof Error ? err : new Error(formatError(err)));
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
243
327
|
if (shouldProcessMessage) {
|
|
244
328
|
const shouldProcess = await shouldProcessMessage(message);
|
|
245
329
|
if (!shouldProcess) {
|
|
@@ -299,117 +383,3 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
|
|
299
383
|
|
|
300
384
|
return { server, start, stop };
|
|
301
385
|
}
|
|
302
|
-
|
|
303
|
-
export type NextcloudTalkMonitorOptions = {
|
|
304
|
-
accountId?: string;
|
|
305
|
-
config?: CoreConfig;
|
|
306
|
-
runtime?: RuntimeEnv;
|
|
307
|
-
abortSignal?: AbortSignal;
|
|
308
|
-
onMessage?: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
|
|
309
|
-
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
export async function monitorNextcloudTalkProvider(
|
|
313
|
-
opts: NextcloudTalkMonitorOptions,
|
|
314
|
-
): Promise<{ stop: () => void }> {
|
|
315
|
-
const core = getNextcloudTalkRuntime();
|
|
316
|
-
const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig);
|
|
317
|
-
const account = resolveNextcloudTalkAccount({
|
|
318
|
-
cfg,
|
|
319
|
-
accountId: opts.accountId,
|
|
320
|
-
});
|
|
321
|
-
const runtime: RuntimeEnv =
|
|
322
|
-
opts.runtime ??
|
|
323
|
-
createLoggerBackedRuntime({
|
|
324
|
-
logger: core.logging.getChildLogger(),
|
|
325
|
-
exitError: () => new Error("Runtime exit not available"),
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
if (!account.secret) {
|
|
329
|
-
throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT;
|
|
333
|
-
const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST;
|
|
334
|
-
const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH;
|
|
335
|
-
|
|
336
|
-
const logger = core.logging.getChildLogger({
|
|
337
|
-
channel: "nextcloud-talk",
|
|
338
|
-
accountId: account.accountId,
|
|
339
|
-
});
|
|
340
|
-
const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
|
|
341
|
-
const replayGuard = createNextcloudTalkReplayGuard({
|
|
342
|
-
stateDir: core.state.resolveStateDir(process.env, os.homedir),
|
|
343
|
-
onDiskError: (error) => {
|
|
344
|
-
logger.warn(
|
|
345
|
-
`[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`,
|
|
346
|
-
);
|
|
347
|
-
},
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
const { start, stop } = createNextcloudTalkWebhookServer({
|
|
351
|
-
port,
|
|
352
|
-
host,
|
|
353
|
-
path,
|
|
354
|
-
secret: account.secret,
|
|
355
|
-
isBackendAllowed: (backend) => {
|
|
356
|
-
if (!expectedBackendOrigin) {
|
|
357
|
-
return true;
|
|
358
|
-
}
|
|
359
|
-
const backendOrigin = normalizeOrigin(backend);
|
|
360
|
-
return backendOrigin === expectedBackendOrigin;
|
|
361
|
-
},
|
|
362
|
-
shouldProcessMessage: async (message) => {
|
|
363
|
-
const shouldProcess = await replayGuard.shouldProcessMessage({
|
|
364
|
-
accountId: account.accountId,
|
|
365
|
-
roomToken: message.roomToken,
|
|
366
|
-
messageId: message.messageId,
|
|
367
|
-
});
|
|
368
|
-
if (!shouldProcess) {
|
|
369
|
-
logger.warn(
|
|
370
|
-
`[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`,
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
return shouldProcess;
|
|
374
|
-
},
|
|
375
|
-
onMessage: async (message) => {
|
|
376
|
-
core.channel.activity.record({
|
|
377
|
-
channel: "nextcloud-talk",
|
|
378
|
-
accountId: account.accountId,
|
|
379
|
-
direction: "inbound",
|
|
380
|
-
at: message.timestamp,
|
|
381
|
-
});
|
|
382
|
-
if (opts.onMessage) {
|
|
383
|
-
await opts.onMessage(message);
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
await handleNextcloudTalkInbound({
|
|
387
|
-
message,
|
|
388
|
-
account,
|
|
389
|
-
config: cfg,
|
|
390
|
-
runtime,
|
|
391
|
-
statusSink: opts.statusSink,
|
|
392
|
-
});
|
|
393
|
-
},
|
|
394
|
-
onError: (error) => {
|
|
395
|
-
logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
|
|
396
|
-
},
|
|
397
|
-
abortSignal: opts.abortSignal,
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
if (opts.abortSignal?.aborted) {
|
|
401
|
-
return { stop };
|
|
402
|
-
}
|
|
403
|
-
await start();
|
|
404
|
-
if (opts.abortSignal?.aborted) {
|
|
405
|
-
stop();
|
|
406
|
-
return { stop };
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const publicUrl =
|
|
410
|
-
account.config.webhookPublicUrl ??
|
|
411
|
-
`http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
|
|
412
|
-
logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`);
|
|
413
|
-
|
|
414
|
-
return { stop };
|
|
415
|
-
}
|
package/src/normalize.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export function
|
|
1
|
+
export function stripNextcloudTalkTargetPrefix(raw: string): string | undefined {
|
|
2
2
|
const trimmed = raw.trim();
|
|
3
3
|
if (!trimmed) {
|
|
4
4
|
return undefined;
|
|
@@ -22,7 +22,12 @@ export function normalizeNextcloudTalkMessagingTarget(raw: string): string | und
|
|
|
22
22
|
return undefined;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
return
|
|
25
|
+
return normalized;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined {
|
|
29
|
+
const normalized = stripNextcloudTalkTargetPrefix(raw);
|
|
30
|
+
return normalized ? `nextcloud-talk:${normalized}`.toLowerCase() : undefined;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export function looksLikeNextcloudTalkTargetId(raw: string): boolean {
|