@martin4455/matomo-mcp-ro 0.4.1

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/cli.mjs ADDED
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { MatomoApi, MatomoError } from './api.mjs';
5
+ import { CONFIG_NAME, ConfigError, apiOptions, loadConfig, normalizeBaseUrl, resolveConfigPath, saveConfig, validateConfig, prepareConfiguration, configStatus } from './lib/config.mjs';
6
+ import { ask } from './lib/prompt.mjs';
7
+ import { clientConfiguration } from './lib/client-config.mjs';
8
+ import { VERSION } from './lib/version.mjs';
9
+
10
+ export { VERSION };
11
+ const help = `Matomo MCP - lokalny adapter Reporting API, tylko do odczytu\n\nUżycie: matomo-mcp [polecenie] [--config /ścieżka/${CONFIG_NAME}]\n\n configure Wpisz dane w terminalu, sprawdź i zapisz w keyringu\n check Sprawdź prawa pliku, keyring i połączenie z Matomo\n status Sprawdź konfigurację i keyring bez HTTP\n serve Uruchom MCP przez stdio (polecenie domyślne)\n client-config codex Wypisz wpis TOML z bezpośrednim startem Node.js\n client-config claude-code Wypisz wpis MCP JSON dla Claude Code\n client-config claude-desktop Wypisz wpis MCP JSON dla Claude Desktop\n --help / --version\n\nBez --config używany jest wyłącznie plik ${CONFIG_NAME} w bieżącym katalogu.\nURL, token i Basic Auth są w systemowym keyringu; plik zawiera tylko odwołanie.\nToken i dane HTTP Auth wpisuj tylko w configure, nigdy w argumentach poleceń.\n`;
12
+
13
+ export function parseArguments(argv) {
14
+ let config;
15
+ const positional = [];
16
+ for (let i = 0; i < argv.length; i++) {
17
+ if (argv[i] === '--config') {
18
+ if (config !== undefined || !argv[i + 1] || argv[i + 1].startsWith('-')) throw new ConfigError('Po --config podaj ścieżkę do pliku konfiguracji.');
19
+ config = argv[++i];
20
+ } else if (['--help', '-h', '--version'].includes(argv[i])) positional.push(argv[i]);
21
+ else if (argv[i].startsWith('-')) throw new ConfigError('Nieznana opcja. Użyj --help.');
22
+ else positional.push(argv[i]);
23
+ }
24
+ const command = positional[0] ?? 'serve';
25
+ const client = positional[1];
26
+ if (!['configure', 'check', 'status', 'serve', 'client-config', '--help', '-h', '--version'].includes(command)
27
+ || positional.length > (command === 'client-config' ? 2 : 1)
28
+ || command === 'client-config' && !['codex', 'claude-code', 'claude-desktop'].includes(client)) {
29
+ throw new ConfigError('Nieprawidłowe polecenie. Użyj --help.');
30
+ }
31
+ return { command, client, configPath: resolveConfigPath(config) };
32
+ }
33
+
34
+ export async function checkConnection(config, fetchImpl) {
35
+ const api = new MatomoApi({ ...apiOptions(config), ...(fetchImpl ? { fetchImpl } : {}) });
36
+ const sites = await api.call('SitesManager.getSitesWithAtLeastViewAccess');
37
+ if (!Array.isArray(sites)) throw new MatomoError('Matomo nie zwróciło oczekiwanej listy witryn.');
38
+ if (!sites.length) throw new MatomoError('Token jest poprawny, ale nie ma dostępu do żadnej witryny.');
39
+ return { ok: true, backend: 'reporting-api', siteCount: sites.length };
40
+ }
41
+
42
+ export async function configure(configPath, { store, askImpl = ask, input = process.stdin, output = process.stdout, fetchImpl } = {}) {
43
+ if (!input.isTTY || !output.isTTY) throw new ConfigError('Uruchom configure we własnym terminalu. Nie przesyłaj tokenu, loginu ani hasła w rozmowie z AI.');
44
+ output.write(`Konfiguracja: ${configPath}\nURL, token i Basic Auth będą zapisane w systemowym keyringu. Plik będzie zawierał tylko odwołanie.\nOdpowiedź wpisuj po dwukropku i zatwierdzaj klawiszem Enter. Ctrl+C przerywa konfigurację.\nToken, login i hasło są ukryte: podczas wpisywania nie widać znaków ani gwiazdek.\n\nOdczytuję zapisaną konfigurację...\n`);
45
+ const expected = await prepareConfiguration(configPath, { store });
46
+ let previous = expected.previous;
47
+ if (expected.record && !previous) output.write('Zapisane dane są nieprawidłowe lub niekompletne. Wpisz pełne dane; zostaną zapisane dopiero po udanym teście.\n');
48
+ const prompt = (question, options) => askImpl(question, { ...options, input, output });
49
+ const secret = async (label, saved) => {
50
+ const value = await prompt(`${label}${saved ? ' (Enter = zachowaj)' : ' (wymagane)'}: `, { hidden: true });
51
+ if (value) output.write(' Przyjęto nową wartość.\n');
52
+ else if (saved) output.write(' Zachowano zapisaną wartość.\n');
53
+ return value || saved;
54
+ };
55
+ output.write('\n[1/4] Adres Matomo\n');
56
+ if (previous) output.write(`Zapisany adres: ${previous.baseUrl}\n`);
57
+ const baseUrl = normalizeBaseUrl((await prompt(`Adres HTTPS Matomo${previous ? ' (Enter = zachowaj)' : ' (wymagane)'}: `)).trim() || previous?.baseUrl);
58
+ if (previous && previous.baseUrl !== baseUrl) {
59
+ previous = undefined;
60
+ output.write('Adres Matomo został zmieniony. Wpisz komplet danych dla nowego adresu.\n');
61
+ }
62
+ output.write('\n[2/4] HTTP Basic Auth\nCzy serwer wymaga dodatkowego loginu i hasła HTTP Basic Auth?\n');
63
+ let answer;
64
+ while (true) {
65
+ answer = (await prompt(`Wpisz tak lub nie (Enter = ${previous?.basicAuth ? 'tak' : 'nie'}): `)).trim().toLowerCase();
66
+ if (['', 't', 'tak', 'y', 'yes', 'n', 'nie', 'no'].includes(answer)) break;
67
+ output.write('Nie rozpoznano odpowiedzi. Wpisz tak albo nie i naciśnij Enter.\n');
68
+ }
69
+ const useBasic = answer ? ['t', 'tak', 'y', 'yes'].includes(answer) : Boolean(previous?.basicAuth);
70
+ output.write(`HTTP Basic Auth: ${useBasic ? 'włączone' : 'wyłączone'}.\n`);
71
+ let basicAuth;
72
+ if (useBasic) {
73
+ const username = await secret('Login HTTP Basic Auth', previous?.basicAuth?.username);
74
+ const password = await secret('Hasło HTTP Basic Auth', previous?.basicAuth?.password);
75
+ basicAuth = { username, password };
76
+ }
77
+ output.write('\n[3/4] Token API Matomo\nWklej aktualny token i naciśnij Enter.\n');
78
+ const token = await secret('Token API Matomo', previous?.token);
79
+ const config = validateConfig({ schemaVersion: 1, baseUrl, token, ...(basicAuth ? { basicAuth } : {}) });
80
+ output.write('\n[4/4] Test połączenia i zapis\nSprawdzam dostęp do Matomo...\n');
81
+ const result = await checkConnection(config, fetchImpl);
82
+ output.write('Połączenie działa. Zapisuję dane w keyringu...\n');
83
+ const saved = await saveConfig(configPath, config, { overwrite: Boolean(expected.record), expected, store });
84
+ if (saved.cleanupPending) output.write('Nowe dane są zapisane; część poprzednich fragmentów mogła pozostać w keyringu.\n');
85
+ output.write(`OK: zapisano konfigurację w keyringu. Dostępne witryny: ${result.siteCount}.\nUruchom check lub client-config codex / claude-code / claude-desktop. Po zmianie danych uruchom MCP ponownie.\n`);
86
+ }
87
+
88
+ export async function main(argv = process.argv.slice(2), { store, output = process.stdout, fetchImpl, ...options } = {}) {
89
+ const { command, client, configPath } = parseArguments(argv);
90
+ if (command === '--help' || command === '-h') return output.write(help);
91
+ if (command === '--version') return output.write(VERSION + '\n');
92
+ if (command === 'client-config') return output.write(clientConfiguration(client, configPath));
93
+ if (command === 'configure') return configure(configPath, { ...options, store, output, fetchImpl });
94
+ if (command === 'status' || command === 'check') {
95
+ const result = command === 'check' ? await checkConnection(await loadConfig(configPath, { store }), fetchImpl) : {};
96
+ const status = await configStatus(configPath, { store });
97
+ output.write(JSON.stringify({ ...result, ...status, config: configPath, backend: 'reporting-api', readOnly: true, node: process.version }, null, 2) + '\n');
98
+ return;
99
+ }
100
+ const config = await loadConfig(configPath, { store });
101
+ const { serve } = await import('./server.mjs');
102
+ await serve(new MatomoApi(apiOptions(config)));
103
+ }
104
+
105
+ export function reportError(error) {
106
+ process.stderr.write((error instanceof ConfigError || error instanceof MatomoError ? error.message : 'Operacja Matomo MCP nie powiodła się. Sprawdź konfigurację poleceniem check.') + '\n');
107
+ process.exitCode = 1;
108
+ }
109
+
110
+ function invokedDirectly() {
111
+ try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
112
+ }
113
+
114
+ if (process.argv[1] && invokedDirectly()) main().catch(reportError);
package/client.mjs ADDED
@@ -0,0 +1,28 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
3
+ import { fileURLToPath } from 'node:url';
4
+ import path from 'node:path';
5
+ import { VERSION } from './lib/version.mjs';
6
+ import { keyringSessionEnvironment } from './lib/session-env.mjs';
7
+ export { protectPath as protectOutputPath } from './lib/permissions.mjs';
8
+
9
+ /** Generic stdio client for local scripts. It never reads or returns credentials. */
10
+ export async function connectMatomo({ configPath, cwd = process.cwd(), cliPath = fileURLToPath(new URL('./cli.mjs', import.meta.url)) } = {}) {
11
+ const client = new Client({ name: 'matomo-mcp-ro-client', version: VERSION });
12
+ const transport = new StdioClientTransport({ command: process.execPath,
13
+ args: [path.resolve(cliPath), 'serve', ...(configPath ? ['--config', path.resolve(configPath)] : [])], cwd, stderr: 'pipe', env: keyringSessionEnvironment() });
14
+ transport.stderr?.on('data', () => {});
15
+ try { await client.connect(transport); }
16
+ catch { await client.close().catch(() => {}); throw new Error('Nie można uruchomić MCP. Sprawdź instalację, prawa konfiguracji i polecenie check.'); }
17
+ return {
18
+ version: client.getServerVersion()?.version,
19
+ async tools() { return (await client.listTools()).tools.map(t => t.name); },
20
+ async call(name, args, { signal, timeout = 180000 } = {}) {
21
+ const result = await client.callTool({ name, arguments: args }, undefined, { signal, timeout });
22
+ const text = result.content?.find(item => item.type === 'text')?.text;
23
+ if (result.isError) throw new Error(text ?? 'Błąd narzędzia MCP.');
24
+ try { return JSON.parse(text); } catch { throw new Error('Nieprawidłowa odpowiedź MCP.'); }
25
+ },
26
+ close: () => client.close(),
27
+ };
28
+ }
@@ -0,0 +1,14 @@
1
+ import { fileURLToPath } from 'node:url';
2
+
3
+ export function clientConfiguration(client, configPath) {
4
+ const command = process.execPath;
5
+ const args = [fileURLToPath(new URL('../cli.mjs', import.meta.url)), 'serve', '--config', configPath];
6
+ const name = 'matomo';
7
+ if (client === 'codex') {
8
+ return `[mcp_servers.${name}]\ncommand = ${JSON.stringify(command)}\nargs = ${JSON.stringify(args)}\nstartup_timeout_sec = 20\ntool_timeout_sec = 90\n`;
9
+ }
10
+ if (client === 'claude-code' || client === 'claude-desktop') {
11
+ return JSON.stringify({ mcpServers: { [name]: { command, args } } }, null, 2) + '\n';
12
+ }
13
+ throw new Error('Unknown client');
14
+ }
@@ -0,0 +1,8 @@
1
+ // Messages in this class are application-owned. Never wrap a native error/cause.
2
+ export class ConfigError extends Error {
3
+ constructor(message, code = 'CONFIG_INVALID') {
4
+ super(message);
5
+ this.name = 'ConfigError';
6
+ this.code = code;
7
+ }
8
+ }
@@ -0,0 +1,102 @@
1
+ import fs from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ConfigError } from './config-errors.mjs';
5
+ import { protectPath, inspectPermissions } from './permissions.mjs';
6
+
7
+ export const CONFIG_NAME = '.matomo-mcp.json';
8
+ const MAX_FILE_BYTES = 65536;
9
+ function checkStat(stat) {
10
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new ConfigError('Konfiguracja musi być zwykłym plikiem, bez dowiązań.');
11
+ if (stat.size > MAX_FILE_BYTES) throw new ConfigError('Plik konfiguracji jest zbyt duży.');
12
+ if (process.platform !== 'win32' && ((stat.mode & 0o777) !== 0o600 || stat.uid !== process.getuid())) {
13
+ throw new ConfigError('Plik konfiguracji wymaga uprawnień 600 i właściciela zgodnego z bieżącym kontem.');
14
+ }
15
+ }
16
+ function sameFile(a, b) { return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs; }
17
+
18
+ export async function readConfigFile(target, { allowMissing = false } = {}) {
19
+ if (path.basename(target) !== CONFIG_NAME) throw new ConfigError(`Plik konfiguracji musi mieć nazwę ${CONFIG_NAME}.`);
20
+ let handle;
21
+ try {
22
+ let before;
23
+ try { before = await fs.lstat(target); }
24
+ catch (error) {
25
+ if (error.code !== 'ENOENT') throw error;
26
+ if (allowMissing) return null;
27
+ throw new ConfigError('Brak lokalnego pliku .matomo-mcp.json. Uruchom configure w katalogu projektu lub podaj --config.', 'CONFIG_MISSING');
28
+ }
29
+ checkStat(before);
30
+ handle = await fs.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
31
+ const opened = await handle.stat();
32
+ checkStat(opened);
33
+ if (!sameFile(before, opened)) throw new ConfigError('Konfiguracja zmieniła się podczas odczytu.', 'CONFIG_CONFLICT');
34
+ const permissions = await inspectPermissions(target);
35
+ if (!permissions.private) throw new ConfigError('Plik konfiguracji ma zbyt szerokie uprawnienia. Wymagane są prywatny ACL Windows lub tryb 600 na Unix.', 'CONFIG_PERMISSIONS');
36
+ const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
37
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
38
+ const after = await handle.stat();
39
+ const atPath = await fs.lstat(target);
40
+ checkStat(after);
41
+ checkStat(atPath);
42
+ if (bytesRead > MAX_FILE_BYTES || bytesRead !== after.size || !sameFile(opened, after) || !sameFile(after, atPath)) {
43
+ throw new ConfigError('Konfiguracja zmieniła się podczas odczytu.', 'CONFIG_CONFLICT');
44
+ }
45
+ return { text: new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, bytesRead)), permissions: permissions.kind };
46
+ } catch (error) {
47
+ if (error instanceof ConfigError) throw error;
48
+ throw new ConfigError('Nie można bezpiecznie odczytać pliku konfiguracji na tym koncie.', 'CONFIG_READ_FAILED');
49
+ } finally { await handle?.close().catch(() => {}); }
50
+ }
51
+
52
+ async function ensureIgnored(directory) {
53
+ for (const name of ['.gitignore', '.ignore']) {
54
+ const target = path.join(directory, name);
55
+ let before = '';
56
+ try {
57
+ const stat = await fs.lstat(target);
58
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new ConfigError('Plik reguł wykluczania nie może być dowiązaniem.');
59
+ before = await fs.readFile(target, 'utf8');
60
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
61
+ const missing = [CONFIG_NAME, '.matomo-mcp-tmp-*/'].filter(rule => !before.split(/\r?\n/).includes(rule));
62
+ if (missing.length) await fs.appendFile(target, `${before && !before.endsWith('\n') ? '\n' : ''}${missing.join('\n')}\n`, 'utf8');
63
+ }
64
+ }
65
+
66
+ // Only reference metadata reaches this writer.
67
+ export async function writeConfigFile(target, record, expectedText) {
68
+ if (record.schemaVersion !== 2 || Object.keys(record).sort().join(',') !== 'credentialId,schemaVersion'
69
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(record.credentialId)) throw new ConfigError('Nieprawidłowe metadane konfiguracji.');
70
+ const directory = path.dirname(target);
71
+ let temporary;
72
+ let stagingFile;
73
+ try {
74
+ await ensureIgnored(directory);
75
+ temporary = await fs.mkdtemp(path.join(directory, '.matomo-mcp-tmp-'));
76
+ await protectPath(temporary, true);
77
+ stagingFile = path.join(temporary, CONFIG_NAME);
78
+ const handle = await fs.open(stagingFile, 'wx', 0o600);
79
+ try {
80
+ await protectPath(stagingFile);
81
+ if (!(await inspectPermissions(stagingFile)).private) throw new ConfigError('Nie udało się ograniczyć dostępu do pliku konfiguracji.');
82
+ await handle.writeFile(JSON.stringify(record, null, 2) + '\n', 'utf8');
83
+ await handle.sync();
84
+ } finally { await handle.close(); }
85
+ const actual = await readConfigFile(target, { allowMissing: true });
86
+ if ((actual?.text ?? null) !== expectedText) throw new ConfigError('Konfiguracja została zmieniona przez inną operację. Ponów configure.', 'CONFIG_CONFLICT');
87
+ if (expectedText !== null) await fs.rename(stagingFile, target);
88
+ else await fs.link(stagingFile, target);
89
+ // After commit, cleanup must not trigger a rollback of the new credential.
90
+ await fs.unlink(stagingFile).catch(() => {});
91
+ if (process.platform !== 'win32') {
92
+ const parent = await fs.open(directory, constants.O_RDONLY).catch(() => null);
93
+ if (parent) { await parent.sync().catch(() => {}); await parent.close().catch(() => {}); }
94
+ }
95
+ } catch (error) {
96
+ if (error instanceof ConfigError) throw error;
97
+ throw new ConfigError('Nie można zapisać prywatnego pliku konfiguracji. Sprawdź prawa do katalogu.', 'CONFIG_WRITE_FAILED');
98
+ } finally {
99
+ if (stagingFile) await fs.unlink(stagingFile).catch(() => {});
100
+ if (temporary) await fs.rmdir(temporary).catch(() => {});
101
+ }
102
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,102 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { z } from 'zod';
5
+ import { ConfigError } from './config-errors.mjs';
6
+ import { CONFIG_NAME, readConfigFile, writeConfigFile } from './config-file.mjs';
7
+ import { SystemCredentialStore, UUID_V4 } from './credential-store.mjs';
8
+ import { withLock } from './locks.mjs';
9
+
10
+ export { ConfigError, CONFIG_NAME };
11
+ const secret = z.string().min(1).max(8192).refine(value => !/[\x00-\x1f\x7f]/.test(value));
12
+ const connectionSchema = z.object({
13
+ schemaVersion: z.literal(1), baseUrl: z.string().min(1).max(2048), token: secret,
14
+ basicAuth: z.object({ username: secret.refine(value => !value.includes(':')), password: secret }).strict().optional(),
15
+ }).strict();
16
+ const referenceSchema = z.object({ schemaVersion: z.literal(2), credentialId: z.string().regex(UUID_V4) }).strict();
17
+ const defaultStore = new SystemCredentialStore(); // No native import/access here.
18
+
19
+ export function normalizeBaseUrl(value) {
20
+ let url;
21
+ try { url = new URL(value); } catch { throw new ConfigError('Podaj poprawny adres HTTPS Matomo.'); }
22
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
23
+ throw new ConfigError('Adres Matomo musi używać HTTPS i nie może zawierać loginu, hasła, parametrów ani fragmentu.');
24
+ }
25
+ if (url.pathname.endsWith('/index.php')) url.pathname = url.pathname.slice(0, -9);
26
+ if (!url.pathname.endsWith('/')) url.pathname += '/';
27
+ if (url.href.length > 2048) throw new ConfigError('Adres Matomo jest zbyt długi po normalizacji.');
28
+ return url.href;
29
+ }
30
+ export function validateConfig(value) {
31
+ const parsed = connectionSchema.safeParse(value);
32
+ if (!parsed.success) throw new ConfigError('Nieprawidłowa konfiguracja Matomo. Wymagane są adres HTTPS i token; opcjonalny Basic Auth wymaga loginu i hasła.');
33
+ return { ...parsed.data, baseUrl: normalizeBaseUrl(parsed.data.baseUrl) };
34
+ }
35
+ export function resolveConfigPath(explicit, cwd = process.cwd()) { return path.resolve(cwd, explicit ?? CONFIG_NAME); }
36
+
37
+ export async function readConfigRecord(target, options) {
38
+ const file = await readConfigFile(target, options);
39
+ if (!file) return { text: null, record: null };
40
+ let value;
41
+ try { value = JSON.parse(file.text); }
42
+ catch { throw new ConfigError('Nie można odczytać konfiguracji JSON.'); }
43
+ // Recognize obsolete files only so configure can replace them. Never reuse their values.
44
+ const parsed = value?.schemaVersion === 1 ? { success: true, data: { schemaVersion: 1 } } : referenceSchema.safeParse(value);
45
+ if (!parsed.success) throw new ConfigError('Nieprawidłowe odwołanie do keyringu: wymagane są wyłącznie schemaVersion 2 i credentialId UUID v4.');
46
+ return { ...file, record: parsed.data };
47
+ }
48
+ function connectionFromSecret(text) {
49
+ if (text === null) throw new ConfigError('Brak wpisu w keyringu tego konta. Uruchom configure i podaj dane połączenia.', 'CREDENTIAL_MISSING');
50
+ try { return validateConfig(JSON.parse(text)); }
51
+ catch { throw new ConfigError('Wpis keyringu zawiera nieprawidłowe dane. Uruchom configure i podaj pełne dane.', 'CREDENTIAL_INVALID'); }
52
+ }
53
+ export async function loadConfig(target, { store = defaultStore } = {}) {
54
+ const { record } = await readConfigRecord(target);
55
+ if (record.schemaVersion === 1) throw new ConfigError('Zapisane dane mają nieprawidłowy format. Uruchom configure i podaj pełne dane połączenia.', 'CREDENTIAL_INVALID');
56
+ return connectionFromSecret(await store.get(record.credentialId));
57
+ }
58
+
59
+ export async function prepareConfiguration(target, { store = defaultStore } = {}) {
60
+ const snapshot = await readConfigRecord(target, { allowMissing: true });
61
+ if (!snapshot.record) return snapshot;
62
+ if (snapshot.record.schemaVersion === 1) return snapshot;
63
+ const { result } = await store.transaction(snapshot.record.credentialId, async tx => {
64
+ let previous;
65
+ try { previous = connectionFromSecret(await tx.get()); }
66
+ catch (error) { if (!['CREDENTIAL_MISSING', 'CREDENTIAL_INVALID'].includes(error.code)) throw error; }
67
+ return { previous, credentialRevision: tx.revision };
68
+ });
69
+ return { ...snapshot, ...result };
70
+ }
71
+
72
+ export async function saveConfig(target, input, { overwrite = false, expected, store = defaultStore, writeRecord = writeConfigFile } = {}) {
73
+ const config = validateConfig(input);
74
+ const canonical = path.join(await fs.realpath(path.dirname(target)), path.basename(target));
75
+ const lockKey = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
76
+ return withLock(`config:${lockKey}`, async () => {
77
+ const actual = await readConfigRecord(target, { allowMissing: true });
78
+ if (actual.record && !overwrite) throw new ConfigError('Konfiguracja już istnieje. Użyj configure, aby ją zmienić.');
79
+ if (expected && expected.text !== actual.text) throw new ConfigError('Plik konfiguracji został zmieniony podczas operacji. Ponów configure.', 'CONFIG_CONFLICT');
80
+ const record = { schemaVersion: 2, credentialId: actual.record?.schemaVersion === 2 ? actual.record.credentialId : randomUUID() };
81
+ const { cleanupPending } = await store.transaction(record.credentialId, async tx => {
82
+ if (expected && Object.hasOwn(expected, 'credentialRevision') && expected.credentialRevision !== tx.revision) {
83
+ throw new ConfigError('Poświadczenia zmieniły się podczas konfiguracji. Ponów configure.', 'CONFIG_CONFLICT');
84
+ }
85
+ await tx.set(JSON.stringify(config));
86
+ await writeRecord(target, record, actual.text);
87
+ });
88
+ return { ...record, cleanupPending };
89
+ }, store.lockOptions);
90
+ }
91
+
92
+ export async function configStatus(target, { store = defaultStore } = {}) {
93
+ const { record, permissions } = await readConfigRecord(target);
94
+ if (record.schemaVersion === 1) return { schemaVersion: 1, credentials: 'invalid', permissions: `${permissions}: OK` };
95
+ try { connectionFromSecret(await store.get(record.credentialId)); }
96
+ catch (error) {
97
+ if (error.code !== 'CREDENTIAL_INVALID') throw error;
98
+ return { ...record, credentials: 'invalid', storage: 'system-keyring', permissions: `${permissions}: OK` };
99
+ }
100
+ return { ...record, credentials: 'configured', storage: 'system-keyring', permissions: `${permissions}: OK` };
101
+ }
102
+ export function apiOptions(config) { return { baseUrl: config.baseUrl, token: config.token, ...config.basicAuth }; }
@@ -0,0 +1,67 @@
1
+ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
2
+ import { ConfigError } from './config-errors.mjs';
3
+
4
+ export const SERVICE = 'matomo-mcp-ro';
5
+ export const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
6
+ export const MAX_SECRET_BYTES = 128 * 1024;
7
+ // Ciphertext is base64; reserve space for the fixed envelope fields and salt/IV/tag.
8
+ export const MAX_ENVELOPE_BYTES = Math.ceil(MAX_SECRET_BYTES / 3) * 4 + 512;
9
+ const ALGORITHM = 'aes-256-gcm';
10
+ const KDF = 'hkdf-sha256';
11
+ const FIELDS = ['algorithm', 'ciphertext', 'iv', 'kdf', 'salt', 'tag', 'version'].join(',');
12
+ const invalid = () => new ConfigError('Nieprawidłowa zaszyfrowana konfiguracja keyringu. Uruchom configure i podaj pełne dane.', 'CREDENTIAL_INVALID');
13
+
14
+ function context(id, service) {
15
+ if (typeof id !== 'string' || !UUID_V4.test(id) || typeof service !== 'string' || !service) throw invalid();
16
+ return Buffer.from(JSON.stringify([service, 2, ALGORITHM, KDF, id]), 'utf8');
17
+ }
18
+ function deriveKey(id, salt, info) {
19
+ return Buffer.from(hkdfSync('sha256', Buffer.from(id, 'utf8'), salt, info, 32));
20
+ }
21
+ function decodeBase64(value, size) {
22
+ if (typeof value !== 'string' || !value) throw invalid();
23
+ const decoded = Buffer.from(value, 'base64');
24
+ if (decoded.toString('base64') !== value || size !== undefined && decoded.length !== size) throw invalid();
25
+ return decoded;
26
+ }
27
+
28
+ // The UUID is public: this envelope is an additional masking/authentication layer,
29
+ // while the OS keyring remains the access boundary. No separate secret is stored.
30
+ export function encryptCredential(raw, id, service = SERVICE) {
31
+ const info = context(id, service);
32
+ if (typeof raw !== 'string' || !raw || Buffer.byteLength(raw, 'utf8') > MAX_SECRET_BYTES) throw invalid();
33
+ const salt = randomBytes(32), iv = randomBytes(12);
34
+ const key = deriveKey(id, salt, info);
35
+ try {
36
+ const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: 16 });
37
+ cipher.setAAD(info);
38
+ const ciphertext = Buffer.concat([cipher.update(raw, 'utf8'), cipher.final()]);
39
+ return JSON.stringify({ version: 2, algorithm: ALGORITHM, kdf: KDF,
40
+ salt: salt.toString('base64'), iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'),
41
+ ciphertext: ciphertext.toString('base64') });
42
+ } catch { throw invalid(); }
43
+ finally { key.fill(0); }
44
+ }
45
+
46
+ export function decryptCredential(raw, id, service = SERVICE) {
47
+ const info = context(id, service);
48
+ let key;
49
+ try {
50
+ if (typeof raw !== 'string' || !raw || Buffer.byteLength(raw, 'utf8') > MAX_ENVELOPE_BYTES) throw invalid();
51
+ const payload = JSON.parse(raw);
52
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)
53
+ || Object.keys(payload).sort().join(',') !== FIELDS
54
+ || payload.version !== 2 || payload.algorithm !== ALGORITHM || payload.kdf !== KDF) throw invalid();
55
+ const salt = decodeBase64(payload.salt, 32), iv = decodeBase64(payload.iv, 12);
56
+ const tag = decodeBase64(payload.tag, 16), ciphertext = decodeBase64(payload.ciphertext);
57
+ if (ciphertext.length > MAX_SECRET_BYTES) throw invalid();
58
+ key = deriveKey(id, salt, info);
59
+ const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: 16 });
60
+ decipher.setAAD(info);
61
+ decipher.setAuthTag(tag);
62
+ // No plaintext is returned until final() has authenticated the entire record.
63
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
64
+ return new TextDecoder('utf-8', { fatal: true }).decode(plaintext);
65
+ } catch { throw invalid(); }
66
+ finally { key?.fill(0); }
67
+ }
@@ -0,0 +1,182 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { ConfigError } from './config-errors.mjs';
4
+ import { withLock } from './locks.mjs';
5
+ import { discoverSessionBus } from './session-env.mjs';
6
+ import { SERVICE, UUID_V4, MAX_SECRET_BYTES, MAX_ENVELOPE_BYTES, encryptCredential, decryptCredential } from './credential-encryption.mjs';
7
+
8
+ export { SERVICE, UUID_V4, MAX_SECRET_BYTES };
9
+ // Base64 ASCII -> at most 2400 UTF-16LE bytes, below Windows' 2560-byte limit.
10
+ export const CHUNK_CHARACTERS = 1200;
11
+ const MAX_PARTS = Math.ceil(Math.ceil(MAX_ENVELOPE_BYTES / 3) * 4 / CHUNK_CHARACTERS);
12
+ const digest = text => createHash('sha256').update(text).digest('hex');
13
+ const manifestSchema = z.object({
14
+ storageVersion: z.union([z.literal(1), z.literal(2)]), generation: z.string().regex(UUID_V4),
15
+ parts: z.number().int().min(1).max(MAX_PARTS),
16
+ bytes: z.number().int().min(1).max(MAX_ENVELOPE_BYTES),
17
+ sha256: z.string().regex(/^[0-9a-f]{64}$/),
18
+ }).strict();
19
+ const invalid = () => new ConfigError('Wpis keyringu jest nieprawidłowy lub niekompletny. Uruchom configure, aby podać pełne dane.', 'CREDENTIAL_INVALID');
20
+ function manifest(text) {
21
+ try {
22
+ if (typeof text !== 'string' || Buffer.byteLength(text, 'utf16le') > 2560) throw new Error();
23
+ const result = manifestSchema.parse(JSON.parse(text));
24
+ if (result.storageVersion === 1 && result.bytes > MAX_SECRET_BYTES) throw new Error();
25
+ if (result.parts !== Math.ceil(Math.ceil(result.bytes / 3) * 4 / CHUNK_CHARACTERS)) throw new Error();
26
+ return result;
27
+ } catch { throw invalid(); }
28
+ }
29
+ const partKey = (id, header, index) => `${id}:${header.generation}:${index}`;
30
+
31
+ async function nativeEntry(service, account) {
32
+ await discoverSessionBus();
33
+ const { AsyncEntry } = await import('@napi-rs/keyring');
34
+ return new AsyncEntry(service, account, { linux: { store: 'secret-service' } });
35
+ }
36
+
37
+ export class SystemCredentialStore {
38
+ constructor({ entryFactory = nativeEntry, service = SERVICE, lockOptions } = {}) {
39
+ this.entryFactory = entryFactory;
40
+ this.service = service;
41
+ this.lockOptions = lockOptions;
42
+ }
43
+
44
+ async #call(account, method, value) {
45
+ try {
46
+ const entry = await this.entryFactory(this.service, account);
47
+ return await entry[method](...(value === undefined ? [] : [value]));
48
+ } catch {
49
+ throw new ConfigError('Systemowy keyring jest niedostępny lub odmówił operacji. Sprawdź dostęp i odblokowanie magazynu; na Linux również Secret Service oraz sesję D-Bus.', 'KEYRING_UNAVAILABLE');
50
+ }
51
+ }
52
+ async #read(account) {
53
+ const value = await this.#call(account, 'getPassword');
54
+ if (value === undefined || value === null) return null;
55
+ if (typeof value !== 'string') throw invalid();
56
+ return value;
57
+ }
58
+ async #write(account, value) {
59
+ if (Buffer.byteLength(value, 'utf16le') > 2560) throw invalid();
60
+ await this.#call(account, 'setPassword', value);
61
+ }
62
+ async #remove(account) { return this.#call(account, 'deleteCredential'); }
63
+
64
+ async #readPayload(id, root) {
65
+ if (root === null) return null;
66
+ const header = manifest(root);
67
+ // Old manifests are understood for cleanup only; their credentials are never read.
68
+ if (header.storageVersion !== 2) throw invalid();
69
+ const parts = [];
70
+ for (let index = 0; index < header.parts; index++) {
71
+ const text = await this.#read(partKey(id, header, index));
72
+ if (!text || text.length > CHUNK_CHARACTERS || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)
73
+ || index < header.parts - 1 && text.length !== CHUNK_CHARACTERS) throw invalid();
74
+ parts.push(text);
75
+ }
76
+ const base64 = parts.join('');
77
+ const bytes = Buffer.from(base64, 'base64');
78
+ if (bytes.length !== header.bytes || bytes.toString('base64') !== base64 || digest(bytes) !== header.sha256) throw invalid();
79
+ let text;
80
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
81
+ catch { throw invalid(); }
82
+ return decryptCredential(text, id, this.service);
83
+ }
84
+
85
+ async #cleanup(accounts) {
86
+ let pending = false;
87
+ for (const account of accounts) {
88
+ try {
89
+ await this.#remove(account);
90
+ if (await this.#read(account) !== null) pending = true;
91
+ } catch { pending = true; }
92
+ }
93
+ return pending;
94
+ }
95
+
96
+ // Keeps old fragments until the callback (including the metadata file commit)
97
+ // succeeds. Readers share this lock, including references in other projects.
98
+ async transaction(id, operation) {
99
+ if (typeof id !== 'string' || !UUID_V4.test(id)) throw new ConfigError('Nieprawidłowy identyfikator wpisu keyringu.');
100
+ return withLock(`credential:${this.service}:${id}`, async () => {
101
+ const before = await this.#read(id);
102
+ let current = before;
103
+ let changed = false;
104
+ let attempted = false;
105
+ const staged = [];
106
+ let result;
107
+ const tx = {
108
+ revision: before === null ? null : digest(before),
109
+ get: () => this.#readPayload(id, current),
110
+ set: async text => {
111
+ if (attempted) throw new ConfigError('Jedna operacja może zmienić wpis tylko raz.');
112
+ if (typeof text !== 'string' || !text.length || Buffer.byteLength(text, 'utf8') > MAX_SECRET_BYTES) throw invalid();
113
+ try { JSON.parse(text); } catch { throw invalid(); }
114
+ attempted = true;
115
+ const bytes = Buffer.from(encryptCredential(text, id, this.service), 'utf8');
116
+ const encoded = bytes.toString('base64');
117
+ const header = { storageVersion: 2, generation: randomUUID(), parts: Math.ceil(encoded.length / CHUNK_CHARACTERS), bytes: bytes.length, sha256: digest(bytes) };
118
+ const next = JSON.stringify(header);
119
+ for (let index = 0; index < header.parts; index++) {
120
+ const account = partKey(id, header, index);
121
+ const chunk = encoded.slice(index * CHUNK_CHARACTERS, (index + 1) * CHUNK_CHARACTERS);
122
+ staged.push(account); // Also clean writes that throw after committing.
123
+ await this.#write(account, chunk);
124
+ if (await this.#read(account) !== chunk) throw invalid();
125
+ }
126
+ if (await this.#readPayload(id, next) !== text) throw invalid();
127
+ if (await this.#read(id) !== before) throw new ConfigError('Wpis keyringu zmienił się podczas operacji. Ponów konfigurację.', 'CONFIG_CONFLICT');
128
+ current = next; // Record the attempted commit even if the native call throws.
129
+ changed = true;
130
+ await this.#write(id, next);
131
+ if (await this.#read(id) !== next) throw invalid();
132
+ },
133
+ delete: async () => {
134
+ if (attempted) throw new ConfigError('Jedna operacja może zmienić wpis tylko raz.');
135
+ attempted = true;
136
+ if (before === null) return false;
137
+ if (await this.#read(id) !== before) throw new ConfigError('Wpis keyringu zmienił się podczas operacji.', 'CONFIG_CONFLICT');
138
+ current = null;
139
+ changed = true;
140
+ await this.#remove(id);
141
+ if (await this.#read(id) !== null) throw invalid();
142
+ return true;
143
+ },
144
+ };
145
+ try { result = await operation(tx); }
146
+ catch (error) {
147
+ let recovered = true;
148
+ if (changed) {
149
+ try {
150
+ const actual = await this.#read(id);
151
+ if (actual === current) {
152
+ if (before === null) await this.#remove(id);
153
+ else await this.#write(id, before);
154
+ } else if (actual !== before) throw new Error();
155
+ if (await this.#read(id) !== before) throw new Error();
156
+ } catch { recovered = false; }
157
+ }
158
+ // On uncertain root recovery, retain fragments the root might still need.
159
+ if (recovered && await this.#cleanup(staged)) recovered = false;
160
+ if (!recovered) throw new ConfigError('Operacja nie powiodła się; nie udało się potwierdzić pełnego przywrócenia lub usunięcia nowych wpisów keyringu. Sprawdź status przed ponowieniem. Konfiguracja może wymagać naprawy.', 'KEYRING_RECOVERY_FAILED');
161
+ if (error instanceof ConfigError) throw error;
162
+ throw new ConfigError('Nie można zatwierdzić konfiguracji; poprzedni wpis keyringu został zachowany.', 'CONFIG_WRITE_FAILED');
163
+ }
164
+ let cleanupPending = false;
165
+ if (changed && before !== null) {
166
+ let old;
167
+ try { old = manifest(before); } catch { /* A repaired corrupt root has no trustworthy fragment list. */ }
168
+ if (old) cleanupPending = await this.#cleanup(Array.from({ length: old.parts }, (_, index) => partKey(id, old, index)));
169
+ else cleanupPending = true;
170
+ }
171
+ return { result, cleanupPending };
172
+ }, this.lockOptions);
173
+ }
174
+
175
+ async get(id) { return (await this.transaction(id, tx => tx.get())).result; }
176
+ async set(id, text) { return this.transaction(id, tx => tx.set(text)); }
177
+ async delete(id) {
178
+ const { result, cleanupPending } = await this.transaction(id, tx => tx.delete());
179
+ if (cleanupPending) throw new ConfigError('Wpis główny został usunięty, ale część jego fragmentów mogła pozostać w keyringu.', 'KEYRING_CLEANUP_FAILED');
180
+ return result;
181
+ }
182
+ }