@kodelyth/nextcloud-talk 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.
- package/api.ts +1 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +4 -0
- package/dist/api.js +2 -0
- package/dist/channel-ej3z6XJ5.js +2094 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/contract-api.js +2 -0
- package/dist/doctor-contract-Dia7keG4.js +7 -0
- package/dist/doctor-contract-api.js +2 -0
- package/dist/index.js +22 -0
- package/dist/runtime-api-DCIDXlUd.js +14 -0
- package/dist/runtime-api.js +2 -0
- package/dist/secret-contract-DQ2wQ4m1.js +86 -0
- package/dist/secret-contract-api.js +2 -0
- package/dist/setup-entry.js +15 -0
- package/doctor-contract-api.ts +1 -0
- package/index.ts +20 -0
- package/klaw.plugin.json +2 -799
- package/package.json +4 -4
- package/runtime-api.ts +29 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +31 -0
- package/src/accounts.ts +149 -0
- package/src/api-credentials.ts +31 -0
- package/src/approval-auth.test.ts +17 -0
- package/src/approval-auth.ts +27 -0
- package/src/bot-preflight.test.ts +135 -0
- package/src/bot-preflight.ts +183 -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 +91 -0
- package/src/channel.status.test.ts +28 -0
- package/src/channel.ts +225 -0
- package/src/config-schema.ts +79 -0
- package/src/core.test.ts +325 -0
- package/src/doctor-contract.ts +9 -0
- package/src/doctor.test.ts +87 -0
- package/src/doctor.ts +40 -0
- package/src/gateway.ts +109 -0
- package/src/inbound.authz.test.ts +146 -0
- package/src/inbound.behavior.test.ts +309 -0
- package/src/inbound.ts +392 -0
- package/src/message-actions.test.ts +270 -0
- package/src/message-actions.ts +82 -0
- package/src/message-adapter.ts +28 -0
- package/src/monitor-runtime.ts +138 -0
- package/src/monitor.replay.test.ts +276 -0
- package/src/monitor.test-fixtures.ts +30 -0
- package/src/monitor.test-harness.ts +59 -0
- package/src/monitor.ts +385 -0
- package/src/normalize.ts +44 -0
- package/src/policy.ts +111 -0
- package/src/replay-guard.ts +128 -0
- package/src/room-info.test.ts +160 -0
- package/src/room-info.ts +130 -0
- package/src/runtime.ts +9 -0
- package/src/secret-contract.ts +103 -0
- package/src/secret-input.ts +4 -0
- package/src/send.cfg-threading.test.ts +359 -0
- package/src/send.runtime.ts +8 -0
- package/src/send.ts +269 -0
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +250 -0
- package/src/setup-surface.ts +195 -0
- package/src/setup.test.ts +445 -0
- package/src/signature.ts +82 -0
- package/src/types.ts +195 -0
- package/tsconfig.json +16 -0
- package/api.js +0 -7
- package/channel-plugin-api.js +0 -7
- package/contract-api.js +0 -7
- package/doctor-contract-api.js +0 -7
- package/index.js +0 -7
- package/runtime-api.js +0 -7
- package/secret-contract-api.js +0 -7
- package/setup-entry.js +0 -7
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DEFAULT_ACCOUNT_ID } from "klaw/plugin-sdk/routing";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { resolveNextcloudTalkAccount } from "./accounts.js";
|
|
7
|
+
import {
|
|
8
|
+
clearNextcloudTalkAccountFields,
|
|
9
|
+
nextcloudTalkDmPolicy,
|
|
10
|
+
nextcloudTalkSetupAdapter,
|
|
11
|
+
normalizeNextcloudTalkBaseUrl,
|
|
12
|
+
setNextcloudTalkAccountConfig,
|
|
13
|
+
validateNextcloudTalkBaseUrl,
|
|
14
|
+
} from "./setup-core.js";
|
|
15
|
+
import { nextcloudTalkSetupWizard } from "./setup-surface.js";
|
|
16
|
+
import type { CoreConfig } from "./types.js";
|
|
17
|
+
|
|
18
|
+
describe("nextcloud talk setup", () => {
|
|
19
|
+
it("shows a bot install command with webhook, response, and reaction features", () => {
|
|
20
|
+
expect(nextcloudTalkSetupWizard.introNote?.lines.join("\n")).toContain(
|
|
21
|
+
"--feature webhook --feature response --feature reaction",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("normalizes and validates base urls", () => {
|
|
26
|
+
expect(normalizeNextcloudTalkBaseUrl(" https://cloud.example.com/// ")).toBe(
|
|
27
|
+
"https://cloud.example.com",
|
|
28
|
+
);
|
|
29
|
+
expect(normalizeNextcloudTalkBaseUrl(undefined)).toBe("");
|
|
30
|
+
|
|
31
|
+
expect(validateNextcloudTalkBaseUrl("")).toBe("Required");
|
|
32
|
+
expect(validateNextcloudTalkBaseUrl("cloud.example.com")).toBe(
|
|
33
|
+
"URL must start with http:// or https://",
|
|
34
|
+
);
|
|
35
|
+
expect(validateNextcloudTalkBaseUrl("https://cloud.example.com")).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("patches scoped account config and clears selected fields", () => {
|
|
39
|
+
const cfg: CoreConfig = {
|
|
40
|
+
channels: {
|
|
41
|
+
"nextcloud-talk": {
|
|
42
|
+
baseUrl: "https://cloud.example.com",
|
|
43
|
+
botSecret: "top-secret",
|
|
44
|
+
accounts: {
|
|
45
|
+
work: {
|
|
46
|
+
botSecret: "work-secret",
|
|
47
|
+
botSecretFile: "/tmp/work-secret",
|
|
48
|
+
apiPassword: "api-secret",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
expect(
|
|
56
|
+
setNextcloudTalkAccountConfig(cfg, DEFAULT_ACCOUNT_ID, {
|
|
57
|
+
apiUser: "bot",
|
|
58
|
+
}),
|
|
59
|
+
).toEqual({
|
|
60
|
+
channels: {
|
|
61
|
+
"nextcloud-talk": {
|
|
62
|
+
enabled: true,
|
|
63
|
+
baseUrl: "https://cloud.example.com",
|
|
64
|
+
botSecret: "top-secret",
|
|
65
|
+
apiUser: "bot",
|
|
66
|
+
accounts: {
|
|
67
|
+
work: {
|
|
68
|
+
botSecret: "work-secret",
|
|
69
|
+
botSecretFile: "/tmp/work-secret",
|
|
70
|
+
apiPassword: "api-secret",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"])).toEqual({
|
|
78
|
+
channels: {
|
|
79
|
+
"nextcloud-talk": {
|
|
80
|
+
baseUrl: "https://cloud.example.com",
|
|
81
|
+
accounts: {
|
|
82
|
+
work: {
|
|
83
|
+
botSecret: "work-secret",
|
|
84
|
+
botSecretFile: "/tmp/work-secret",
|
|
85
|
+
apiPassword: "api-secret",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
expect(
|
|
92
|
+
clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"]),
|
|
93
|
+
).not.toHaveProperty(["channels", "nextcloud-talk", "botSecret"]);
|
|
94
|
+
|
|
95
|
+
expect(clearNextcloudTalkAccountFields(cfg, "work", ["botSecret", "botSecretFile"])).toEqual({
|
|
96
|
+
channels: {
|
|
97
|
+
"nextcloud-talk": {
|
|
98
|
+
baseUrl: "https://cloud.example.com",
|
|
99
|
+
botSecret: "top-secret",
|
|
100
|
+
accounts: {
|
|
101
|
+
work: {
|
|
102
|
+
apiPassword: "api-secret",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("sets top-level DM policy state", () => {
|
|
111
|
+
const base: CoreConfig = {
|
|
112
|
+
channels: {
|
|
113
|
+
"nextcloud-talk": {},
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
expect(nextcloudTalkDmPolicy.getCurrent(base)).toBe("pairing");
|
|
118
|
+
expect(nextcloudTalkDmPolicy.setPolicy(base, "open")).toEqual({
|
|
119
|
+
channels: {
|
|
120
|
+
"nextcloud-talk": {
|
|
121
|
+
enabled: true,
|
|
122
|
+
dmPolicy: "open",
|
|
123
|
+
allowFrom: ["*"],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("honors named-account DM policy state and config keys", () => {
|
|
130
|
+
const base: CoreConfig = {
|
|
131
|
+
channels: {
|
|
132
|
+
"nextcloud-talk": {
|
|
133
|
+
dmPolicy: "disabled",
|
|
134
|
+
accounts: {
|
|
135
|
+
work: {
|
|
136
|
+
baseUrl: "https://cloud.example.com",
|
|
137
|
+
botSecret: "work-secret",
|
|
138
|
+
dmPolicy: "allowlist",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
expect(nextcloudTalkDmPolicy.getCurrent(base, "work")).toBe("allowlist");
|
|
146
|
+
expect(nextcloudTalkDmPolicy.resolveConfigKeys?.(base, "work")).toEqual({
|
|
147
|
+
policyKey: "channels.nextcloud-talk.accounts.work.dmPolicy",
|
|
148
|
+
allowFromKey: "channels.nextcloud-talk.accounts.work.allowFrom",
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("uses configured defaultAccount for omitted DM policy account context", () => {
|
|
153
|
+
const base: CoreConfig = {
|
|
154
|
+
channels: {
|
|
155
|
+
"nextcloud-talk": {
|
|
156
|
+
defaultAccount: "work",
|
|
157
|
+
dmPolicy: "disabled",
|
|
158
|
+
accounts: {
|
|
159
|
+
work: {
|
|
160
|
+
baseUrl: "https://cloud.example.com",
|
|
161
|
+
botSecret: "work-secret",
|
|
162
|
+
dmPolicy: "allowlist",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
expect(nextcloudTalkDmPolicy.getCurrent(base)).toBe("allowlist");
|
|
170
|
+
expect(nextcloudTalkDmPolicy.resolveConfigKeys?.(base)).toEqual({
|
|
171
|
+
policyKey: "channels.nextcloud-talk.accounts.work.dmPolicy",
|
|
172
|
+
allowFromKey: "channels.nextcloud-talk.accounts.work.allowFrom",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const next = nextcloudTalkDmPolicy.setPolicy(base, "open");
|
|
176
|
+
expect(next.channels?.["nextcloud-talk"]?.dmPolicy).toBe("disabled");
|
|
177
|
+
const workAccount = next.channels?.["nextcloud-talk"]?.accounts?.work as
|
|
178
|
+
| { dmPolicy?: string; allowFrom?: Array<string | number> }
|
|
179
|
+
| undefined;
|
|
180
|
+
expect(workAccount?.dmPolicy).toBe("open");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('writes open DM policy to the named account and preserves inherited allowFrom with "*"', () => {
|
|
184
|
+
const next = nextcloudTalkDmPolicy.setPolicy(
|
|
185
|
+
{
|
|
186
|
+
channels: {
|
|
187
|
+
"nextcloud-talk": {
|
|
188
|
+
allowFrom: ["alice"],
|
|
189
|
+
accounts: {
|
|
190
|
+
work: {
|
|
191
|
+
baseUrl: "https://cloud.example.com",
|
|
192
|
+
botSecret: "work-secret",
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
"open",
|
|
199
|
+
"work",
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
expect(next.channels?.["nextcloud-talk"]?.dmPolicy).toBeUndefined();
|
|
203
|
+
const workAccount = next.channels?.["nextcloud-talk"]?.accounts?.work as
|
|
204
|
+
| { dmPolicy?: string; allowFrom?: Array<string | number> }
|
|
205
|
+
| undefined;
|
|
206
|
+
expect(workAccount?.dmPolicy).toBe("open");
|
|
207
|
+
expect(workAccount?.allowFrom).toEqual(["alice", "*"]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("validates env/default-account constraints and applies config patches", () => {
|
|
211
|
+
const validateInput = nextcloudTalkSetupAdapter.validateInput;
|
|
212
|
+
const applyAccountConfig = nextcloudTalkSetupAdapter.applyAccountConfig;
|
|
213
|
+
expect(validateInput).toBeTypeOf("function");
|
|
214
|
+
expect(applyAccountConfig).toBeTypeOf("function");
|
|
215
|
+
if (!validateInput) {
|
|
216
|
+
throw new Error("Expected Nextcloud Talk setup validateInput");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
expect(
|
|
220
|
+
validateInput({
|
|
221
|
+
accountId: "work",
|
|
222
|
+
input: { useEnv: true },
|
|
223
|
+
} as never),
|
|
224
|
+
).toBe("NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account.");
|
|
225
|
+
|
|
226
|
+
expect(
|
|
227
|
+
validateInput({
|
|
228
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
229
|
+
input: { useEnv: false, baseUrl: "", secret: "" },
|
|
230
|
+
} as never),
|
|
231
|
+
).toBe("Nextcloud Talk requires bot secret or --secret-file (or --use-env).");
|
|
232
|
+
|
|
233
|
+
expect(
|
|
234
|
+
validateInput({
|
|
235
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
236
|
+
input: { useEnv: false, secret: "secret", baseUrl: "" },
|
|
237
|
+
} as never),
|
|
238
|
+
).toBe("Nextcloud Talk requires --base-url.");
|
|
239
|
+
|
|
240
|
+
expect(
|
|
241
|
+
applyAccountConfig({
|
|
242
|
+
cfg: {
|
|
243
|
+
channels: {
|
|
244
|
+
"nextcloud-talk": {},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
248
|
+
input: {
|
|
249
|
+
name: "Default",
|
|
250
|
+
baseUrl: "https://cloud.example.com///",
|
|
251
|
+
secret: "bot-secret",
|
|
252
|
+
},
|
|
253
|
+
} as never),
|
|
254
|
+
).toEqual({
|
|
255
|
+
channels: {
|
|
256
|
+
"nextcloud-talk": {
|
|
257
|
+
enabled: true,
|
|
258
|
+
name: "Default",
|
|
259
|
+
baseUrl: "https://cloud.example.com",
|
|
260
|
+
botSecret: "bot-secret",
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(
|
|
266
|
+
applyAccountConfig({
|
|
267
|
+
cfg: {
|
|
268
|
+
channels: {
|
|
269
|
+
"nextcloud-talk": {
|
|
270
|
+
accounts: {
|
|
271
|
+
work: {
|
|
272
|
+
botSecret: "old-secret",
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
accountId: "work",
|
|
279
|
+
input: {
|
|
280
|
+
name: "Work",
|
|
281
|
+
useEnv: true,
|
|
282
|
+
baseUrl: "https://cloud.example.com",
|
|
283
|
+
},
|
|
284
|
+
} as never),
|
|
285
|
+
).toEqual({
|
|
286
|
+
channels: {
|
|
287
|
+
"nextcloud-talk": {
|
|
288
|
+
enabled: true,
|
|
289
|
+
accounts: {
|
|
290
|
+
work: {
|
|
291
|
+
enabled: true,
|
|
292
|
+
name: "Work",
|
|
293
|
+
baseUrl: "https://cloud.example.com",
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("clears stored bot secret fields when switching the default account to env", () => {
|
|
302
|
+
type ApplyAccountConfigContext = Parameters<
|
|
303
|
+
typeof nextcloudTalkSetupAdapter.applyAccountConfig
|
|
304
|
+
>[0];
|
|
305
|
+
|
|
306
|
+
const next = nextcloudTalkSetupAdapter.applyAccountConfig({
|
|
307
|
+
cfg: {
|
|
308
|
+
channels: {
|
|
309
|
+
"nextcloud-talk": {
|
|
310
|
+
enabled: true,
|
|
311
|
+
baseUrl: "https://cloud.old.example",
|
|
312
|
+
botSecret: "stored-secret",
|
|
313
|
+
botSecretFile: "/tmp/secret.txt",
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
318
|
+
input: {
|
|
319
|
+
baseUrl: "https://cloud.example.com",
|
|
320
|
+
useEnv: true,
|
|
321
|
+
},
|
|
322
|
+
} as unknown as ApplyAccountConfigContext);
|
|
323
|
+
|
|
324
|
+
expect(next.channels?.["nextcloud-talk"]?.baseUrl).toBe("https://cloud.example.com");
|
|
325
|
+
expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret");
|
|
326
|
+
expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("clears stored bot secret fields when the wizard switches to env", async () => {
|
|
330
|
+
const credential = nextcloudTalkSetupWizard.credentials[0];
|
|
331
|
+
const next = await credential.applyUseEnv?.({
|
|
332
|
+
cfg: {
|
|
333
|
+
channels: {
|
|
334
|
+
"nextcloud-talk": {
|
|
335
|
+
enabled: true,
|
|
336
|
+
baseUrl: "https://cloud.example.com",
|
|
337
|
+
botSecret: "stored-secret",
|
|
338
|
+
botSecretFile: "/tmp/secret.txt",
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret");
|
|
346
|
+
expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("resolveNextcloudTalkAccount", () => {
|
|
351
|
+
it("matches normalized configured account ids", () => {
|
|
352
|
+
const account = resolveNextcloudTalkAccount({
|
|
353
|
+
cfg: {
|
|
354
|
+
channels: {
|
|
355
|
+
"nextcloud-talk": {
|
|
356
|
+
accounts: {
|
|
357
|
+
"Ops Team": {
|
|
358
|
+
baseUrl: "https://cloud.example.com",
|
|
359
|
+
botSecret: "bot-secret",
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
} as CoreConfig,
|
|
365
|
+
accountId: "ops-team",
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
expect(account.accountId).toBe("ops-team");
|
|
369
|
+
expect(account.baseUrl).toBe("https://cloud.example.com");
|
|
370
|
+
expect(account.secret).toBe("bot-secret");
|
|
371
|
+
expect(account.secretSource).toBe("config");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it.runIf(process.platform !== "win32")("rejects symlinked botSecretFile paths", () => {
|
|
375
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "klaw-nextcloud-talk-"));
|
|
376
|
+
const secretFile = path.join(dir, "secret.txt");
|
|
377
|
+
const secretLink = path.join(dir, "secret-link.txt");
|
|
378
|
+
fs.writeFileSync(secretFile, "bot-secret\n", "utf8");
|
|
379
|
+
fs.symlinkSync(secretFile, secretLink);
|
|
380
|
+
|
|
381
|
+
const cfg = {
|
|
382
|
+
channels: {
|
|
383
|
+
"nextcloud-talk": {
|
|
384
|
+
baseUrl: "https://cloud.example.com",
|
|
385
|
+
botSecretFile: secretLink,
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
} as CoreConfig;
|
|
389
|
+
|
|
390
|
+
const account = resolveNextcloudTalkAccount({ cfg });
|
|
391
|
+
expect(account.secret).toBe("");
|
|
392
|
+
expect(account.secretSource).toBe("none");
|
|
393
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("uses configured defaultAccount when accountId is omitted", () => {
|
|
397
|
+
const account = resolveNextcloudTalkAccount({
|
|
398
|
+
cfg: {
|
|
399
|
+
channels: {
|
|
400
|
+
"nextcloud-talk": {
|
|
401
|
+
defaultAccount: "work",
|
|
402
|
+
botSecret: "top-secret",
|
|
403
|
+
accounts: {
|
|
404
|
+
work: {
|
|
405
|
+
baseUrl: "https://cloud.example.com",
|
|
406
|
+
botSecret: "work-secret",
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
} as CoreConfig,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(account.accountId).toBe("work");
|
|
415
|
+
expect(account.baseUrl).toBe("https://cloud.example.com");
|
|
416
|
+
expect(account.secret).toBe("work-secret");
|
|
417
|
+
expect(account.secretSource).toBe("config");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("uses configured defaultAccount for omitted setup configured state", () => {
|
|
421
|
+
const configured = nextcloudTalkSetupWizard.status.resolveConfigured({
|
|
422
|
+
cfg: {
|
|
423
|
+
channels: {
|
|
424
|
+
"nextcloud-talk": {
|
|
425
|
+
defaultAccount: "work",
|
|
426
|
+
baseUrl: "https://root.example.com",
|
|
427
|
+
botSecret: "root-secret",
|
|
428
|
+
accounts: {
|
|
429
|
+
alerts: {
|
|
430
|
+
baseUrl: "https://alerts.example.com",
|
|
431
|
+
botSecret: "alerts-secret",
|
|
432
|
+
},
|
|
433
|
+
work: {
|
|
434
|
+
baseUrl: "",
|
|
435
|
+
botSecret: "",
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
} as CoreConfig,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
expect(configured).toBe(false);
|
|
444
|
+
});
|
|
445
|
+
});
|
package/src/signature.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
|
|
3
|
+
import type { NextcloudTalkWebhookHeaders } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
|
|
6
|
+
const RANDOM_HEADER = "x-nextcloud-talk-random";
|
|
7
|
+
const BACKEND_HEADER = "x-nextcloud-talk-backend";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Verify the HMAC-SHA256 signature of an incoming webhook request.
|
|
11
|
+
* Signature is calculated as: HMAC-SHA256(random + body, secret)
|
|
12
|
+
*/
|
|
13
|
+
export function verifyNextcloudTalkSignature(params: {
|
|
14
|
+
signature: string;
|
|
15
|
+
random: string;
|
|
16
|
+
body: string;
|
|
17
|
+
secret: string;
|
|
18
|
+
}): boolean {
|
|
19
|
+
const { signature, random, body, secret } = params;
|
|
20
|
+
if (!signature || !random || !secret) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const expected = createHmac("sha256", secret)
|
|
25
|
+
.update(random + body)
|
|
26
|
+
.digest("hex");
|
|
27
|
+
|
|
28
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
29
|
+
const signatureBuf = Buffer.from(signature, "utf8");
|
|
30
|
+
|
|
31
|
+
// Pad to equal length before constant-time comparison to prevent
|
|
32
|
+
// leaking length information via early-return timing.
|
|
33
|
+
// Note: digest("hex") always produces lowercase ASCII (64 bytes for SHA-256),
|
|
34
|
+
// so expectedBuf is always 64 bytes — no variable-length concern on the expected side.
|
|
35
|
+
const maxLen = Math.max(expectedBuf.length, signatureBuf.length);
|
|
36
|
+
const paddedExpected = Buffer.alloc(maxLen);
|
|
37
|
+
const paddedSignature = Buffer.alloc(maxLen);
|
|
38
|
+
expectedBuf.copy(paddedExpected);
|
|
39
|
+
signatureBuf.copy(paddedSignature);
|
|
40
|
+
|
|
41
|
+
// Use crypto.timingSafeEqual instead of manual XOR loop to avoid
|
|
42
|
+
// potential JIT-optimisation timing leaks in the JavaScript engine.
|
|
43
|
+
const timingResult = timingSafeEqual(paddedExpected, paddedSignature);
|
|
44
|
+
return expectedBuf.length === signatureBuf.length && timingResult;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract webhook headers from an incoming request.
|
|
49
|
+
*/
|
|
50
|
+
export function extractNextcloudTalkHeaders(
|
|
51
|
+
headers: Record<string, string | string[] | undefined>,
|
|
52
|
+
): NextcloudTalkWebhookHeaders | null {
|
|
53
|
+
const getHeader = (name: string): string | undefined => {
|
|
54
|
+
const value = headers[name] ?? headers[normalizeLowercaseStringOrEmpty(name)];
|
|
55
|
+
return Array.isArray(value) ? value[0] : value;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const signature = getHeader(SIGNATURE_HEADER);
|
|
59
|
+
const random = getHeader(RANDOM_HEADER);
|
|
60
|
+
const backend = getHeader(BACKEND_HEADER);
|
|
61
|
+
|
|
62
|
+
if (!signature || !random || !backend) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { signature, random, backend };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate signature headers for an outbound request to Nextcloud Talk.
|
|
71
|
+
*/
|
|
72
|
+
export function generateNextcloudTalkSignature(params: { body: string; secret: string }): {
|
|
73
|
+
random: string;
|
|
74
|
+
signature: string;
|
|
75
|
+
} {
|
|
76
|
+
const { body, secret } = params;
|
|
77
|
+
const random = randomBytes(32).toString("hex");
|
|
78
|
+
const signature = createHmac("sha256", secret)
|
|
79
|
+
.update(random + body)
|
|
80
|
+
.digest("hex");
|
|
81
|
+
return { random, signature };
|
|
82
|
+
}
|