artifacty 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +50 -8
- package/README.md +51 -9
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/docs/README.md +6 -0
- package/docs/ko/README.md +6 -0
- package/docs/ko/language-packs.md +40 -0
- package/docs/ko/smtp.md +27 -0
- package/docs/language-packs.md +38 -0
- package/docs/roadmap-design.md +31 -2
- package/docs/smtp.md +25 -0
- package/package.json +10 -1
- package/scripts/build-ui.mjs +16 -0
- package/src/cli.js +4 -4
- package/src/client/api-docs.js +32 -0
- package/src/client/auto-apply.js +20 -0
- package/src/client/sidebar.js +31 -0
- package/src/client/theme.js +42 -0
- package/src/client/ui-controls.css +2 -0
- package/src/client/viewer.js +17 -6
- package/src/lib/events.js +4 -4
- package/src/lib/i18n.js +497 -11
- package/src/lib/language-settings.js +114 -0
- package/src/lib/openapi.js +93 -74
- package/src/lib/render.js +551 -397
- package/src/lib/smtp-client.js +206 -0
- package/src/lib/smtp-settings.js +109 -0
- package/src/lib/storage.js +71 -17
- package/src/lib/ui-icons.js +32 -0
- package/src/mcp-server.js +3 -3
- package/src/server.js +213 -16
- package/src/styles/ui-controls.scss +63 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import tls from "node:tls";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
const ERRORS = {
|
|
6
|
+
SMTP_CONFIG_INVALID: "SMTP configuration or message is invalid.",
|
|
7
|
+
SMTP_CONNECT_FAILED: "Could not connect to the SMTP server.",
|
|
8
|
+
SMTP_TLS_FAILED: "Could not establish a verified TLS connection to the SMTP server.",
|
|
9
|
+
SMTP_AUTH_FAILED: "SMTP authentication failed.",
|
|
10
|
+
SMTP_PROTOCOL_ERROR: "The SMTP server returned an invalid or unexpected response.",
|
|
11
|
+
SMTP_REJECTED: "The SMTP server rejected the message.",
|
|
12
|
+
SMTP_TIMEOUT: "The SMTP operation timed out.",
|
|
13
|
+
SMTP_DELIVERY_UNKNOWN: "The SMTP connection ended before message acceptance could be confirmed. Delivery may have occurred."
|
|
14
|
+
};
|
|
15
|
+
const MAX_REPLY_BYTES = 64 * 1024;
|
|
16
|
+
|
|
17
|
+
function smtpError(code) {
|
|
18
|
+
return Object.assign(new Error(ERRORS[code]), { code, ...(code === "SMTP_DELIVERY_UNKNOWN" ? { deliveryUnknown: true } : {}) });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mailbox(value) {
|
|
22
|
+
if (typeof value !== "string" || value.length > 254 || !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+$/.test(value)) return false;
|
|
23
|
+
const [local, domain] = value.split("@");
|
|
24
|
+
return local.length <= 64 && !local.startsWith(".") && !local.endsWith(".") && !local.includes("..") &&
|
|
25
|
+
domain.split(".").every((label) => label.length > 0 && label.length <= 63 && /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validate(config, message) {
|
|
29
|
+
const bad = () => { throw smtpError("SMTP_CONFIG_INVALID"); };
|
|
30
|
+
if (!config || !message || typeof config.host !== "string" || !config.host || config.host.length > 253 ||
|
|
31
|
+
(!net.isIP(config.host) && !config.host.split(".").every((label) => /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label))) ||
|
|
32
|
+
!Number.isInteger(config.port) || config.port < 1 || config.port > 65535 || !["tls", "starttls"].includes(config.tlsMode)) bad();
|
|
33
|
+
if (!mailbox(config.from) || !mailbox(message.to)) bad();
|
|
34
|
+
for (const value of [config.username ?? "", config.password ?? "", message.subject]) {
|
|
35
|
+
if (typeof value !== "string" || Buffer.byteLength(value) > 4096 || /[\x00-\x1f\x7f]/.test(value)) bad();
|
|
36
|
+
}
|
|
37
|
+
if (Boolean(config.username) !== Boolean(config.password) || typeof message.text !== "string" || Buffer.byteLength(message.text) > 1024 * 1024) bad();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function encodedSubject(subject) {
|
|
41
|
+
if (!subject) return "";
|
|
42
|
+
const chunks = [];
|
|
43
|
+
let current = "";
|
|
44
|
+
for (const character of subject) {
|
|
45
|
+
if (Buffer.byteLength(current + character) > 42) { chunks.push(current); current = ""; }
|
|
46
|
+
current += character;
|
|
47
|
+
}
|
|
48
|
+
chunks.push(current);
|
|
49
|
+
return chunks.map((chunk) => `=?UTF-8?B?${Buffer.from(chunk).toString("base64")}?=`).join("\r\n ");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function encodeMessage(config, { to, subject, text }) {
|
|
53
|
+
const encoded = Buffer.from(text.replace(/\r\n|\r|\n/g, "\r\n"), "utf8").toString("base64");
|
|
54
|
+
const body = encoded.match(/.{1,76}/g)?.join("\r\n") || "";
|
|
55
|
+
return [
|
|
56
|
+
`From: ${config.from}`, `To: ${to}`, `Subject: ${encodedSubject(subject)}`,
|
|
57
|
+
`Date: ${new Date().toUTCString()}`, `Message-ID: <${randomUUID()}@${config.from.split("@")[1]}>`,
|
|
58
|
+
"MIME-Version: 1.0", 'Content-Type: text/plain; charset="UTF-8"', "Content-Transfer-Encoding: base64", "", body
|
|
59
|
+
].join("\r\n").replace(/^\./gm, "..") + "\r\n.\r\n";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// One bounded response parser per transport; STARTTLS discards the plaintext
|
|
63
|
+
// parser and its capabilities before sending a fresh EHLO over verified TLS.
|
|
64
|
+
function replies(socket) {
|
|
65
|
+
let buffer = "";
|
|
66
|
+
let current = null;
|
|
67
|
+
let failed = null;
|
|
68
|
+
let pending = null;
|
|
69
|
+
const queued = [];
|
|
70
|
+
function fail(error = smtpError("SMTP_CONNECT_FAILED")) {
|
|
71
|
+
if (failed) return;
|
|
72
|
+
failed = error;
|
|
73
|
+
if (pending) { pending.reject(error); pending = null; }
|
|
74
|
+
}
|
|
75
|
+
function onData(data) {
|
|
76
|
+
buffer += data.toString("latin1");
|
|
77
|
+
if (buffer.length + (current?.bytes || 0) > MAX_REPLY_BYTES) { fail(smtpError("SMTP_PROTOCOL_ERROR")); socket.destroy(); return; }
|
|
78
|
+
let end;
|
|
79
|
+
while ((end = buffer.indexOf("\r\n")) >= 0) {
|
|
80
|
+
const line = buffer.slice(0, end);
|
|
81
|
+
buffer = buffer.slice(end + 2);
|
|
82
|
+
const match = /^([2-5]\d{2})([ -])([^\r\n]*)$/.exec(line);
|
|
83
|
+
if (!match || (current && current.code !== Number(match[1]))) { fail(smtpError("SMTP_PROTOCOL_ERROR")); socket.destroy(); return; }
|
|
84
|
+
current ||= { code: Number(match[1]), lines: [], bytes: 0 };
|
|
85
|
+
current.lines.push(match[3]);
|
|
86
|
+
current.bytes += line.length + 2;
|
|
87
|
+
if (match[2] === " ") {
|
|
88
|
+
const reply = current;
|
|
89
|
+
current = null;
|
|
90
|
+
if (pending) { pending.resolve(reply); pending = null; }
|
|
91
|
+
else queued.push(reply);
|
|
92
|
+
if (queued.length > 8) { fail(smtpError("SMTP_PROTOCOL_ERROR")); socket.destroy(); return; }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const onError = () => fail();
|
|
97
|
+
const onClose = () => fail();
|
|
98
|
+
socket.on("data", onData);
|
|
99
|
+
socket.on("error", onError);
|
|
100
|
+
socket.on("close", onClose);
|
|
101
|
+
return {
|
|
102
|
+
next() {
|
|
103
|
+
if (failed) return Promise.reject(failed);
|
|
104
|
+
if (queued.length) return Promise.resolve(queued.shift());
|
|
105
|
+
return new Promise((resolve, reject) => { pending = { resolve, reject }; });
|
|
106
|
+
},
|
|
107
|
+
detach() {
|
|
108
|
+
if (buffer || current || queued.length || pending) throw smtpError("SMTP_PROTOCOL_ERROR");
|
|
109
|
+
socket.off("data", onData); socket.off("error", onError); socket.off("close", onClose);
|
|
110
|
+
},
|
|
111
|
+
fail
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Send one message, without retries. options.ca is solely an explicit caller
|
|
116
|
+
* trust override for isolated tests; production settings must not expose it. */
|
|
117
|
+
export async function sendSmtpMail(config, message, options = {}) {
|
|
118
|
+
validate(config, message);
|
|
119
|
+
const timeoutMs = Math.min(15000, options.timeoutMs ?? 15000);
|
|
120
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1) throw smtpError("SMTP_CONFIG_INVALID");
|
|
121
|
+
let socket;
|
|
122
|
+
let reader;
|
|
123
|
+
let timer;
|
|
124
|
+
let deliveryStarted = false;
|
|
125
|
+
let accepted = false;
|
|
126
|
+
let timeoutReject;
|
|
127
|
+
const timeout = new Promise((_, reject) => { timeoutReject = reject; });
|
|
128
|
+
timer = setTimeout(() => {
|
|
129
|
+
const error = smtpError(deliveryStarted ? "SMTP_DELIVERY_UNKNOWN" : "SMTP_TIMEOUT");
|
|
130
|
+
timeoutReject(error);
|
|
131
|
+
reader?.fail(error);
|
|
132
|
+
socket?.destroy();
|
|
133
|
+
}, timeoutMs);
|
|
134
|
+
const bounded = async (operation) => {
|
|
135
|
+
let commandTimer;
|
|
136
|
+
try {
|
|
137
|
+
return await Promise.race([operation, timeout, new Promise((_, reject) => {
|
|
138
|
+
commandTimer = setTimeout(() => reject(smtpError(deliveryStarted ? "SMTP_DELIVERY_UNKNOWN" : "SMTP_TIMEOUT")), Math.min(timeoutMs, 5000));
|
|
139
|
+
})]);
|
|
140
|
+
} finally { clearTimeout(commandTimer); }
|
|
141
|
+
};
|
|
142
|
+
const connect = async (secure, underlying) => {
|
|
143
|
+
socket = secure ? tls.connect({
|
|
144
|
+
...(underlying ? { socket: underlying } : { port: config.port }),
|
|
145
|
+
host: config.host, servername: net.isIP(config.host) ? undefined : config.host,
|
|
146
|
+
rejectUnauthorized: true, minVersion: "TLSv1.2", ...(options.ca ? { ca: options.ca } : {}),
|
|
147
|
+
checkServerIdentity: (_hostname, certificate) => tls.checkServerIdentity(config.host, certificate)
|
|
148
|
+
}) : net.connect({ host: config.host, port: config.port });
|
|
149
|
+
reader = replies(socket);
|
|
150
|
+
await bounded(new Promise((resolve, reject) => {
|
|
151
|
+
const event = secure ? "secureConnect" : "connect";
|
|
152
|
+
const onError = () => { socket.off(event, onReady); reject(smtpError(secure ? "SMTP_TLS_FAILED" : "SMTP_CONNECT_FAILED")); };
|
|
153
|
+
const onReady = () => { socket.off("error", onError); resolve(); };
|
|
154
|
+
socket.once(event, onReady); socket.once("error", onError);
|
|
155
|
+
}));
|
|
156
|
+
};
|
|
157
|
+
const expect = async (codes, code = "SMTP_PROTOCOL_ERROR") => {
|
|
158
|
+
const response = await bounded(reader.next());
|
|
159
|
+
if (!codes.includes(response.code)) throw smtpError(code);
|
|
160
|
+
return response;
|
|
161
|
+
};
|
|
162
|
+
const command = async (text, codes, errorCode) => {
|
|
163
|
+
socket.write(`${text}\r\n`);
|
|
164
|
+
return expect(codes, errorCode);
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
await connect(config.tlsMode === "tls");
|
|
168
|
+
await expect([220]);
|
|
169
|
+
let hello = await command("EHLO artifacty.local", [250]);
|
|
170
|
+
if (config.tlsMode === "starttls") {
|
|
171
|
+
if (!hello.lines.some((line) => /^STARTTLS(?:\s|$)/i.test(line))) throw smtpError("SMTP_TLS_FAILED");
|
|
172
|
+
await command("STARTTLS", [220], "SMTP_TLS_FAILED");
|
|
173
|
+
reader.detach();
|
|
174
|
+
await connect(true, socket);
|
|
175
|
+
hello = await command("EHLO artifacty.local", [250]);
|
|
176
|
+
}
|
|
177
|
+
if (config.username) {
|
|
178
|
+
const methods = hello.lines.flatMap((line) => /^AUTH(?:=|\s+)(.*)$/i.exec(line)?.[1].toUpperCase().split(/\s+/) || []);
|
|
179
|
+
if (methods.includes("PLAIN")) {
|
|
180
|
+
const credentials = Buffer.from(`\0${config.username}\0${config.password}`).toString("base64");
|
|
181
|
+
const response = await command(`AUTH PLAIN ${credentials}`, [235, 334], "SMTP_AUTH_FAILED");
|
|
182
|
+
if (response.code === 334) await command(credentials, [235], "SMTP_AUTH_FAILED");
|
|
183
|
+
} else if (methods.includes("LOGIN")) {
|
|
184
|
+
await command("AUTH LOGIN", [334], "SMTP_AUTH_FAILED");
|
|
185
|
+
await command(Buffer.from(config.username).toString("base64"), [334], "SMTP_AUTH_FAILED");
|
|
186
|
+
await command(Buffer.from(config.password).toString("base64"), [235], "SMTP_AUTH_FAILED");
|
|
187
|
+
} else throw smtpError("SMTP_AUTH_FAILED");
|
|
188
|
+
}
|
|
189
|
+
await command(`MAIL FROM:<${config.from}>`, [250], "SMTP_REJECTED");
|
|
190
|
+
await command(`RCPT TO:<${message.to}>`, [250, 251, 252], "SMTP_REJECTED");
|
|
191
|
+
await command("DATA", [354], "SMTP_REJECTED");
|
|
192
|
+
deliveryStarted = true;
|
|
193
|
+
socket.write(encodeMessage(config, message));
|
|
194
|
+
await expect([250], "SMTP_REJECTED");
|
|
195
|
+
accepted = true;
|
|
196
|
+
// Acceptance is final; failed QUIT must never turn success into a retry.
|
|
197
|
+
try { socket.end("QUIT\r\n"); } catch { /* DATA acceptance remains final. */ }
|
|
198
|
+
return { accepted: true };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (deliveryStarted && !accepted && error.code !== "SMTP_REJECTED") throw smtpError("SMTP_DELIVERY_UNKNOWN");
|
|
201
|
+
throw smtpError(Object.hasOwn(ERRORS, error.code) ? error.code : "SMTP_PROTOCOL_ERROR");
|
|
202
|
+
} finally {
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
socket?.destroy();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
|
|
2
|
+
import { mkdirSync, lstatSync, readFileSync, openSync, writeFileSync, closeSync, fsyncSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { openDatabase, transaction, insertAuditRow } from "./storage.js";
|
|
5
|
+
|
|
6
|
+
const AAD = Buffer.from("artifacty:smtp-settings:v1");
|
|
7
|
+
const defaults = () => ({ enabled: false, host: "", port: 587, tlsMode: "starttls", username: "", password: "", from: "" });
|
|
8
|
+
const fail = (code, statusCode = 500) => Object.assign(new Error(code === "SMTP_KEY_UNAVAILABLE"
|
|
9
|
+
? "SMTP encryption key is unavailable. Restore the original secrets/smtp.key before changing settings."
|
|
10
|
+
: code === "SMTP_SETTINGS_INVALID" ? "Invalid SMTP settings. Check required fields and TLS configuration."
|
|
11
|
+
: "SMTP settings could not be accessed securely."), { code, statusCode });
|
|
12
|
+
|
|
13
|
+
function checkedPath(file, directory, mode) {
|
|
14
|
+
let info;
|
|
15
|
+
try { info = lstatSync(file); } catch (error) { if (error.code === "ENOENT") return false; throw fail("SMTP_KEY_UNAVAILABLE"); }
|
|
16
|
+
if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile()) ||
|
|
17
|
+
(process.platform !== "win32" && ((info.mode & 0o777) !== mode || (process.getuid && info.uid !== process.getuid())))) {
|
|
18
|
+
throw fail("SMTP_KEY_UNSAFE");
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function keyFor(store, create) {
|
|
24
|
+
const directory = path.join(store.home, "secrets");
|
|
25
|
+
const file = path.join(directory, "smtp.key");
|
|
26
|
+
if (!checkedPath(directory, true, 0o700)) {
|
|
27
|
+
if (!create) throw fail("SMTP_KEY_UNAVAILABLE");
|
|
28
|
+
mkdirSync(directory, { mode: 0o700 });
|
|
29
|
+
checkedPath(directory, true, 0o700);
|
|
30
|
+
}
|
|
31
|
+
if (!checkedPath(file, false, 0o600)) {
|
|
32
|
+
if (!create) throw fail("SMTP_KEY_UNAVAILABLE");
|
|
33
|
+
let descriptor;
|
|
34
|
+
try {
|
|
35
|
+
descriptor = openSync(file, "wx", 0o600);
|
|
36
|
+
writeFileSync(descriptor, randomBytes(32));
|
|
37
|
+
fsyncSync(descriptor);
|
|
38
|
+
} catch (error) { if (error.code !== "EEXIST") throw fail("SMTP_KEY_UNAVAILABLE"); }
|
|
39
|
+
finally { if (descriptor !== undefined) closeSync(descriptor); }
|
|
40
|
+
}
|
|
41
|
+
checkedPath(file, false, 0o600);
|
|
42
|
+
let key;
|
|
43
|
+
try { key = readFileSync(file); } catch { throw fail("SMTP_KEY_UNAVAILABLE"); }
|
|
44
|
+
if (key.length !== 32) throw fail("SMTP_KEY_UNAVAILABLE");
|
|
45
|
+
return key;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validate(input) {
|
|
49
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || typeof input.enabled !== "boolean") throw fail("SMTP_SETTINGS_INVALID", 400);
|
|
50
|
+
for (const field of ["host", "username", "password", "from"]) {
|
|
51
|
+
if (typeof input[field] !== "string" || Buffer.byteLength(input[field], "utf8") > 4096 || /[\r\n\0]/.test(input[field])) throw fail("SMTP_SETTINGS_INVALID", 400);
|
|
52
|
+
}
|
|
53
|
+
if (Boolean(input.username) !== Boolean(input.password)) throw fail("SMTP_SETTINGS_INVALID", 400);
|
|
54
|
+
if (!input.host.trim() || /[\s/@?#]/.test(input.host) || !/^[^\s<>@,;]+@[^\s<>@,;]+\.[^\s<>@,;]+$/.test(input.from) ||
|
|
55
|
+
!Number.isInteger(input.port) || input.port < 1 || input.port > 65535 || !["tls", "starttls"].includes(input.tlsMode)) throw fail("SMTP_SETTINGS_INVALID", 400);
|
|
56
|
+
return { enabled: input.enabled, host: input.host, port: input.port, tlsMode: input.tlsMode, username: input.username, password: input.password, from: input.from };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readConfig(db, store) {
|
|
60
|
+
const row = db.prepare("SELECT value FROM meta WHERE key='smtp_settings'").get();
|
|
61
|
+
if (!row) return defaults();
|
|
62
|
+
const key = keyFor(store, false);
|
|
63
|
+
try {
|
|
64
|
+
const envelope = JSON.parse(row.value);
|
|
65
|
+
if (envelope.v !== 1 || !["iv", "tag", "ciphertext"].every((field) => typeof envelope[field] === "string")) throw new Error();
|
|
66
|
+
const iv = Buffer.from(envelope.iv, "base64");
|
|
67
|
+
const tag = Buffer.from(envelope.tag, "base64");
|
|
68
|
+
if (iv.length !== 12 || tag.length !== 16) throw new Error();
|
|
69
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
70
|
+
decipher.setAAD(AAD);
|
|
71
|
+
decipher.setAuthTag(tag);
|
|
72
|
+
return validate(JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64")), decipher.final()]).toString("utf8")));
|
|
73
|
+
} catch { throw fail("SMTP_SETTINGS_UNREADABLE"); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function redact(config) {
|
|
77
|
+
const { password, ...settings } = config;
|
|
78
|
+
return { ...settings, hasPassword: Boolean(password) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function getSmtpDeliveryConfig(store) {
|
|
82
|
+
const db = openDatabase(store);
|
|
83
|
+
try { return readConfig(db, store); } finally { db.close(); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function getSmtpSettings(store) {
|
|
87
|
+
return redact(await getSmtpDeliveryConfig(store));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function saveSmtpSettings(store, input, { access, audit } = {}) {
|
|
91
|
+
const db = openDatabase(store);
|
|
92
|
+
try {
|
|
93
|
+
return transaction(db, () => {
|
|
94
|
+
const actor = db.prepare("SELECT id,email FROM users WHERE id=? AND active=1 AND role='admin'").get(access?.userId || "");
|
|
95
|
+
if (!actor) throw Object.assign(new Error("An active administrator is required"), { code: "ADMIN_REQUIRED", statusCode: 403 });
|
|
96
|
+
const previous = readConfig(db, store);
|
|
97
|
+
const config = validate({ ...input, password: input?.username === "" ? "" : input?.password === "" ? previous.password : input?.password });
|
|
98
|
+
const key = keyFor(store, true);
|
|
99
|
+
const iv = randomBytes(12);
|
|
100
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
101
|
+
cipher.setAAD(AAD);
|
|
102
|
+
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(config), "utf8"), cipher.final()]);
|
|
103
|
+
const envelope = { v: 1, iv: iv.toString("base64"), tag: cipher.getAuthTag().toString("base64"), ciphertext: ciphertext.toString("base64") };
|
|
104
|
+
db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES ('smtp_settings',?)").run(JSON.stringify(envelope));
|
|
105
|
+
insertAuditRow(db, { action: "smtp-settings-change", artifactId: "", audit: { surface: audit?.surface, actor: actor.email, userId: actor.id }, metadata: { enabled: config.enabled, tlsMode: config.tlsMode } });
|
|
106
|
+
return redact(config);
|
|
107
|
+
});
|
|
108
|
+
} finally { db.close(); }
|
|
109
|
+
}
|
package/src/lib/storage.js
CHANGED
|
@@ -85,7 +85,7 @@ export const ARTIFACT_TYPES = [
|
|
|
85
85
|
|
|
86
86
|
export const RELATION_TYPES = ["derived-from", "supersedes", "reviews", "references", "part-of"];
|
|
87
87
|
|
|
88
|
-
export const VISIBILITY_VALUES = ["private", "team"];
|
|
88
|
+
export const VISIBILITY_VALUES = ["private", "team", "public"];
|
|
89
89
|
|
|
90
90
|
// Allowlisted filter keys a saved view may capture. Keeps saved views forward
|
|
91
91
|
// compatible with filters that are recognized but not yet acted on (e.g.
|
|
@@ -216,13 +216,13 @@ export function visibilityClause(prefix, access) {
|
|
|
216
216
|
const isAdmin = access.role === "admin" ? 1 : 0;
|
|
217
217
|
return {
|
|
218
218
|
sql: `((COALESCE((SELECT value FROM meta WHERE key = 'access_mode'), 'public') != 'private'
|
|
219
|
-
AND ${prefix}visibility != '
|
|
219
|
+
AND (${prefix}visibility = 'public' OR (${prefix}visibility = 'team' AND ? != '')))
|
|
220
220
|
OR (? != '' AND ${prefix}owner_user_id = ?) OR ? = 1
|
|
221
221
|
OR (COALESCE((SELECT value FROM meta WHERE key = 'access_mode'), 'public') = 'private'
|
|
222
222
|
AND ? != '' AND EXISTS (SELECT 1 FROM artifact_shares s
|
|
223
223
|
WHERE s.artifact_id = ${prefix}id AND
|
|
224
224
|
(s.user_id = ? OR s.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)))))`,
|
|
225
|
-
params: [userId, userId, isAdmin, userId, userId, userId]
|
|
225
|
+
params: [userId, userId, userId, isAdmin, userId, userId, userId]
|
|
226
226
|
};
|
|
227
227
|
}
|
|
228
228
|
|
|
@@ -451,9 +451,14 @@ export async function previewAccessMode(store = createStore(), mode, { access }
|
|
|
451
451
|
if (!["public", "private"].includes(mode)) throw Object.assign(new Error("Access mode must be public or private"), { statusCode: 400 });
|
|
452
452
|
const db = openDatabase(store);
|
|
453
453
|
try {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
return { mode, anonymousArtifactCount:
|
|
454
|
+
// Simulate anonymous dashboard visibility under the proposed mode without
|
|
455
|
+
// consulting or changing the currently configured access mode.
|
|
456
|
+
if (mode === "private") return { mode, anonymousArtifactCount: 0, total: 0, artifacts: [] };
|
|
457
|
+
const eligibility = "archived_at IS NULL AND (visibility = 'public' OR (SELECT COUNT(*) FROM users) = 0)";
|
|
458
|
+
const count = db.prepare(`SELECT COUNT(*) AS count FROM artifacts WHERE ${eligibility}`).get().count;
|
|
459
|
+
const rows = db.prepare(`SELECT ${artifactColumns()} FROM artifacts WHERE ${eligibility}
|
|
460
|
+
ORDER BY updated_at DESC, created_at DESC, id ASC LIMIT 50`).all();
|
|
461
|
+
return { mode, anonymousArtifactCount: count, total: count, artifacts: rows.map((row) => toArtifactSummary(artifactFromRow(db, row))) };
|
|
457
462
|
} finally { db.close(); }
|
|
458
463
|
}
|
|
459
464
|
|
|
@@ -482,9 +487,8 @@ function canReadArtifactMeta(db, artifact, access) {
|
|
|
482
487
|
AND (user_id = ? OR team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) LIMIT 1`)
|
|
483
488
|
.get(artifact.id, access.userId, access.userId));
|
|
484
489
|
}
|
|
485
|
-
if (artifactVisibilityOf(artifact)
|
|
486
|
-
|
|
487
|
-
}
|
|
490
|
+
if (artifactVisibilityOf(artifact) === "public") return true;
|
|
491
|
+
if (artifactVisibilityOf(artifact) === "team" && access.userId) return true;
|
|
488
492
|
return isOwnerOrAdmin(access, artifact.ownerUserId);
|
|
489
493
|
}
|
|
490
494
|
|
|
@@ -492,12 +496,10 @@ function canWriteArtifactMeta(db, artifact, access) {
|
|
|
492
496
|
if (!access || isSingleUserMode(db)) {
|
|
493
497
|
return true;
|
|
494
498
|
}
|
|
495
|
-
if (accessModeInDb(db) === "private" || artifactVisibilityOf(artifact)
|
|
499
|
+
if (accessModeInDb(db) === "private" || artifactVisibilityOf(artifact) !== "team") {
|
|
496
500
|
return isOwnerOrAdmin(access, artifact.ownerUserId);
|
|
497
501
|
}
|
|
498
|
-
if (access.anonymous)
|
|
499
|
-
return !teamWriteRestrictedToOwner();
|
|
500
|
-
}
|
|
502
|
+
if (access.anonymous || !access.userId) return access.role === "admin";
|
|
501
503
|
if (teamWriteRestrictedToOwner()) {
|
|
502
504
|
return isOwnerOrAdmin(access, artifact.ownerUserId);
|
|
503
505
|
}
|
|
@@ -2526,10 +2528,14 @@ export async function canReadArtifactEvent(store = createStore(), event, access
|
|
|
2526
2528
|
if (!access) return true;
|
|
2527
2529
|
const db = openDatabase(store);
|
|
2528
2530
|
try {
|
|
2529
|
-
if (access.userId
|
|
2531
|
+
if (access.userId) {
|
|
2532
|
+
const user = db.prepare("SELECT role FROM users WHERE id = ? AND active = 1").get(access.userId);
|
|
2533
|
+
if (!user) return false;
|
|
2534
|
+
access = { ...access, role: user.role };
|
|
2535
|
+
}
|
|
2530
2536
|
const row = event.artifactId ? db.prepare("SELECT id, visibility, owner_user_id FROM artifacts WHERE id = ?").get(event.artifactId) : null;
|
|
2531
2537
|
if (row) return canReadArtifactMeta(db, { id: row.id, visibility: row.visibility, ownerUserId: row.owner_user_id }, access);
|
|
2532
|
-
return accessModeInDb(db) !== "private" && eventVisibleTo(event, access);
|
|
2538
|
+
return isSingleUserMode(db) || (accessModeInDb(db) !== "private" && eventVisibleTo(event, access));
|
|
2533
2539
|
} finally { db.close(); }
|
|
2534
2540
|
}
|
|
2535
2541
|
|
|
@@ -2582,7 +2588,7 @@ export async function listEventsSince(store = createStore(), sinceSeq = 0, filte
|
|
|
2582
2588
|
? (row.live_visibility != null
|
|
2583
2589
|
? canReadArtifactMeta(db, { id: row.artifact_id, visibility: row.live_visibility, ownerUserId: row.live_owner_user_id }, access)
|
|
2584
2590
|
: access.role === "admin")
|
|
2585
|
-
: eventVisibleTo(event, access);
|
|
2591
|
+
: (isSingleUserMode(db) || eventVisibleTo(event, access));
|
|
2586
2592
|
if (matchesFilter(event, filter) && visible) {
|
|
2587
2593
|
matched.push(event);
|
|
2588
2594
|
if (matched.length >= limit) {
|
|
@@ -3059,6 +3065,30 @@ export async function listUsers(store = createStore()) {
|
|
|
3059
3065
|
}
|
|
3060
3066
|
}
|
|
3061
3067
|
|
|
3068
|
+
export async function setUserRole(store = createStore(), id, role, { access, audit } = {}) {
|
|
3069
|
+
if (!["admin", "user"].includes(role)) {
|
|
3070
|
+
throw Object.assign(new Error("Role must be admin or user"), { statusCode: 400, code: "INVALID_USER_ROLE" });
|
|
3071
|
+
}
|
|
3072
|
+
const db = openDatabase(store);
|
|
3073
|
+
try {
|
|
3074
|
+
return transaction(db, () => {
|
|
3075
|
+
const actor = db.prepare("SELECT id, email, role, active FROM users WHERE id = ?").get(access?.userId || "");
|
|
3076
|
+
if (!actor?.active || actor.role !== "admin") {
|
|
3077
|
+
throw Object.assign(new Error("An active administrator is required"), { statusCode: 403, code: "ADMIN_REQUIRED" });
|
|
3078
|
+
}
|
|
3079
|
+
const target = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
|
|
3080
|
+
if (!target) throw Object.assign(new Error(`User not found: ${id}`), { statusCode: 404, code: "USER_NOT_FOUND" });
|
|
3081
|
+
if (target.role === role) return userFromRow(target);
|
|
3082
|
+
if (target.active && target.role === "admin" && role === "user" && !db.prepare("SELECT id FROM users WHERE active = 1 AND role = 'admin' AND id != ? LIMIT 1").get(id)) {
|
|
3083
|
+
throw Object.assign(new Error("At least one active administrator must remain"), { statusCode: 409, code: "LAST_ADMIN_REQUIRED" });
|
|
3084
|
+
}
|
|
3085
|
+
db.prepare("UPDATE users SET role = ?, updated_at = ? WHERE id = ?").run(role, new Date().toISOString(), id);
|
|
3086
|
+
insertAuditRow(db, { action: "user-role-change", artifactId: "", audit: { ...audit, actor: actor.email, userId: actor.id }, metadata: { targetUserId: id, fromRole: target.role, toRole: role, actorUserId: actor.id } });
|
|
3087
|
+
return userFromRow(db.prepare("SELECT * FROM users WHERE id = ?").get(id));
|
|
3088
|
+
});
|
|
3089
|
+
} finally { db.close(); }
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3062
3092
|
export async function setUserActive(store = createStore(), id, active) {
|
|
3063
3093
|
const db = openDatabase(store);
|
|
3064
3094
|
try {
|
|
@@ -3075,6 +3105,28 @@ export async function setUserActive(store = createStore(), id, active) {
|
|
|
3075
3105
|
}
|
|
3076
3106
|
}
|
|
3077
3107
|
|
|
3108
|
+
export async function resetUserPassword(store = createStore(), id, { access, audit } = {}) {
|
|
3109
|
+
const db = openDatabase(store);
|
|
3110
|
+
try {
|
|
3111
|
+
return transaction(db, () => {
|
|
3112
|
+
const actor = db.prepare("SELECT id, email, role, active FROM users WHERE id = ?").get(access?.userId || "");
|
|
3113
|
+
if (!actor?.active || actor.role !== "admin") {
|
|
3114
|
+
throw Object.assign(new Error("An active administrator is required"), { statusCode: 403, code: "ADMIN_REQUIRED" });
|
|
3115
|
+
}
|
|
3116
|
+
if (!db.prepare("SELECT id FROM users WHERE id = ?").get(id)) {
|
|
3117
|
+
throw Object.assign(new Error(`User not found: ${id}`), { statusCode: 404, code: "USER_NOT_FOUND" });
|
|
3118
|
+
}
|
|
3119
|
+
const temporaryPassword = generateTemporaryPassword();
|
|
3120
|
+
const now = new Date().toISOString();
|
|
3121
|
+
db.prepare("UPDATE users SET password_hash = ?, password_reset_required = 1, updated_at = ? WHERE id = ?")
|
|
3122
|
+
.run(hashPassword(temporaryPassword), now, id);
|
|
3123
|
+
db.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL").run(now, id);
|
|
3124
|
+
insertAuditRow(db, { action: "user-password-reset", artifactId: "", audit: { ...audit, actor: actor.email, userId: actor.id }, metadata: { targetUserId: id } });
|
|
3125
|
+
return { user: userFromRow(db.prepare("SELECT * FROM users WHERE id = ?").get(id)), temporaryPassword };
|
|
3126
|
+
});
|
|
3127
|
+
} finally { db.close(); }
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3078
3130
|
export async function changeUserPassword(store = createStore(), userId, input = {}) {
|
|
3079
3131
|
const password = String(input.password || input.newPassword || "");
|
|
3080
3132
|
if (password.length < 8) {
|
|
@@ -3258,7 +3310,9 @@ export async function authenticateApiToken(store = createStore(), token) {
|
|
|
3258
3310
|
actor: row.email,
|
|
3259
3311
|
tokenId: row.token_id,
|
|
3260
3312
|
tokenName: row.token_name,
|
|
3261
|
-
|
|
3313
|
+
// A stored admin grant must not outlive the owner's current admin role.
|
|
3314
|
+
scopes: (row.token_scopes_json ? parseJson(row.token_scopes_json, ["read", "write"]) : ["read", "write"])
|
|
3315
|
+
.filter((scope) => scope !== "admin" || row.role === "admin"),
|
|
3262
3316
|
user: userFromRow(row)
|
|
3263
3317
|
};
|
|
3264
3318
|
} finally {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Generated from @tabler/icons v3.49.0 (MIT), by npm run build:ui.
|
|
2
|
+
const paths = {
|
|
3
|
+
"layout-sidebar-left-collapse": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12\" /> <path d=\"M9 4v16\" /> <path d=\"M15 10l-2 2l2 2\" />",
|
|
4
|
+
"layout-sidebar-left-expand": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12\" /> <path d=\"M9 4v16\" /> <path d=\"M14 10l2 2l-2 2\" />",
|
|
5
|
+
"files": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M15 3v4a1 1 0 0 0 1 1h4\" /> <path d=\"M18 17h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h4l5 5v7a2 2 0 0 1 -2 2\" /> <path d=\"M16 17v2a2 2 0 0 1 -2 2h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h2\" />",
|
|
6
|
+
"inbox": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12\" /> <path d=\"M4 13h3l3 3h4l3 -3h3\" />",
|
|
7
|
+
"plus": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M12 5l0 14\" /> <path d=\"M5 12l14 0\" />",
|
|
8
|
+
"upload": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2\" /> <path d=\"M7 9l5 -5l5 5\" /> <path d=\"M12 4l0 12\" />",
|
|
9
|
+
"settings": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065\" /> <path d=\"M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0\" />",
|
|
10
|
+
"users": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M5 7a4 4 0 1 0 8 0a4 4 0 1 0 -8 0\" /> <path d=\"M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2\" /> <path d=\"M16 3.13a4 4 0 0 1 0 7.75\" /> <path d=\"M21 21v-2a4 4 0 0 0 -3 -3.85\" />",
|
|
11
|
+
"database-export": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M4 6c0 1.657 3.582 3 8 3s8 -1.343 8 -3s-3.582 -3 -8 -3s-8 1.343 -8 3\" /> <path d=\"M4 6v6c0 1.657 3.582 3 8 3c1.118 0 2.183 -.086 3.15 -.241\" /> <path d=\"M20 12v-6\" /> <path d=\"M4 12v6c0 1.657 3.582 3 8 3c.157 0 .312 -.002 .466 -.005\" /> <path d=\"M16 19h6\" /> <path d=\"M19 16l3 3l-3 3\" />",
|
|
12
|
+
"archive": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2\" /> <path d=\"M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10\" /> <path d=\"M10 12l4 0\" />",
|
|
13
|
+
"webhook": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M4.876 13.61a4 4 0 1 0 6.124 3.39h6\" /> <path d=\"M15.066 20.502a4 4 0 1 0 1.934 -7.502c-.706 0 -1.424 .179 -2 .5l-3 -5.5\" /> <path d=\"M16 8a4 4 0 1 0 -8 0c0 1.506 .77 2.818 2 3.5l-3 5.5\" />",
|
|
14
|
+
"refresh": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4\" /> <path d=\"M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4\" />",
|
|
15
|
+
"language": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M9 6.371c0 4.418 -2.239 6.629 -5 6.629\" /> <path d=\"M4 6.371h7\" /> <path d=\"M5 9c0 2.144 2.252 3.908 6 4\" /> <path d=\"M12 20l4 -9l4 9\" /> <path d=\"M19.1 18h-6.2\" /> <path d=\"M6.694 3l.793 .582\" />",
|
|
16
|
+
"mail": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10\" /> <path d=\"M3 7l9 6l9 -6\" />",
|
|
17
|
+
"user-circle": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0\" /> <path d=\"M9 10a3 3 0 1 0 6 0a3 3 0 1 0 -6 0\" /> <path d=\"M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855\" />",
|
|
18
|
+
"key": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1 -4.069 0l-.301 -.301l-6.558 6.558a2 2 0 0 1 -1.239 .578l-.175 .008h-1.172a1 1 0 0 1 -.993 -.883l-.007 -.117v-1.172a2 2 0 0 1 .467 -1.284l.119 -.13l.414 -.414h2v-2h2v-2l2.144 -2.144l-.301 -.301a2.877 2.877 0 0 1 0 -4.069l2.643 -2.643a2.877 2.877 0 0 1 4.069 0\" /> <path d=\"M15 9h.01\" />",
|
|
19
|
+
"logout": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2\" /> <path d=\"M9 12h12l-3 -3\" /> <path d=\"M18 15l3 -3\" />",
|
|
20
|
+
"login": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2\" /> <path d=\"M21 12h-13l3 -3\" /> <path d=\"M11 15l-3 -3\" />",
|
|
21
|
+
"code": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M7 8l-4 4l4 4\" /> <path d=\"M17 8l4 4l-4 4\" /> <path d=\"M14 4l-4 16\" />",
|
|
22
|
+
"chevron-down": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M6 9l6 6l6 -6\" />",
|
|
23
|
+
"device-desktop": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10\" /> <path d=\"M7 20h10\" /> <path d=\"M9 16v4\" /> <path d=\"M15 16v4\" />",
|
|
24
|
+
"sun": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0\" /> <path d=\"M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7\" />",
|
|
25
|
+
"moon": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454l0 .008\" />"
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function uiIcon(name, { size = 18 } = {}) {
|
|
29
|
+
if (!Object.hasOwn(paths, name)) throw new Error('Unknown UI icon');
|
|
30
|
+
const dimension = [16, 18, 20, 24].includes(size) ? size : 18;
|
|
31
|
+
return '<svg class="ui-icon" width="' + dimension + '" height="' + dimension + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + paths[name] + '</svg>';
|
|
32
|
+
}
|
package/src/mcp-server.js
CHANGED
|
@@ -100,7 +100,7 @@ const nativeArtifactInputSchema = {
|
|
|
100
100
|
visibility: {
|
|
101
101
|
type: "string",
|
|
102
102
|
enum: VISIBILITY_VALUES,
|
|
103
|
-
description: "'team' (default)
|
|
103
|
+
description: "In public server mode, 'public' allows anonymous reading, 'team' (default) requires a signed-in user, and 'private' is owner/admin only. Private server mode always requires owner/admin or explicit sharing."
|
|
104
104
|
},
|
|
105
105
|
relations: relationsInputSchema
|
|
106
106
|
},
|
|
@@ -264,7 +264,7 @@ const tools = [
|
|
|
264
264
|
visibility: {
|
|
265
265
|
type: "string",
|
|
266
266
|
enum: VISIBILITY_VALUES,
|
|
267
|
-
description: "'team' (default)
|
|
267
|
+
description: "In public server mode, 'public' allows anonymous reading, 'team' (default) requires a signed-in user, and 'private' is owner/admin only. Private server mode always requires owner/admin or explicit sharing. Prefer artifacty_set_visibility to change visibility on its own."
|
|
268
268
|
},
|
|
269
269
|
relations: relationsInputSchema
|
|
270
270
|
},
|
|
@@ -280,7 +280,7 @@ const tools = [
|
|
|
280
280
|
{
|
|
281
281
|
name: "artifacty_set_visibility",
|
|
282
282
|
title: "Set Artifact Visibility",
|
|
283
|
-
description: "Change an artifact's visibility
|
|
283
|
+
description: "Change an artifact's visibility to 'public', 'team', or 'private'. Requires the artifact's owner or an admin.",
|
|
284
284
|
inputSchema: {
|
|
285
285
|
type: "object",
|
|
286
286
|
properties: {
|